@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.
- package/README.md +153 -0
- package/dist/call-classes-CSvD7w8U.mjs +206 -0
- package/dist/call-classes-CSvD7w8U.mjs.map +1 -0
- package/dist/column-types.d.mts +33 -0
- package/dist/column-types.d.mts.map +1 -0
- package/dist/column-types.mjs +42 -0
- package/dist/column-types.mjs.map +1 -0
- package/dist/constants-BDxL9Pe3.d.mts +22 -0
- package/dist/constants-BDxL9Pe3.d.mts.map +1 -0
- package/dist/constants-B_2TNvUi.mjs +46 -0
- package/dist/constants-B_2TNvUi.mjs.map +1 -0
- package/dist/control.d.mts +7 -0
- package/dist/control.d.mts.map +1 -0
- package/dist/control.mjs +430 -0
- package/dist/control.mjs.map +1 -0
- package/dist/descriptor-meta-BgQfZTAF.mjs +129 -0
- package/dist/descriptor-meta-BgQfZTAF.mjs.map +1 -0
- package/dist/envelope-P9BxfJNr.mjs +271 -0
- package/dist/envelope-P9BxfJNr.mjs.map +1 -0
- package/dist/middleware.d.mts +13 -0
- package/dist/middleware.d.mts.map +1 -0
- package/dist/middleware.mjs +129 -0
- package/dist/middleware.mjs.map +1 -0
- package/dist/migration.d.mts +141 -0
- package/dist/migration.d.mts.map +1 -0
- package/dist/migration.mjs +2 -0
- package/dist/operation-types.d.mts +49 -0
- package/dist/operation-types.d.mts.map +1 -0
- package/dist/operation-types.mjs +1 -0
- package/dist/pack.d.mts +86 -0
- package/dist/pack.d.mts.map +1 -0
- package/dist/pack.mjs +2 -0
- package/dist/runtime.d.mts +207 -0
- package/dist/runtime.d.mts.map +1 -0
- package/dist/runtime.mjs +429 -0
- package/dist/runtime.mjs.map +1 -0
- package/dist/sdk-D5FTGyzp.d.mts +67 -0
- package/dist/sdk-D5FTGyzp.d.mts.map +1 -0
- package/package.json +69 -0
- package/src/contract/authoring.ts +62 -0
- package/src/contract/contract.d.ts +149 -0
- package/src/contract/contract.json +104 -0
- package/src/contract/contract.prisma +46 -0
- package/src/execution/abort.ts +143 -0
- package/src/execution/codec-runtime.ts +209 -0
- package/src/execution/decrypt-all.ts +217 -0
- package/src/execution/envelope.ts +263 -0
- package/src/execution/operators.ts +211 -0
- package/src/execution/parameterized.ts +71 -0
- package/src/execution/routing.ts +93 -0
- package/src/execution/sdk.ts +68 -0
- package/src/exports/column-types.ts +62 -0
- package/src/exports/contract-space-typing.ts +86 -0
- package/src/exports/control.ts +120 -0
- package/src/exports/middleware.ts +24 -0
- package/src/exports/migration.ts +43 -0
- package/src/exports/operation-types.ts +16 -0
- package/src/exports/pack.ts +13 -0
- package/src/exports/runtime.ts +110 -0
- package/src/extension-metadata/codec-metadata.ts +81 -0
- package/src/extension-metadata/constants.ts +70 -0
- package/src/extension-metadata/descriptor-meta.ts +76 -0
- package/src/middleware/bulk-encrypt.ts +192 -0
- package/src/migration/call-classes.ts +350 -0
- package/src/migration/cipherstash-codec.ts +157 -0
- package/src/migration/eql-bundle.ts +29 -0
- package/src/migration/eql-install.generated.ts +5751 -0
- 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 };
|