@prisma-next/extension-cipherstash 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/README.md +153 -0
  2. package/dist/call-classes-CSvD7w8U.mjs +206 -0
  3. package/dist/call-classes-CSvD7w8U.mjs.map +1 -0
  4. package/dist/column-types.d.mts +33 -0
  5. package/dist/column-types.d.mts.map +1 -0
  6. package/dist/column-types.mjs +42 -0
  7. package/dist/column-types.mjs.map +1 -0
  8. package/dist/constants-BDxL9Pe3.d.mts +22 -0
  9. package/dist/constants-BDxL9Pe3.d.mts.map +1 -0
  10. package/dist/constants-B_2TNvUi.mjs +46 -0
  11. package/dist/constants-B_2TNvUi.mjs.map +1 -0
  12. package/dist/control.d.mts +7 -0
  13. package/dist/control.d.mts.map +1 -0
  14. package/dist/control.mjs +430 -0
  15. package/dist/control.mjs.map +1 -0
  16. package/dist/descriptor-meta-BgQfZTAF.mjs +129 -0
  17. package/dist/descriptor-meta-BgQfZTAF.mjs.map +1 -0
  18. package/dist/envelope-P9BxfJNr.mjs +271 -0
  19. package/dist/envelope-P9BxfJNr.mjs.map +1 -0
  20. package/dist/middleware.d.mts +13 -0
  21. package/dist/middleware.d.mts.map +1 -0
  22. package/dist/middleware.mjs +129 -0
  23. package/dist/middleware.mjs.map +1 -0
  24. package/dist/migration.d.mts +141 -0
  25. package/dist/migration.d.mts.map +1 -0
  26. package/dist/migration.mjs +2 -0
  27. package/dist/operation-types.d.mts +49 -0
  28. package/dist/operation-types.d.mts.map +1 -0
  29. package/dist/operation-types.mjs +1 -0
  30. package/dist/pack.d.mts +86 -0
  31. package/dist/pack.d.mts.map +1 -0
  32. package/dist/pack.mjs +2 -0
  33. package/dist/runtime.d.mts +207 -0
  34. package/dist/runtime.d.mts.map +1 -0
  35. package/dist/runtime.mjs +429 -0
  36. package/dist/runtime.mjs.map +1 -0
  37. package/dist/sdk-D5FTGyzp.d.mts +67 -0
  38. package/dist/sdk-D5FTGyzp.d.mts.map +1 -0
  39. package/package.json +69 -0
  40. package/src/contract/authoring.ts +62 -0
  41. package/src/contract/contract.d.ts +149 -0
  42. package/src/contract/contract.json +104 -0
  43. package/src/contract/contract.prisma +46 -0
  44. package/src/execution/abort.ts +143 -0
  45. package/src/execution/codec-runtime.ts +209 -0
  46. package/src/execution/decrypt-all.ts +217 -0
  47. package/src/execution/envelope.ts +263 -0
  48. package/src/execution/operators.ts +211 -0
  49. package/src/execution/parameterized.ts +71 -0
  50. package/src/execution/routing.ts +93 -0
  51. package/src/execution/sdk.ts +68 -0
  52. package/src/exports/column-types.ts +62 -0
  53. package/src/exports/contract-space-typing.ts +86 -0
  54. package/src/exports/control.ts +120 -0
  55. package/src/exports/middleware.ts +24 -0
  56. package/src/exports/migration.ts +43 -0
  57. package/src/exports/operation-types.ts +16 -0
  58. package/src/exports/pack.ts +13 -0
  59. package/src/exports/runtime.ts +110 -0
  60. package/src/extension-metadata/codec-metadata.ts +81 -0
  61. package/src/extension-metadata/constants.ts +70 -0
  62. package/src/extension-metadata/descriptor-meta.ts +76 -0
  63. package/src/middleware/bulk-encrypt.ts +192 -0
  64. package/src/migration/call-classes.ts +350 -0
  65. package/src/migration/cipherstash-codec.ts +157 -0
  66. package/src/migration/eql-bundle.ts +29 -0
  67. package/src/migration/eql-install.generated.ts +5751 -0
  68. package/src/types/operation-types.ts +81 -0
@@ -0,0 +1,192 @@
1
+ /**
2
+ * Bulk-encrypt middleware for cipherstash envelopes.
3
+ *
4
+ * The middleware sits in the SQL runtime's `beforeExecute` chain and:
5
+ *
6
+ * 1. Walks the lowered query AST (`InsertAst` / `UpdateAst`) and stamps
7
+ * `(table, column)` routing context onto every `EncryptedString`
8
+ * envelope embedded in a `ParamRef`. The handle's `(table, column)`
9
+ * slots are the canonical input to {@link groupByRoutingKey}; this
10
+ * walk is the single place the AST's structural column metadata gets
11
+ * attached to the envelopes the SDK will see.
12
+ *
13
+ * 2. Iterates `params.entries()` to collect every cipherstash-codec'd
14
+ * `ParamRef` whose value is an `EncryptedString`, groups them by
15
+ * routing key, and issues exactly one `sdk.bulkEncrypt(...)` call
16
+ * per group. Routing-key derivation is `(table, column)` —
17
+ * homogeneous batches only.
18
+ *
19
+ * 3. Stamps each returned ciphertext onto the envelope's handle via
20
+ * `setHandleCiphertext` and writes the envelope back through
21
+ * `params.replaceValues` so the runtime's `currentParams()` view
22
+ * reflects the post-mutation slot. The handle's `plaintext` slot
23
+ * is **retained** — `envelope.decrypt()` continues to return the
24
+ * plaintext synchronously without consulting the SDK.
25
+ *
26
+ * Cancellation: `ctx.signal` is forwarded by identity to every
27
+ * `bulkEncrypt` call via `ifDefined`; the SDK is responsible for
28
+ * honoring it. The awaiting middleware also races the SDK promise
29
+ * against `ctx.signal` via `raceCipherstashAbort` so a caller-side
30
+ * abort surfaces a `RUNTIME.ABORTED { phase: 'bulk-encrypt' }`
31
+ * envelope promptly even when the SDK body itself ignores the signal.
32
+ * A pre-flight `checkCipherstashAborted` short-circuits before any
33
+ * SDK round-trip is scheduled when the signal is already aborted at
34
+ * entry.
35
+ */
36
+
37
+ import type {
38
+ AnyQueryAst,
39
+ ColumnRef,
40
+ DefaultValueExpr,
41
+ InsertAst,
42
+ ParamRef,
43
+ UpdateAst,
44
+ } from '@prisma-next/sql-relational-core/ast';
45
+ import type {
46
+ ParamRefHandle,
47
+ SqlParamRefMutator,
48
+ } from '@prisma-next/sql-relational-core/middleware';
49
+ import type { SqlMiddleware } from '@prisma-next/sql-runtime';
50
+ import { ifDefined } from '@prisma-next/utils/defined';
51
+ import { checkCipherstashAborted, raceCipherstashAbort } from '../execution/abort';
52
+ import { EncryptedString, setHandleCiphertext, setHandleRoutingKey } from '../execution/envelope';
53
+ import { type BulkEncryptTarget, groupByRoutingKey } from '../execution/routing';
54
+ import type { CipherstashSdk } from '../execution/sdk';
55
+ import { CIPHERSTASH_STRING_CODEC_ID } from '../extension-metadata/constants';
56
+
57
+ /**
58
+ * Construct the bulk-encrypt middleware. The returned middleware is
59
+ * stateless aside from the captured `sdk` reference; one instance per
60
+ * runtime extension is the expected pattern.
61
+ */
62
+ export function bulkEncryptMiddleware(sdk: CipherstashSdk): SqlMiddleware {
63
+ return {
64
+ name: 'cipherstash.bulk-encrypt',
65
+ familyId: 'sql',
66
+ async beforeExecute(plan, ctx, params) {
67
+ if (!params) {
68
+ return;
69
+ }
70
+
71
+ stampRoutingKeysFromAst(plan.ast);
72
+
73
+ const targets = collectTargets(params);
74
+ if (targets.length === 0) {
75
+ return;
76
+ }
77
+
78
+ const groups = groupByRoutingKey(targets);
79
+ for (const [groupKey, group] of groups) {
80
+ const first = group[0];
81
+ if (!first) continue;
82
+ const routingKey = first.routingKey;
83
+
84
+ checkCipherstashAborted(ctx.signal, 'bulk-encrypt');
85
+ const ciphertexts = await raceCipherstashAbort(
86
+ sdk.bulkEncrypt({
87
+ routingKey,
88
+ values: group.map((t) => t.plaintext),
89
+ ...ifDefined('signal', ctx.signal),
90
+ }),
91
+ ctx.signal,
92
+ 'bulk-encrypt',
93
+ );
94
+
95
+ if (ciphertexts.length !== group.length) {
96
+ throw new Error(
97
+ `cipherstash bulk-encrypt: SDK returned ${ciphertexts.length} ciphertexts ` +
98
+ `for routing key ${groupKey} but ${group.length} were requested.`,
99
+ );
100
+ }
101
+
102
+ params.replaceValues(
103
+ group.map((t, i) => {
104
+ const ciphertext = ciphertexts[i];
105
+ setHandleCiphertext(t.envelope, ciphertext);
106
+ return { ref: t.ref, newValue: t.envelope };
107
+ }),
108
+ );
109
+ }
110
+ },
111
+ };
112
+ }
113
+
114
+ function collectTargets(
115
+ params: SqlParamRefMutator,
116
+ ): BulkEncryptTarget<ParamRefHandle<string | undefined>>[] {
117
+ const targets: BulkEncryptTarget<ParamRefHandle<string | undefined>>[] = [];
118
+ for (const entry of params.entries()) {
119
+ if (entry.codecId !== CIPHERSTASH_STRING_CODEC_ID) continue;
120
+ const value = entry.value;
121
+ if (!(value instanceof EncryptedString)) continue;
122
+ const handle = value.expose();
123
+ if (handle.plaintext === undefined) {
124
+ throw new Error(
125
+ 'cipherstash bulk-encrypt: encountered an envelope with no plaintext on the write path. ' +
126
+ 'Use `EncryptedString.from(plaintext)` to construct write-side envelopes.',
127
+ );
128
+ }
129
+ if (handle.table === undefined || handle.column === undefined) {
130
+ throw new Error(
131
+ 'cipherstash bulk-encrypt: envelope reached the bulk-encrypt phase without a (table, column) ' +
132
+ "routing context. The middleware's AST walk only handles `InsertAst` and `UpdateAst`; " +
133
+ 'cipherstash envelopes embedded in other plan shapes (e.g. raw SQL) must stamp routing ' +
134
+ 'context explicitly via `setHandleRoutingKey` before execute.',
135
+ );
136
+ }
137
+ targets.push({
138
+ ref: entry.ref,
139
+ plaintext: handle.plaintext,
140
+ envelope: value,
141
+ routingKey: { table: handle.table, column: handle.column },
142
+ });
143
+ }
144
+ return targets;
145
+ }
146
+
147
+ function stampRoutingKeysFromAst(ast: AnyQueryAst | undefined): void {
148
+ if (!ast) return;
149
+ switch (ast.kind) {
150
+ case 'insert':
151
+ stampInsert(ast);
152
+ return;
153
+ case 'update':
154
+ stampUpdate(ast);
155
+ return;
156
+ default:
157
+ return;
158
+ }
159
+ }
160
+
161
+ function stampInsert(ast: InsertAst): void {
162
+ const tableName = ast.table.name;
163
+ for (const row of ast.rows) {
164
+ for (const [column, value] of Object.entries(row)) {
165
+ stampParamRefIfEnvelope(value, tableName, column);
166
+ }
167
+ }
168
+ if (ast.onConflict?.action.kind === 'do-update-set') {
169
+ for (const [column, value] of Object.entries(ast.onConflict.action.set)) {
170
+ stampParamRefIfEnvelope(value, tableName, column);
171
+ }
172
+ }
173
+ }
174
+
175
+ function stampUpdate(ast: UpdateAst): void {
176
+ const tableName = ast.table.name;
177
+ for (const [column, value] of Object.entries(ast.set)) {
178
+ stampParamRefIfEnvelope(value, tableName, column);
179
+ }
180
+ }
181
+
182
+ function stampParamRefIfEnvelope(
183
+ value: ColumnRef | ParamRef | DefaultValueExpr,
184
+ table: string,
185
+ column: string,
186
+ ): void {
187
+ if (value.kind !== 'param-ref') return;
188
+ const inner = value.value;
189
+ if (inner instanceof EncryptedString) {
190
+ setHandleRoutingKey(inner, table, column);
191
+ }
192
+ }
@@ -0,0 +1,350 @@
1
+ /**
2
+ * Cipherstash migration IR — renderable `*Call` classes for the codec
3
+ * lifecycle hook + the public `@prisma-next/extension-cipherstash/migration`
4
+ * factory functions.
5
+ *
6
+ * Each `*Call` implements the framework `OpFactoryCall` interface (ADR
7
+ * 195) directly, so cipherstash's contributions flow through the postgres
8
+ * planner as first-class IR nodes — no `RawSqlCall` wrap, no detour
9
+ * through the unstructured-op fallback. The codec hook
10
+ * (`./cipherstash-codec.ts`) returns Calls; the postgres planner adds
11
+ * them to its call list and renders them via `renderCallsToTypeScript`.
12
+ *
13
+ * Public factory functions (`cipherstashAddSearchConfig` /
14
+ * `cipherstashRemoveSearchConfig`) are re-exported from
15
+ * `@prisma-next/extension-cipherstash/migration`. Users authoring a
16
+ * hand-written migration can call them directly:
17
+ *
18
+ * ```ts
19
+ * import { cipherstashAddSearchConfig } from '@prisma-next/extension-cipherstash/migration';
20
+ *
21
+ * createTable('public', 'user', [...]);
22
+ * cipherstashAddSearchConfig({ table: 'user', column: 'email', index: 'unique' });
23
+ * ```
24
+ *
25
+ * Round-trip invariant: `toOp()` produces the same op shape the codec
26
+ * hook would emit directly — `ops.json` stays byte-identical;
27
+ * `migration.ts` carries a factory call instead of an opaque
28
+ * `rawSql({...})` block.
29
+ */
30
+
31
+ import type { SqlMigrationPlanOperation } from '@prisma-next/family-sql/control';
32
+ import type {
33
+ MigrationOperationClass,
34
+ OpFactoryCall,
35
+ } from '@prisma-next/framework-components/control';
36
+ import { type ImportRequirement, jsonToTsSource, TsExpression } from '@prisma-next/ts-render';
37
+ import { ifDefined } from '@prisma-next/utils/defined';
38
+
39
+ const CIPHERSTASH_MIGRATION_MODULE = '@prisma-next/extension-cipherstash/migration';
40
+
41
+ /** Mirrors `eql_v2.add_search_config(table, column, index_name, cast_as)`. */
42
+ const DEFAULT_CAST_AS = 'text';
43
+
44
+ /**
45
+ * Two-valued enumeration matching the EQL search-config indices the
46
+ * cipherstash codec emits — one per enabled flag in
47
+ * `Encrypted<string>`'s `typeParams`:
48
+ *
49
+ * - `equality: true` → `'unique'` index
50
+ * - `freeTextSearch: true` → `'match'` index
51
+ */
52
+ export type CipherstashSearchIndex = 'unique' | 'match';
53
+
54
+ /**
55
+ * Args shape accepted by the public `cipherstashAddSearchConfig` /
56
+ * `cipherstashRemoveSearchConfig` factory functions.
57
+ *
58
+ * `castAs` defaults to `'text'` — matches the cipherstash codec hook's
59
+ * canonical output and the EQL bundle's expected cast for
60
+ * `eql_v2_encrypted` columns. Override only if you know the runtime
61
+ * cast for your column differs.
62
+ */
63
+ export interface CipherstashSearchConfigArgs {
64
+ readonly table: string;
65
+ readonly column: string;
66
+ readonly index: CipherstashSearchIndex;
67
+ readonly castAs?: string;
68
+ }
69
+
70
+ type CipherstashOp = SqlMigrationPlanOperation<unknown>;
71
+ type OpStep = CipherstashOp['execute'][number];
72
+
73
+ /**
74
+ * Escape a string so it can be embedded inside a Postgres single-quoted
75
+ * literal. Identifiers in our IR are unlikely to contain apostrophes,
76
+ * but doubling them keeps the emitted SQL safe under any future
77
+ * relaxation.
78
+ */
79
+ function sqlLiteral(value: string): string {
80
+ return `'${value.replace(/'/g, "''")}'`;
81
+ }
82
+
83
+ function invariantIdFor(
84
+ tableName: string,
85
+ fieldName: string,
86
+ action: 'add-search-config' | 'remove-search-config',
87
+ indexName: CipherstashSearchIndex,
88
+ ): string {
89
+ return `cipherstash-codec:${tableName}.${fieldName}:${action}:${indexName}@v1`;
90
+ }
91
+
92
+ /**
93
+ * Base class for cipherstash migration IR nodes.
94
+ *
95
+ * Each instance is *both* an `OpFactoryCall` (renderable to TypeScript,
96
+ * lowerable to a runtime op via `toOp()`) and a structurally-valid
97
+ * {@link CipherstashOp} — `id`, `label`, `operationClass`,
98
+ * `invariantId`, `target`, `precheck`, `execute`, `postcheck` are
99
+ * stored as enumerable own properties, populated in the concrete
100
+ * subclass constructors. So when the planner-rendered `migration.ts`
101
+ * runs and the user's `operations` getter returns Call instances
102
+ * directly, both `MigrationOpSchema` validation (which checks `id` /
103
+ * `label` / `operationClass`) and `JSON.stringify` (which writes
104
+ * `ops.json`) see the runtime op shape unchanged.
105
+ *
106
+ * The cipherstash-specific identity fields (`factoryName`, `table`,
107
+ * `column`, `index`, `castAs`) live on the subclass prototype as
108
+ * accessor getters and on a per-instance backing record kept in a
109
+ * private slot (`#args`). Accessor properties on the class are
110
+ * non-enumerable, and the backing record is a private field, so
111
+ * `Object.keys(call)` and `canonicalizeJson(...)` see only the op
112
+ * fields — `ops.json` and `migrationHash` stay byte-stable.
113
+ */
114
+ abstract class CipherstashOpFactoryCallNode extends TsExpression implements OpFactoryCall {
115
+ abstract get factoryName(): string;
116
+ abstract readonly operationClass: MigrationOperationClass;
117
+ abstract readonly label: string;
118
+ abstract readonly id: string;
119
+ abstract readonly invariantId: string;
120
+ abstract readonly target: { readonly id: string };
121
+ abstract readonly precheck: readonly OpStep[];
122
+ abstract readonly execute: readonly OpStep[];
123
+ abstract readonly postcheck: readonly OpStep[];
124
+
125
+ importRequirements(): readonly ImportRequirement[] {
126
+ return [{ moduleSpecifier: CIPHERSTASH_MIGRATION_MODULE, symbol: this.factoryName }];
127
+ }
128
+
129
+ /**
130
+ * Re-expose the runtime op view for callers that prefer to lower
131
+ * Calls explicitly (notably {@link renderOps} on the postgres lane).
132
+ * The returned object is a plain copy of this Call's op-shaped
133
+ * fields.
134
+ */
135
+ toOp(): CipherstashOp {
136
+ return {
137
+ id: this.id,
138
+ label: this.label,
139
+ operationClass: this.operationClass,
140
+ invariantId: this.invariantId,
141
+ target: this.target,
142
+ precheck: this.precheck,
143
+ execute: this.execute,
144
+ postcheck: this.postcheck,
145
+ };
146
+ }
147
+
148
+ protected freeze(): void {
149
+ Object.freeze(this);
150
+ }
151
+ }
152
+
153
+ /**
154
+ * `cipherstashAddSearchConfig` — register an EQL search-config row for
155
+ * the given column / index combination. Lowers to a `SELECT
156
+ * eql_v2.add_search_config('<table>', '<column>', '<index>',
157
+ * '<castAs>')` op, classified `'additive'`.
158
+ */
159
+ interface AddArgs {
160
+ readonly table: string;
161
+ readonly column: string;
162
+ readonly index: CipherstashSearchIndex;
163
+ readonly castAs: string;
164
+ }
165
+
166
+ export class CipherstashAddSearchConfigCall extends CipherstashOpFactoryCallNode {
167
+ readonly id: string;
168
+ readonly label: string;
169
+ readonly operationClass: 'additive';
170
+ readonly invariantId: string;
171
+ readonly target: { readonly id: string };
172
+ readonly precheck: readonly OpStep[];
173
+ readonly execute: readonly OpStep[];
174
+ readonly postcheck: readonly OpStep[];
175
+
176
+ // Private slot keeps the renderer-side args off the enumerable
177
+ // own-property surface; the public accessors below expose them
178
+ // read-only on the prototype, so neither `Object.keys` nor
179
+ // `canonicalizeJson` walks them.
180
+ readonly #args: AddArgs;
181
+
182
+ constructor(
183
+ table: string,
184
+ column: string,
185
+ index: CipherstashSearchIndex,
186
+ castAs: string = DEFAULT_CAST_AS,
187
+ ) {
188
+ super();
189
+ this.#args = { table, column, index, castAs };
190
+ // Property assignment order is fixed (id → label → operationClass
191
+ // → invariantId → target → precheck → execute → postcheck) so
192
+ // `JSON.stringify(call)` lays out keys in the byte order the
193
+ // baseline `ops.json` carries.
194
+ this.id = `cipherstash-codec.${table}.${column}.add-search-config.${index}`;
195
+ this.label = `Enable cipherstash search on ${table}.${column}`;
196
+ this.operationClass = 'additive';
197
+ this.invariantId = invariantIdFor(table, column, 'add-search-config', index);
198
+ this.target = { id: 'postgres' };
199
+ this.precheck = [];
200
+ this.execute = [
201
+ {
202
+ description: `Register cipherstash ${index} search config for ${table}.${column}`,
203
+ sql: `SELECT eql_v2.add_search_config(${sqlLiteral(table)}, ${sqlLiteral(column)}, ${sqlLiteral(index)}, ${sqlLiteral(castAs)});`,
204
+ },
205
+ ];
206
+ this.postcheck = [];
207
+ this.freeze();
208
+ }
209
+
210
+ get factoryName(): 'cipherstashAddSearchConfig' {
211
+ return 'cipherstashAddSearchConfig';
212
+ }
213
+
214
+ get table(): string {
215
+ return this.#args.table;
216
+ }
217
+
218
+ get column(): string {
219
+ return this.#args.column;
220
+ }
221
+
222
+ get index(): CipherstashSearchIndex {
223
+ return this.#args.index;
224
+ }
225
+
226
+ get castAs(): string {
227
+ return this.#args.castAs;
228
+ }
229
+
230
+ renderTypeScript(): string {
231
+ const args = {
232
+ table: this.#args.table,
233
+ column: this.#args.column,
234
+ index: this.#args.index,
235
+ ...ifDefined('castAs', this.#args.castAs !== DEFAULT_CAST_AS ? this.#args.castAs : undefined),
236
+ };
237
+ return `cipherstashAddSearchConfig(${jsonToTsSource(args)})`;
238
+ }
239
+ }
240
+
241
+ /**
242
+ * `cipherstashRemoveSearchConfig` — invert
243
+ * {@link CipherstashAddSearchConfigCall} for the same (table, column,
244
+ * index) tuple. Lowers to `SELECT eql_v2.remove_search_config('<table>',
245
+ * '<column>', '<index>')`, classified `'destructive'`.
246
+ *
247
+ * No `castAs` argument — `eql_v2.remove_search_config` takes only the
248
+ * three identifying fields; the cast was applied at the index's add
249
+ * site.
250
+ */
251
+ interface RemoveArgs {
252
+ readonly table: string;
253
+ readonly column: string;
254
+ readonly index: CipherstashSearchIndex;
255
+ }
256
+
257
+ export class CipherstashRemoveSearchConfigCall extends CipherstashOpFactoryCallNode {
258
+ readonly id: string;
259
+ readonly label: string;
260
+ readonly operationClass: 'destructive';
261
+ readonly invariantId: string;
262
+ readonly target: { readonly id: string };
263
+ readonly precheck: readonly OpStep[];
264
+ readonly execute: readonly OpStep[];
265
+ readonly postcheck: readonly OpStep[];
266
+
267
+ readonly #args: RemoveArgs;
268
+
269
+ constructor(table: string, column: string, index: CipherstashSearchIndex) {
270
+ super();
271
+ this.#args = { table, column, index };
272
+ this.id = `cipherstash-codec.${table}.${column}.remove-search-config.${index}`;
273
+ this.label = `Disable cipherstash search on ${table}.${column}`;
274
+ this.operationClass = 'destructive';
275
+ this.invariantId = invariantIdFor(table, column, 'remove-search-config', index);
276
+ this.target = { id: 'postgres' };
277
+ this.precheck = [];
278
+ this.execute = [
279
+ {
280
+ description: `Remove cipherstash ${index} search config for ${table}.${column}`,
281
+ sql: `SELECT eql_v2.remove_search_config(${sqlLiteral(table)}, ${sqlLiteral(column)}, ${sqlLiteral(index)});`,
282
+ },
283
+ ];
284
+ this.postcheck = [];
285
+ this.freeze();
286
+ }
287
+
288
+ get factoryName(): 'cipherstashRemoveSearchConfig' {
289
+ return 'cipherstashRemoveSearchConfig';
290
+ }
291
+
292
+ get table(): string {
293
+ return this.#args.table;
294
+ }
295
+
296
+ get column(): string {
297
+ return this.#args.column;
298
+ }
299
+
300
+ get index(): CipherstashSearchIndex {
301
+ return this.#args.index;
302
+ }
303
+
304
+ renderTypeScript(): string {
305
+ return `cipherstashRemoveSearchConfig(${jsonToTsSource({
306
+ table: this.#args.table,
307
+ column: this.#args.column,
308
+ index: this.#args.index,
309
+ })})`;
310
+ }
311
+ }
312
+
313
+ /**
314
+ * Public factory: register a cipherstash search-config row.
315
+ *
316
+ * Use from a hand-written migration when you need to wire EQL
317
+ * search-config alongside a `createTable` / `addColumn`. The
318
+ * `Encrypted<string>` codec hook calls this factory automatically when
319
+ * planning a contract diff that adds a `searchable: true` column.
320
+ *
321
+ * Returns the {@link CipherstashAddSearchConfigCall} IR node, which
322
+ * implements `OpFactoryCall` and is itself a `SqlMigrationPlanOperation`
323
+ * (its readonly op-shaped fields are populated in the constructor) — so
324
+ * the same value flows through both the renderer (planner-time IR) and
325
+ * the runtime ops list (`Migration.operations`) without an extra
326
+ * lowering step at the call site.
327
+ */
328
+ export function cipherstashAddSearchConfig(
329
+ args: CipherstashSearchConfigArgs,
330
+ ): CipherstashAddSearchConfigCall {
331
+ return new CipherstashAddSearchConfigCall(
332
+ args.table,
333
+ args.column,
334
+ args.index,
335
+ args.castAs ?? DEFAULT_CAST_AS,
336
+ );
337
+ }
338
+
339
+ /**
340
+ * Public factory: invert {@link cipherstashAddSearchConfig} for the
341
+ * same (table, column, index) tuple.
342
+ *
343
+ * Returns the {@link CipherstashRemoveSearchConfigCall} IR node — see
344
+ * {@link cipherstashAddSearchConfig} for the rationale.
345
+ */
346
+ export function cipherstashRemoveSearchConfig(
347
+ args: CipherstashSearchConfigArgs,
348
+ ): CipherstashRemoveSearchConfigCall {
349
+ return new CipherstashRemoveSearchConfigCall(args.table, args.column, args.index);
350
+ }
@@ -0,0 +1,157 @@
1
+ /**
2
+ * Control hooks for the `cipherstash:string@1` codec.
3
+ *
4
+ * Implements `CodecControlHooks.onFieldEvent`. Reacts to per-field
5
+ * added / dropped / altered events as the *application* emitter diffs
6
+ * the prior contract against the new contract; the returned Calls flow
7
+ * through the SQL planner's IR alongside structural DDL and render as
8
+ * `cipherstashAddSearchConfig({...})` / `cipherstashRemoveSearchConfig({...})`
9
+ * calls in the user's `migration.ts` (ADR 195 two-renderer pattern).
10
+ *
11
+ * Trigger: a field uses the `cipherstash:string@1` codec. The planner
12
+ * already dispatches per `(table, field)` based on the field's
13
+ * `codecId` (new field for `'added'` / `'altered'`, prior field for
14
+ * `'dropped'`), so this hook only fires when a cipherstash field is
15
+ * involved. Per field the hook emits **one
16
+ * `cipherstashAddSearchConfig` Call per enabled flag** in `typeParams`
17
+ * (and one `cipherstashRemoveSearchConfig` Call per previously-enabled
18
+ * flag on drop / altered-off).
19
+ *
20
+ * Flag → EQL index mapping:
21
+ *
22
+ * - `equality: true` → `'unique'` index
23
+ * - `freeTextSearch: true` → `'match'` index
24
+ *
25
+ * One Call per flag (rather than a single multi-statement Call per
26
+ * field) keeps each Call independently invertible by a paired
27
+ * `cipherstashRemoveSearchConfig` Call carrying the same index name,
28
+ * and the op-graph stays per-flag granular for diffing.
29
+ *
30
+ * `'altered'` events decompose into per-flag deltas:
31
+ * - flag flipped on → emit `cipherstashAddSearchConfig({...})`.
32
+ * - flag flipped off → emit `cipherstashRemoveSearchConfig({...})`.
33
+ * - flag unchanged → no Call.
34
+ *
35
+ * `invariantId` template (carried on the Call's `toOp()` output):
36
+ * `cipherstash-codec:<table>.<field>:<action>:<index>@v1`
37
+ * `<action>` ∈ `'add-search-config' | 'remove-search-config'`,
38
+ * `<index>` ∈ `'unique' | 'match'`.
39
+ * Stable across regenerations because every input is deterministic.
40
+ */
41
+
42
+ import type { CodecControlHooks, FieldEventContext } from '@prisma-next/family-sql/control';
43
+ import type { OpFactoryCall } from '@prisma-next/framework-components/control';
44
+ import { CIPHERSTASH_STRING_CODEC_ID } from '../extension-metadata/constants';
45
+ import {
46
+ type CipherstashSearchIndex,
47
+ cipherstashAddSearchConfig,
48
+ cipherstashRemoveSearchConfig,
49
+ } from './call-classes';
50
+
51
+ type FlagName = 'equality' | 'freeTextSearch';
52
+
53
+ const FLAG_TO_INDEX: Readonly<Record<FlagName, CipherstashSearchIndex>> = {
54
+ equality: 'unique',
55
+ freeTextSearch: 'match',
56
+ };
57
+
58
+ const ALL_FLAGS: ReadonlyArray<FlagName> = ['equality', 'freeTextSearch'];
59
+
60
+ function isEnabled(
61
+ typeParams: Readonly<Record<string, unknown>> | undefined,
62
+ flag: FlagName,
63
+ ): boolean {
64
+ return typeParams !== undefined && typeParams[flag] === true;
65
+ }
66
+
67
+ /**
68
+ * Hook entry point. Called by `planFieldEventOperations` for every per-
69
+ * field delta dispatched to `cipherstash:string@1`. Pure and
70
+ * synchronous; callers replay it deterministically when re-emitting.
71
+ */
72
+ function onFieldEvent(
73
+ event: 'added' | 'dropped' | 'altered',
74
+ ctx: FieldEventContext,
75
+ ): readonly OpFactoryCall[] {
76
+ const { tableName, fieldName, priorField, newField } = ctx;
77
+
78
+ if (event === 'added') {
79
+ if (newField === undefined) return [];
80
+ const calls: OpFactoryCall[] = [];
81
+ for (const flag of ALL_FLAGS) {
82
+ if (isEnabled(newField.typeParams, flag)) {
83
+ calls.push(
84
+ cipherstashAddSearchConfig({
85
+ table: tableName,
86
+ column: fieldName,
87
+ index: FLAG_TO_INDEX[flag],
88
+ }),
89
+ );
90
+ }
91
+ }
92
+ return calls;
93
+ }
94
+
95
+ if (event === 'dropped') {
96
+ if (priorField === undefined) return [];
97
+ const calls: OpFactoryCall[] = [];
98
+ for (const flag of ALL_FLAGS) {
99
+ if (isEnabled(priorField.typeParams, flag)) {
100
+ calls.push(
101
+ cipherstashRemoveSearchConfig({
102
+ table: tableName,
103
+ column: fieldName,
104
+ index: FLAG_TO_INDEX[flag],
105
+ }),
106
+ );
107
+ }
108
+ }
109
+ return calls;
110
+ }
111
+
112
+ if (priorField === undefined || newField === undefined) return [];
113
+ const calls: OpFactoryCall[] = [];
114
+ for (const flag of ALL_FLAGS) {
115
+ const before = isEnabled(priorField.typeParams, flag);
116
+ const after = isEnabled(newField.typeParams, flag);
117
+ if (after && !before) {
118
+ calls.push(
119
+ cipherstashAddSearchConfig({
120
+ table: tableName,
121
+ column: fieldName,
122
+ index: FLAG_TO_INDEX[flag],
123
+ }),
124
+ );
125
+ } else if (before && !after) {
126
+ calls.push(
127
+ cipherstashRemoveSearchConfig({
128
+ table: tableName,
129
+ column: fieldName,
130
+ index: FLAG_TO_INDEX[flag],
131
+ }),
132
+ );
133
+ }
134
+ }
135
+ return calls;
136
+ }
137
+
138
+ /**
139
+ * The DDL type for an `Encrypted<string>` column is always
140
+ * `eql_v2_encrypted` regardless of any `typeParams` flags: the
141
+ * search-config wiring is delivered by the codec hook's
142
+ * `cipherstashAddSearchConfig` Calls (separate rows in
143
+ * `eql_v2_configuration`), not by the column type itself. Returning
144
+ * `nativeType` unchanged tells the planner "no expansion required" —
145
+ * see `expandParameterizedTypeSql` in
146
+ * `packages/3-targets/3-targets/postgres/src/core/migrations/planner-ddl-builders.ts`,
147
+ * which only requires this hook to *exist* for any column carrying
148
+ * `typeParams`. Without it, the planner refuses to render the column
149
+ * (the existing arktype-json extension wires the same identity hook).
150
+ */
151
+ const expandNativeType: NonNullable<CodecControlHooks['expandNativeType']> = ({ nativeType }) =>
152
+ nativeType;
153
+
154
+ export const cipherstashStringCodecHooks: CodecControlHooks = { onFieldEvent, expandNativeType };
155
+
156
+ /** Re-export the codec id alongside the hooks so wiring sites import them together. */
157
+ export { CIPHERSTASH_STRING_CODEC_ID };