From 04f5a13b9dc4dcd79b287c26669125b3c1218047 Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Tue, 21 Jul 2026 13:26:36 +1000 Subject: [PATCH] fix(cli): scaffold EQL v3 encryption client from stash init stash init installs an EQL v3 database but the client scaffolding still emitted v2 authoring patterns (Encryption, encryptedColumn chains, encryptedType flag objects), handing the customer's agent v2 guidance against a v3 schema. Follow-up to #732. - Rewrite DRIZZLE_PLACEHOLDER / GENERIC_PLACEHOLDER to EncryptionV3, the types.* concrete-domain factories, and the stack-drizzle/v3 entry. - Port the live introspection generators (generateClientFromSchemas + generateDrizzleFromSchemas / generateGenericFromSchemas) to v3 via a new v3DomainFactory() mapping SearchOp sets to fixed-capability domains. - Delete the dead singular generators (generateClientFromSchema, generateDrizzleFromSchema, generateSchemaFromDef, drizzleTsType) that had no callers rather than porting v2 dead code. encryptionClient export shape and the empty-schema error path unchanged. --- .changeset/init-placeholder-eql-v3.md | 19 ++ packages/cli/src/commands/init/utils.ts | 259 ++++++++++-------------- 2 files changed, 121 insertions(+), 157 deletions(-) create mode 100644 .changeset/init-placeholder-eql-v3.md diff --git a/.changeset/init-placeholder-eql-v3.md b/.changeset/init-placeholder-eql-v3.md new file mode 100644 index 00000000..b82cf31c --- /dev/null +++ b/.changeset/init-placeholder-eql-v3.md @@ -0,0 +1,19 @@ +--- +'stash': patch +--- + +`stash init` now scaffolds an EQL **v3** encryption client, matching the EQL v3 +database it installs. + +The placeholder client (`DRIZZLE_PLACEHOLDER` / `GENERIC_PLACEHOLDER`) and the +introspection-driven client generator previously emitted EQL v2 authoring +patterns — `Encryption({ schemas })`, `encryptedColumn(...).equality().freeTextSearch()`, +and `encryptedType('x', { equality: true })`. Since init installs a v3 +database, this handed the customer's coding agent v2 guidance against a v3 +schema (follow-up to #732 / #705). + +Scaffolds now teach the v3 surface: `EncryptionV3` from `@cipherstash/stack/v3`, +the concrete-domain `types.*` factories (`types.TextSearch`, `types.IntegerOrd`, +`types.Text`, `types.Json`, …), and the `@cipherstash/stack-drizzle/v3` entry +(`extractEncryptionSchemaV3`) for Drizzle. The `encryptionClient` export shape +and the empty-schema "no schemas yet" error path are unchanged. diff --git a/packages/cli/src/commands/init/utils.ts b/packages/cli/src/commands/init/utils.ts index 19e551ae..d2a86ff7 100644 --- a/packages/cli/src/commands/init/utils.ts +++ b/packages/cli/src/commands/init/utils.ts @@ -1,6 +1,6 @@ import { existsSync, readFileSync } from 'node:fs' import { resolve } from 'node:path' -import type { Integration, SchemaDef } from './types.js' +import type { DataType, Integration, SchemaDef, SearchOp } from './types.js' /** * Checks if a package is installed and loadable from the current project. @@ -264,105 +264,55 @@ function toCamelCase(str: string): string { return str.replace(/_([a-z])/g, (_, c: string) => c.toUpperCase()) } -function drizzleTsType(dataType: string): string { +/** + * Map a column's v2-style capability request ({@link DataType} plus the set of + * requested {@link SearchOp}s) to the name of the concrete EQL **v3** domain + * factory on the `types` namespace (e.g. `'TextSearch'`, `'IntegerOrd'`). + * + * v3 has no chainable capability tuners — each domain's query capabilities are + * FIXED by the type — so a `{ equality, orderAndRange, freeTextSearch }` request + * does not map mechanically to a flag object; it picks the domain whose fixed + * capability set is the tightest match. The mapping mirrors the capability sets + * in `@cipherstash/stack/eql/v3` (`columns.ts`): + * + * storage-only → `Text` / `Integer` / `Date` / … (no searchOps) + * equality → `TextEq` / `IntegerEq` / `DateEq` (EQUALITY_ONLY) + * order & range → `TextOrd` / `IntegerOrd` / `DateOrd` (ORDER_AND_RANGE, also answers equality) + * free-text → `TextMatch` (MATCH_ONLY), or `TextSearch` when combined with eq/ord (TEXT_SEARCH) + * + * `boolean` and `json` each have a single domain (no searchable variants), so + * their searchOps are informational only. + * + * The numeric `DataType` collapses to the `Integer*` family — the scaffold has + * no width/precision signal to distinguish `Numeric`/`Real`/`Double`/`Bigint`, + * exactly as the v2 scaffold collapsed every number to one `dataType: 'number'`. + * The user's real schema files stay authoritative; they pick the precise domain. + */ +function v3DomainFactory(dataType: DataType, searchOps: SearchOp[]): string { + const eq = searchOps.includes('equality') + const ord = searchOps.includes('orderAndRange') + const text = searchOps.includes('freeTextSearch') + switch (dataType) { - case 'number': - return 'number' + case 'json': + return 'Json' case 'boolean': - return 'boolean' + // Boolean is storage-only in v3 — no eq/ord/match domain exists. + return 'Boolean' + case 'number': + if (ord) return 'IntegerOrd' + if (eq) return 'IntegerEq' + return 'Integer' case 'date': + if (ord) return 'DateOrd' + if (eq) return 'DateEq' return 'Date' - case 'json': - return 'Record' - default: - return 'string' - } -} - -function generateDrizzleFromSchema(schema: SchemaDef): string { - const varName = `${toCamelCase(schema.tableName)}Table` - const schemaVarName = `${toCamelCase(schema.tableName)}Schema` - - const columnDefs = schema.columns.map((col) => { - const opts: string[] = [] - if (col.dataType !== 'string') { - opts.push(`dataType: '${col.dataType}'`) - } - if (col.searchOps.includes('equality')) { - opts.push('equality: true') - } - if (col.searchOps.includes('orderAndRange')) { - opts.push('orderAndRange: true') - } - if (col.searchOps.includes('freeTextSearch')) { - opts.push('freeTextSearch: true') - } - - const tsType = drizzleTsType(col.dataType) - const optsStr = - opts.length > 0 ? `, {\n ${opts.join(',\n ')},\n }` : '' - return ` ${col.name}: encryptedType<${tsType}>('${col.name}'${optsStr}),` - }) - - return `import { pgTable, integer, timestamp } from 'drizzle-orm/pg-core' -import { encryptedType, extractEncryptionSchema } from '@cipherstash/stack-drizzle' -import { Encryption } from '@cipherstash/stack' - -export const ${varName} = pgTable('${schema.tableName}', { - id: integer('id').primaryKey().generatedAlwaysAsIdentity(), -${columnDefs.join('\n')} - createdAt: timestamp('created_at').defaultNow(), -}) - -const ${schemaVarName} = extractEncryptionSchema(${varName}) - -export const encryptionClient = await Encryption({ - schemas: [${schemaVarName}], -}) -` -} - -function generateSchemaFromDef(schema: SchemaDef): string { - const varName = `${toCamelCase(schema.tableName)}Table` - - const columnDefs = schema.columns.map((col) => { - const parts: string[] = [` ${col.name}: encryptedColumn('${col.name}')`] - - if (col.dataType !== 'string') { - parts.push(`.dataType('${col.dataType}')`) - } - - for (const op of col.searchOps) { - parts.push(`.${op}()`) - } - - return `${parts.join('\n ')},` - }) - - return `import { encryptedTable, encryptedColumn } from '@cipherstash/stack/schema' -import { Encryption } from '@cipherstash/stack' - -export const ${varName} = encryptedTable('${schema.tableName}', { -${columnDefs.join('\n')} -}) - -export const encryptionClient = await Encryption({ - schemas: [${varName}], -}) -` -} - -/** Generates the encryption client file contents for a given integration and schema. */ -export function generateClientFromSchema( - integration: Integration, - schema: SchemaDef, -): string { - switch (integration) { - case 'drizzle': - return generateDrizzleFromSchema(schema) - case 'supabase': - case 'postgresql': - return generateSchemaFromDef(schema) + case 'string': + if (text && (eq || ord)) return 'TextSearch' + if (text) return 'TextMatch' + if (ord) return 'TextOrd' + if (eq) return 'TextEq' + return 'Text' } } @@ -372,24 +322,8 @@ function generateDrizzleFromSchemas(schemas: SchemaDef[]): string { const schemaVarName = `${toCamelCase(schema.tableName)}Schema` const columnDefs = schema.columns.map((col) => { - const opts: string[] = [] - if (col.dataType !== 'string') { - opts.push(`dataType: '${col.dataType}'`) - } - if (col.searchOps.includes('equality')) { - opts.push('equality: true') - } - if (col.searchOps.includes('orderAndRange')) { - opts.push('orderAndRange: true') - } - if (col.searchOps.includes('freeTextSearch')) { - opts.push('freeTextSearch: true') - } - - const tsType = drizzleTsType(col.dataType) - const optsStr = - opts.length > 0 ? `, {\n ${opts.join(',\n ')},\n }` : '' - return ` ${col.name}: encryptedType<${tsType}>('${col.name}'${optsStr}),` + const factory = v3DomainFactory(col.dataType, col.searchOps) + return ` ${col.name}: types.${factory}('${col.name}'),` }) return `export const ${varName} = pgTable('${schema.tableName}', { @@ -398,18 +332,18 @@ ${columnDefs.join('\n')} createdAt: timestamp('created_at').defaultNow(), }) -const ${schemaVarName} = extractEncryptionSchema(${varName})` +const ${schemaVarName} = extractEncryptionSchemaV3(${varName})` }) const schemaVarNames = schemas.map((s) => `${toCamelCase(s.tableName)}Schema`) return `import { pgTable, integer, timestamp } from 'drizzle-orm/pg-core' -import { encryptedType, extractEncryptionSchema } from '@cipherstash/stack-drizzle' -import { Encryption } from '@cipherstash/stack' +import { types, extractEncryptionSchemaV3 } from '@cipherstash/stack-drizzle/v3' +import { EncryptionV3 } from '@cipherstash/stack/v3' ${tableDefs.join('\n\n')} -export const encryptionClient = await Encryption({ +export const encryptionClient = await EncryptionV3({ schemas: [${schemaVarNames.join(', ')}], }) ` @@ -420,17 +354,8 @@ function generateGenericFromSchemas(schemas: SchemaDef[]): string { const varName = `${toCamelCase(schema.tableName)}Table` const columnDefs = schema.columns.map((col) => { - const parts: string[] = [` ${col.name}: encryptedColumn('${col.name}')`] - - if (col.dataType !== 'string') { - parts.push(`.dataType('${col.dataType}')`) - } - - for (const op of col.searchOps) { - parts.push(`.${op}()`) - } - - return `${parts.join('\n ')},` + const factory = v3DomainFactory(col.dataType, col.searchOps) + return ` ${col.name}: types.${factory}('${col.name}'),` }) return `export const ${varName} = encryptedTable('${schema.tableName}', { @@ -440,12 +365,11 @@ ${columnDefs.join('\n')} const tableVarNames = schemas.map((s) => `${toCamelCase(s.tableName)}Table`) - return `import { encryptedTable, encryptedColumn } from '@cipherstash/stack/schema' -import { Encryption } from '@cipherstash/stack' + return `import { EncryptionV3, encryptedTable, types } from '@cipherstash/stack/v3' ${tableDefs.join('\n\n')} -export const encryptionClient = await Encryption({ +export const encryptionClient = await EncryptionV3({ schemas: [${tableVarNames.join(', ')}], }) ` @@ -485,7 +409,7 @@ export function generateClientFromSchemas( * schemas yet, and explicitly tells the agent that the user's existing * schema files remain authoritative. The agent's job during the handoff * is to declare encrypted columns directly in those files and update the - * `Encryption({ schemas: [...] })` call below to reference them. + * `EncryptionV3({ schemas: [...] })` call below to reference them. */ export function generatePlaceholderClient(integration: Integration): string { if (integration === 'drizzle') { @@ -500,47 +424,57 @@ const DRIZZLE_PLACEHOLDER = `/** * \`stash init\` wrote this file. It is intentionally NOT a real Drizzle * schema. Your existing schema files (typically under \`src/db/\`) remain * authoritative — your agent will edit those directly when you encrypt a - * column, then update the \`Encryption({ schemas: [...] })\` call below + * column, then update the \`EncryptionV3({ schemas: [...] })\` call below * to reference the encrypted tables you declared there. * * Until that happens, the encryption client is initialised with no * schemas, and \`stash encrypt\` commands will surface a clear error * pointing at this file. * + * This project uses EQL v3. Encrypted columns are concrete Postgres domains + * built with the \`types.*\` factories from \`@cipherstash/stack-drizzle/v3\`. + * Each domain's query capabilities are FIXED by the type you pick — there is + * no capability config object. Choose the factory whose capabilities you need: + * types.Text / types.Integer / … storage only (encrypt/decrypt, no queries) + * types.TextEq / types.IntegerEq equality (eq, inArray) + * types.IntegerOrd / types.DateOrd equality + order/range (gt/lt/between/sort) + * types.TextMatch free-text match only + * types.TextSearch equality + order/range + free-text + * types.Json encrypted-JSONB containment + selectors + * * --- Pattern reference (copy into your real schema, do NOT use as-is) --- * * Encrypted twin column for an existing populated column (path 3 — lifecycle): * - * import { encryptedType } from '@cipherstash/stack-drizzle' + * import { pgTable, integer, text } from 'drizzle-orm/pg-core' + * import { types } from '@cipherstash/stack-drizzle/v3' * * export const users = pgTable('users', { * id: integer('id').primaryKey().generatedAlwaysAsIdentity(), - * email: text('email').notNull(), // existing plaintext, unchanged for now - * email_encrypted: encryptedType('email_encrypted', { // encrypted twin, NULLABLE — never .notNull() - * freeTextSearch: true, - * equality: true, - * }), + * email: text('email').notNull(), // existing plaintext, unchanged for now + * email_encrypted: types.TextSearch('email_encrypted'), // encrypted twin, NULLABLE — never .notNull() * }) * * Net-new encrypted column (path 1 — declare encrypted from the start): * * export const orders = pgTable('orders', { * id: integer('id').primaryKey().generatedAlwaysAsIdentity(), - * billing_address: encryptedType('billing_address', { equality: true }), + * billing_address: types.TextEq('billing_address'), * }) * - * Once you have encrypted tables declared, harvest them and pass to Encryption(): + * Once you have encrypted tables declared, harvest them and pass to EncryptionV3(): * - * import { extractEncryptionSchema } from '@cipherstash/stack-drizzle' + * import { extractEncryptionSchemaV3 } from '@cipherstash/stack-drizzle/v3' + * import { EncryptionV3 } from '@cipherstash/stack/v3' * import { users, orders } from './db/schema' * - * export const encryptionClient = await Encryption({ - * schemas: [extractEncryptionSchema(users), extractEncryptionSchema(orders)], + * export const encryptionClient = await EncryptionV3({ + * schemas: [extractEncryptionSchemaV3(users), extractEncryptionSchemaV3(orders)], * }) */ -import { Encryption } from '@cipherstash/stack' +import { EncryptionV3 } from '@cipherstash/stack/v3' -export const encryptionClient = await Encryption({ schemas: [] }) +export const encryptionClient = await EncryptionV3({ schemas: [] }) ` const GENERIC_PLACEHOLDER = `/** @@ -549,39 +483,50 @@ const GENERIC_PLACEHOLDER = `/** * \`stash init\` wrote this file. It is intentionally NOT a real schema * definition. Your existing schema files remain authoritative — your * agent will declare encrypted columns there and update the - * \`Encryption({ schemas: [...] })\` call below to reference them. + * \`EncryptionV3({ schemas: [...] })\` call below to reference them. * * Until that happens, the encryption client is initialised with no * schemas, and \`stash encrypt\` commands will surface a clear error * pointing at this file. * + * This project uses EQL v3. Encrypted columns are concrete Postgres domains + * built with the \`types.*\` factories from \`@cipherstash/stack/eql/v3\` + * (also re-exported from \`@cipherstash/stack/v3\`). Each domain's query + * capabilities are FIXED by the type you pick — there is no chainable + * capability tuner. Choose the factory whose capabilities you need: + * types.Text / types.Integer / … storage only (encrypt/decrypt, no queries) + * types.TextEq / types.IntegerEq equality + * types.IntegerOrd / types.DateOrd equality + order/range + * types.TextMatch free-text match only + * types.TextSearch equality + order/range + free-text + * types.Json encrypted-JSONB containment + selectors + * * --- Pattern reference (copy into your real schema, do NOT use as-is) --- * * Encrypted twin column for an existing populated column (path 3 — lifecycle): * - * import { encryptedTable, encryptedColumn } from '@cipherstash/stack/schema' + * import { encryptedTable, types } from '@cipherstash/stack/v3' * * export const users = encryptedTable('users', { - * email_encrypted: encryptedColumn('email_encrypted') - * .freeTextSearch() - * .equality(), + * email_encrypted: types.TextSearch('email_encrypted'), * }) * * Net-new encrypted column (path 1 — declare encrypted from the start): * * export const orders = encryptedTable('orders', { - * billing_address: encryptedColumn('billing_address').equality(), + * billing_address: types.TextEq('billing_address'), * }) * - * Once you have encrypted tables declared, pass them to Encryption(): + * Once you have encrypted tables declared, pass them to EncryptionV3(): * + * import { EncryptionV3 } from '@cipherstash/stack/v3' * import { users, orders } from './db/schema' * - * export const encryptionClient = await Encryption({ + * export const encryptionClient = await EncryptionV3({ * schemas: [users, orders], * }) */ -import { Encryption } from '@cipherstash/stack' +import { EncryptionV3 } from '@cipherstash/stack/v3' -export const encryptionClient = await Encryption({ schemas: [] }) +export const encryptionClient = await EncryptionV3({ schemas: [] }) `