@proteinjs/db 1.35.1 → 1.36.0

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 (160) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/dist/generated/index.d.ts.map +1 -1
  3. package/dist/generated/index.js +7 -1
  4. package/dist/generated/index.js.map +1 -1
  5. package/dist/generated/test/index.d.ts.map +1 -1
  6. package/dist/generated/test/index.js +7 -1
  7. package/dist/generated/test/index.js.map +1 -1
  8. package/dist/index.d.ts +15 -0
  9. package/dist/index.d.ts.map +1 -1
  10. package/dist/index.js +15 -0
  11. package/dist/index.js.map +1 -1
  12. package/dist/src/Db.d.ts +25 -0
  13. package/dist/src/Db.d.ts.map +1 -1
  14. package/dist/src/Db.js +219 -22
  15. package/dist/src/Db.js.map +1 -1
  16. package/dist/src/Record.d.ts +7 -1
  17. package/dist/src/Record.d.ts.map +1 -1
  18. package/dist/src/Record.js +56 -21
  19. package/dist/src/Record.js.map +1 -1
  20. package/dist/src/Table.d.ts +82 -1
  21. package/dist/src/Table.d.ts.map +1 -1
  22. package/dist/src/Table.js +24 -1
  23. package/dist/src/Table.js.map +1 -1
  24. package/dist/src/TableQueryTransformProvider.d.ts +17 -0
  25. package/dist/src/TableQueryTransformProvider.d.ts.map +1 -0
  26. package/dist/src/TableQueryTransformProvider.js +34 -0
  27. package/dist/src/TableQueryTransformProvider.js.map +1 -0
  28. package/dist/src/encryption/Base64Url.d.ts +12 -0
  29. package/dist/src/encryption/Base64Url.d.ts.map +1 -0
  30. package/dist/src/encryption/Base64Url.js +21 -0
  31. package/dist/src/encryption/Base64Url.js.map +1 -0
  32. package/dist/src/encryption/DataEncryptionKeyTable.d.ts +33 -0
  33. package/dist/src/encryption/DataEncryptionKeyTable.d.ts.map +1 -0
  34. package/dist/src/encryption/DataEncryptionKeyTable.js +45 -0
  35. package/dist/src/encryption/DataEncryptionKeyTable.js.map +1 -0
  36. package/dist/src/encryption/DataKeyStore.d.ts +84 -0
  37. package/dist/src/encryption/DataKeyStore.d.ts.map +1 -0
  38. package/dist/src/encryption/DataKeyStore.js +481 -0
  39. package/dist/src/encryption/DataKeyStore.js.map +1 -0
  40. package/dist/src/encryption/DbEncryptionConfig.d.ts +62 -0
  41. package/dist/src/encryption/DbEncryptionConfig.d.ts.map +1 -0
  42. package/dist/src/encryption/DbEncryptionConfig.js +67 -0
  43. package/dist/src/encryption/DbEncryptionConfig.js.map +1 -0
  44. package/dist/src/encryption/EncryptedColumnQueryError.d.ts +10 -0
  45. package/dist/src/encryption/EncryptedColumnQueryError.d.ts.map +1 -0
  46. package/dist/src/encryption/EncryptedColumnQueryError.js +38 -0
  47. package/dist/src/encryption/EncryptedColumnQueryError.js.map +1 -0
  48. package/dist/src/encryption/EncryptedColumnQueryTransform.d.ts +52 -0
  49. package/dist/src/encryption/EncryptedColumnQueryTransform.d.ts.map +1 -0
  50. package/dist/src/encryption/EncryptedColumnQueryTransform.js +423 -0
  51. package/dist/src/encryption/EncryptedColumnQueryTransform.js.map +1 -0
  52. package/dist/src/encryption/EncryptedColumns.d.ts +69 -0
  53. package/dist/src/encryption/EncryptedColumns.d.ts.map +1 -0
  54. package/dist/src/encryption/EncryptedColumns.js +286 -0
  55. package/dist/src/encryption/EncryptedColumns.js.map +1 -0
  56. package/dist/src/encryption/EncryptionDerivedTableRegistry.d.ts +20 -0
  57. package/dist/src/encryption/EncryptionDerivedTableRegistry.d.ts.map +1 -0
  58. package/dist/src/encryption/EncryptionDerivedTableRegistry.js +36 -0
  59. package/dist/src/encryption/EncryptionDerivedTableRegistry.js.map +1 -0
  60. package/dist/src/encryption/EncryptionEnvelope.d.ts +25 -0
  61. package/dist/src/encryption/EncryptionEnvelope.d.ts.map +1 -0
  62. package/dist/src/encryption/EncryptionEnvelope.js +59 -0
  63. package/dist/src/encryption/EncryptionEnvelope.js.map +1 -0
  64. package/dist/src/encryption/EncryptionLifecycleWalker.d.ts +65 -0
  65. package/dist/src/encryption/EncryptionLifecycleWalker.d.ts.map +1 -0
  66. package/dist/src/encryption/EncryptionLifecycleWalker.js +343 -0
  67. package/dist/src/encryption/EncryptionLifecycleWalker.js.map +1 -0
  68. package/dist/src/encryption/EncryptionRecordHooks.d.ts +47 -0
  69. package/dist/src/encryption/EncryptionRecordHooks.d.ts.map +1 -0
  70. package/dist/src/encryption/EncryptionRecordHooks.js +229 -0
  71. package/dist/src/encryption/EncryptionRecordHooks.js.map +1 -0
  72. package/dist/src/encryption/EncryptionTokenMaintenance.d.ts +34 -0
  73. package/dist/src/encryption/EncryptionTokenMaintenance.d.ts.map +1 -0
  74. package/dist/src/encryption/EncryptionTokenMaintenance.js +211 -0
  75. package/dist/src/encryption/EncryptionTokenMaintenance.js.map +1 -0
  76. package/dist/src/encryption/InMemoryMasterKeyProvider.d.ts +20 -0
  77. package/dist/src/encryption/InMemoryMasterKeyProvider.d.ts.map +1 -0
  78. package/dist/src/encryption/InMemoryMasterKeyProvider.js +87 -0
  79. package/dist/src/encryption/InMemoryMasterKeyProvider.js.map +1 -0
  80. package/dist/src/encryption/MasterKeyProvider.d.ts +27 -0
  81. package/dist/src/encryption/MasterKeyProvider.d.ts.map +1 -0
  82. package/dist/src/encryption/MasterKeyProvider.js +3 -0
  83. package/dist/src/encryption/MasterKeyProvider.js.map +1 -0
  84. package/dist/src/encryption/SearchTokenizer.d.ts +49 -0
  85. package/dist/src/encryption/SearchTokenizer.d.ts.map +1 -0
  86. package/dist/src/encryption/SearchTokenizer.js +106 -0
  87. package/dist/src/encryption/SearchTokenizer.js.map +1 -0
  88. package/dist/src/schema/TableManager.d.ts +10 -0
  89. package/dist/src/schema/TableManager.d.ts.map +1 -1
  90. package/dist/src/schema/TableManager.js +62 -5
  91. package/dist/src/schema/TableManager.js.map +1 -1
  92. package/dist/src/tables/MigrationTable.d.ts +2 -2
  93. package/dist/test/EncryptedColumnsSchema.test.d.ts +2 -0
  94. package/dist/test/EncryptedColumnsSchema.test.d.ts.map +1 -0
  95. package/dist/test/EncryptedColumnsSchema.test.js +151 -0
  96. package/dist/test/EncryptedColumnsSchema.test.js.map +1 -0
  97. package/dist/test/EncryptionEnvelope.test.d.ts +2 -0
  98. package/dist/test/EncryptionEnvelope.test.d.ts.map +1 -0
  99. package/dist/test/EncryptionEnvelope.test.js +109 -0
  100. package/dist/test/EncryptionEnvelope.test.js.map +1 -0
  101. package/dist/test/EncryptionQueryContract.test.d.ts +2 -0
  102. package/dist/test/EncryptionQueryContract.test.d.ts.map +1 -0
  103. package/dist/test/EncryptionQueryContract.test.js +399 -0
  104. package/dist/test/EncryptionQueryContract.test.js.map +1 -0
  105. package/dist/test/SearchTokenizer.test.d.ts +2 -0
  106. package/dist/test/SearchTokenizer.test.d.ts.map +1 -0
  107. package/dist/test/SearchTokenizer.test.js +64 -0
  108. package/dist/test/SearchTokenizer.test.js.map +1 -0
  109. package/dist/test/reusable/CascadeDeleteTests.js +2 -2
  110. package/dist/test/reusable/CascadeDeleteTests.js.map +1 -1
  111. package/dist/test/reusable/ColumnTypesTests.js +2 -2
  112. package/dist/test/reusable/ColumnTypesTests.js.map +1 -1
  113. package/dist/test/reusable/CrudTests.js +2 -2
  114. package/dist/test/reusable/CrudTests.js.map +1 -1
  115. package/dist/test/reusable/DynamicReferenceColumnTests.js +2 -2
  116. package/dist/test/reusable/DynamicReferenceColumnTests.js.map +1 -1
  117. package/dist/test/reusable/PreloadReferencesTests.js +2 -2
  118. package/dist/test/reusable/PreloadReferencesTests.js.map +1 -1
  119. package/dist/test/reusable/RecordIteratorTests.js +2 -2
  120. package/dist/test/reusable/RecordIteratorTests.js.map +1 -1
  121. package/dist/test/reusable/TableManagerTests.js +2 -2
  122. package/dist/test/reusable/TableManagerTests.js.map +1 -1
  123. package/dist/test/reusable/TransactionTests.js +2 -2
  124. package/dist/test/reusable/TransactionTests.js.map +1 -1
  125. package/generated/index.ts +7 -1
  126. package/generated/test/index.ts +7 -1
  127. package/index.ts +16 -0
  128. package/package.json +3 -3
  129. package/src/Db.ts +137 -12
  130. package/src/Record.ts +21 -4
  131. package/src/Table.ts +109 -2
  132. package/src/TableQueryTransformProvider.ts +31 -0
  133. package/src/encryption/Base64Url.ts +14 -0
  134. package/src/encryption/DataEncryptionKeyTable.ts +40 -0
  135. package/src/encryption/DataKeyStore.ts +305 -0
  136. package/src/encryption/DbEncryptionConfig.ts +92 -0
  137. package/src/encryption/EncryptedColumnQueryError.ts +15 -0
  138. package/src/encryption/EncryptedColumnQueryTransform.ts +383 -0
  139. package/src/encryption/EncryptedColumns.ts +318 -0
  140. package/src/encryption/EncryptionDerivedTableRegistry.ts +34 -0
  141. package/src/encryption/EncryptionEnvelope.ts +60 -0
  142. package/src/encryption/EncryptionLifecycleWalker.ts +243 -0
  143. package/src/encryption/EncryptionRecordHooks.ts +167 -0
  144. package/src/encryption/EncryptionTokenMaintenance.ts +123 -0
  145. package/src/encryption/InMemoryMasterKeyProvider.ts +42 -0
  146. package/src/encryption/MasterKeyProvider.ts +26 -0
  147. package/src/encryption/SearchTokenizer.ts +103 -0
  148. package/src/schema/TableManager.ts +43 -1
  149. package/test/EncryptedColumnsSchema.test.ts +172 -0
  150. package/test/EncryptionEnvelope.test.ts +66 -0
  151. package/test/EncryptionQueryContract.test.ts +195 -0
  152. package/test/SearchTokenizer.test.ts +67 -0
  153. package/test/reusable/CascadeDeleteTests.ts +2 -2
  154. package/test/reusable/ColumnTypesTests.ts +2 -2
  155. package/test/reusable/CrudTests.ts +2 -2
  156. package/test/reusable/DynamicReferenceColumnTests.ts +2 -2
  157. package/test/reusable/PreloadReferencesTests.ts +2 -2
  158. package/test/reusable/RecordIteratorTests.ts +2 -2
  159. package/test/reusable/TableManagerTests.ts +2 -2
  160. package/test/reusable/TransactionTests.ts +2 -2
@@ -0,0 +1,383 @@
1
+ import { isInstanceOf } from '@proteinjs/util';
2
+ import {
3
+ Aggregate,
4
+ ColumnQueryTransform,
5
+ ColumnQueryTransformContext,
6
+ Condition,
7
+ QueryBuilder,
8
+ SortCriteria,
9
+ } from '@proteinjs/db-query';
10
+ import type { Column, ColumnQueryRuntime, EncryptedColumnConfig, Table } from '../Table';
11
+ import { EncryptedColumns, EncryptionSearchToken } from './EncryptedColumns';
12
+ import { EncryptedColumnQueryError } from './EncryptedColumnQueryError';
13
+ import { getDbEncryptionConfig } from './DbEncryptionConfig';
14
+ import { DataKeyMaterial, DataKeyStore } from './DataKeyStore';
15
+ import { SearchTokenizer } from './SearchTokenizer';
16
+
17
+ interface LikeShape {
18
+ kind: 'contains' | 'prefix' | 'suffix' | 'exact' | 'match-any';
19
+ needle: string;
20
+ }
21
+
22
+ /**
23
+ * An encrypted column's `ColumnQueryTransform` — the query-side half of the column's
24
+ * encryption contract (`EncryptionRecordHooks` is the storage-side half). Attached to the
25
+ * column by `EncryptedColumns.ensureSchema` (derived from `ColumnOptions.encrypted`, never
26
+ * caller-declared) and applied by `QueryBuilder.applyColumnTransforms` on every query, so
27
+ * callers keep writing exactly what they write today:
28
+ *
29
+ * - **Equality** (`=`, `IN`) on a column declared `searchable: 'equality'` rewrites onto the
30
+ * whole-value fingerprint companion — one indexed lookup, exact (fingerprints cover the
31
+ * exact value), fingerprinted once per accessible key owner (shared-scope reads OR the
32
+ * owners' fingerprints).
33
+ * - **Contains / prefix LIKE** on a column declared `searchable: 'contains'` resolves in two
34
+ * index-bounded steps: (1) the token table answers a candidate id set — rows holding ALL
35
+ * of the query's fragment fingerprints under some accessible owner key; (2) candidates
36
+ * are verified against the decrypted value (the pg_trgm recheck semantics — no false
37
+ * positive survives, no true match ≥3-chars-per-word is missed). The condition then
38
+ * rewrites to the VERIFIED id set, so it composes exactly under any boolean structure,
39
+ * ORDER BY, pagination, and COUNT.
40
+ * - **Out-of-contract shapes are REJECTED loudly at query-build time** — undeclared ORDER BY,
41
+ * ranges, arbitrary LIKE patterns, aggregation over the value — each error naming the
42
+ * sanctioned paths. A limitation a developer cannot hit silently is a contract; one they
43
+ * discover in production is a bug.
44
+ */
45
+ export class EncryptedColumnQueryTransform implements ColumnQueryTransform {
46
+ private encryptedColumns = new EncryptedColumns();
47
+ private tokenizer = new SearchTokenizer();
48
+
49
+ constructor(
50
+ private table: Table<any>,
51
+ private prop: string,
52
+ private column: Column<any, any>,
53
+ private config: EncryptedColumnConfig,
54
+ private runtime: ColumnQueryRuntime
55
+ ) {}
56
+
57
+ async transformCondition(
58
+ condition: Condition<any>,
59
+ context: ColumnQueryTransformContext
60
+ ): Promise<Condition<any> | undefined> {
61
+ const operator = condition.operator as string;
62
+ if (operator === 'IS NULL' || operator === 'IS NOT NULL') {
63
+ return undefined; // encryption preserves null-ness — native
64
+ }
65
+ if (condition.value === null) {
66
+ return undefined; // `= null` renders IS NULL; a normalized empty-IN renders 1=0
67
+ }
68
+ if (isInstanceOf(condition.value, QueryBuilder)) {
69
+ throw new EncryptedColumnQueryError(
70
+ `Cannot compare encrypted column \`${this.table.name}.${this.prop}\` against a subquery. ` +
71
+ `Compare on a metadata column, or resolve the subquery app-side and pass literal values.`
72
+ );
73
+ }
74
+
75
+ switch (operator) {
76
+ case '=':
77
+ case 'IN':
78
+ return await this.equalityCondition(condition, context);
79
+ case 'LIKE':
80
+ return await this.likeCondition(condition, context);
81
+ case '<':
82
+ case '>':
83
+ case '<=':
84
+ case '>=':
85
+ case 'BETWEEN':
86
+ throw new EncryptedColumnQueryError(
87
+ `Cannot apply range condition (${operator}) to encrypted column \`${this.table.name}.${this.prop}\`. ` +
88
+ `Ranges over an encrypted value are not supported. Options: filter by a metadata column; ` +
89
+ `filter the fetched, decrypted rows app-side (bounded sets); or use ` +
90
+ `encrypted: { sortKey: { revealPrefix: N } } ordering with app-side refinement.`
91
+ );
92
+ default:
93
+ // <>, !=, NOT IN, NOT, NOT LIKE — negations over lossy/keyed representations cannot
94
+ // be answered exactly DB-side.
95
+ throw new EncryptedColumnQueryError(
96
+ `Cannot apply ${operator} to encrypted column \`${this.table.name}.${this.prop}\`. Options: use ` +
97
+ `equality/contains conditions (searchable declarations) and exclude app-side; or filter ` +
98
+ `by a metadata column.`
99
+ );
100
+ }
101
+ }
102
+
103
+ transformSort(criteria: SortCriteria<any>): SortCriteria<any> {
104
+ if (criteria.byValues && criteria.byValues.length > 0) {
105
+ throw new EncryptedColumnQueryError(
106
+ `Cannot ORDER BY specific values of encrypted column \`${this.table.name}.${this.prop}\`: value-CASE ` +
107
+ `ordering compares the stored value. Options: order by a metadata column; or order the ` +
108
+ `fetched rows for display (bounded sets).`
109
+ );
110
+ }
111
+
112
+ if (!this.config.sortKey) {
113
+ throw new EncryptedColumnQueryError(
114
+ `Cannot ORDER BY encrypted column \`${this.table.name}.${this.prop}\` (no sortKey declared). Options: ` +
115
+ `sort by a metadata column; sort the fetched rows for display (bounded sets); or declare ` +
116
+ `encrypted: { sortKey: { revealPrefix: N } } — a documented, bounded reveal of the first ` +
117
+ `N characters' order.`
118
+ );
119
+ }
120
+
121
+ return { ...criteria, field: this.encryptedColumns.sortCompanionProp(this.table, this.prop) };
122
+ }
123
+
124
+ transformAggregate(aggregate: Aggregate<any>): Aggregate<any> | undefined {
125
+ if (aggregate.function === 'COUNT') {
126
+ return undefined; // counting rows never reads the value — native
127
+ }
128
+
129
+ throw new EncryptedColumnQueryError(
130
+ `Cannot ${aggregate.function} encrypted column \`${this.table.name}.${this.prop}\`. Aggregation over ` +
131
+ `an encrypted value is not supported. Options: aggregate a metadata column; or fetch the ` +
132
+ `rows and aggregate the decrypted values app-side (bounded sets).`
133
+ );
134
+ }
135
+
136
+ transformGroupByField(): string {
137
+ throw new EncryptedColumnQueryError(
138
+ `Cannot GROUP BY encrypted column \`${this.table.name}.${this.prop}\`. Grouping over an encrypted ` +
139
+ `value is not supported. Options: group by a metadata column; or group the fetched, ` +
140
+ `decrypted rows app-side (bounded sets).`
141
+ );
142
+ }
143
+
144
+ private async equalityCondition(
145
+ condition: Condition<any>,
146
+ context: ColumnQueryTransformContext
147
+ ): Promise<Condition<any>> {
148
+ if (this.config.searchable !== 'equality') {
149
+ throw new EncryptedColumnQueryError(
150
+ `Cannot compare encrypted column \`${this.table.name}.${this.prop}\` by value (${condition.operator}): ` +
151
+ `the column does not declare encrypted: { searchable: 'equality' }. Options: declare it (plus the ` +
152
+ `lifecycle backfill) for indexed exact lookups; declare searchable: 'contains' and use LIKE; ` +
153
+ `or look the row up by a metadata column.`
154
+ );
155
+ }
156
+ if (!context.caseSensitive) {
157
+ throw new EncryptedColumnQueryError(
158
+ `Cannot case-insensitively compare encrypted column \`${this.table.name}.${this.prop}\`: equality ` +
159
+ `fingerprints cover the exact value. Options: compare the exact value; or declare ` +
160
+ `searchable: 'contains' and use LIKE for token-normalized matching.`
161
+ );
162
+ }
163
+
164
+ const values: any[] = Array.isArray(condition.value) ? condition.value : [condition.value];
165
+ for (const value of values) {
166
+ if (typeof value !== 'string') {
167
+ throw new EncryptedColumnQueryError(
168
+ `Encrypted column \`${this.table.name}.${this.prop}\` equality values must be strings; ` +
169
+ `got ${typeof value}.`
170
+ );
171
+ }
172
+ }
173
+
174
+ const keys = await this.accessibleIndexKeys();
175
+ const fingerprints: string[] = [];
176
+ for (const key of keys) {
177
+ for (const value of values) {
178
+ fingerprints.push(this.tokenizer.equalityFingerprint(value, key.indexKey));
179
+ }
180
+ }
181
+
182
+ const companionProp = this.encryptedColumns.eqCompanionProp(this.table, this.prop);
183
+ if (fingerprints.length === 1) {
184
+ return { field: companionProp, operator: '=', value: fingerprints[0] };
185
+ }
186
+
187
+ return { field: companionProp, operator: 'IN', value: fingerprints };
188
+ }
189
+
190
+ private async likeCondition(
191
+ condition: Condition<any>,
192
+ context: ColumnQueryTransformContext
193
+ ): Promise<Condition<any>> {
194
+ if (this.config.searchable !== 'contains') {
195
+ throw new EncryptedColumnQueryError(
196
+ `Cannot LIKE-search encrypted column \`${this.table.name}.${this.prop}\`: the column does not declare ` +
197
+ `encrypted: { searchable: 'contains' }. Options: declare it (plus the lifecycle backfill) ` +
198
+ `for indexed contains/prefix search; or search a metadata column.`
199
+ );
200
+ }
201
+
202
+ const shape = this.parseLikePattern(condition.value);
203
+ if (shape.kind === 'match-any') {
204
+ // LIKE '%' / '%%' — matches every non-null value.
205
+ return { field: this.prop, operator: 'IS NOT NULL' };
206
+ }
207
+
208
+ const verifiedIds = await this.verifiedCandidateIds(shape, context.caseSensitive);
209
+ return { field: 'id', operator: 'IN', value: verifiedIds };
210
+ }
211
+
212
+ /**
213
+ * The two index-bounded steps of contains search (class doc): token-cover candidates,
214
+ * then decrypt-and-verify. Returns the ids whose values TRULY match the pattern.
215
+ */
216
+ private async verifiedCandidateIds(shape: LikeShape, caseSensitive: boolean): Promise<string[]> {
217
+ const keys = await this.accessibleIndexKeys();
218
+ if (keys.length === 0) {
219
+ return [];
220
+ }
221
+
222
+ const fragments = this.tokenizer.fragmentsForQuery(shape.needle);
223
+ if (fragments.length === 0) {
224
+ // A needle with no indexable words (punctuation-only). Verification alone cannot be
225
+ // index-bounded; reject rather than silently scan.
226
+ throw new EncryptedColumnQueryError(
227
+ `Cannot search encrypted column \`${this.table.name}.${this.prop}\` for a pattern with no letters or ` +
228
+ `digits (${JSON.stringify(shape.needle)}). Search patterns must contain at least one word character.`
229
+ );
230
+ }
231
+
232
+ const candidateIds = await this.tokenCoverCandidates(fragments, keys);
233
+ if (candidateIds.length === 0) {
234
+ return [];
235
+ }
236
+
237
+ const verifyQb = new QueryBuilder<any>(this.table.name)
238
+ .select({ fields: ['id', this.prop] })
239
+ .condition({ field: 'id', operator: 'IN', value: candidateIds });
240
+ const rows = await this.runtime.query(this.table, verifyQb);
241
+ const verified: string[] = [];
242
+ for (const row of rows) {
243
+ const value = row[this.prop];
244
+ if (typeof value === 'string' && this.matches(value, shape, caseSensitive)) {
245
+ verified.push(row.id);
246
+ }
247
+ }
248
+
249
+ return verified;
250
+ }
251
+
252
+ /** Candidate ids: rows whose token rows cover ALL query fragments under SOME accessible key. */
253
+ private async tokenCoverCandidates(fragments: string[], keys: DataKeyMaterial[]): Promise<string[]> {
254
+ const tokenTable = this.encryptedColumns.tokenTableFor(this.table);
255
+ if (!tokenTable) {
256
+ return [];
257
+ }
258
+
259
+ // fingerprint -> the (key, fragment) pairs that produced it
260
+ const fingerprintSources = new Map<string, { keyIndex: number; fragment: string }[]>();
261
+ for (let keyIndex = 0; keyIndex < keys.length; keyIndex++) {
262
+ for (const fragment of fragments) {
263
+ const fingerprint = this.tokenizer.fingerprint(fragment, keys[keyIndex].indexKey);
264
+ const sources = fingerprintSources.get(fingerprint) ?? [];
265
+ sources.push({ keyIndex, fragment });
266
+ fingerprintSources.set(fingerprint, sources);
267
+ }
268
+ }
269
+
270
+ const tokenQb = new QueryBuilder<EncryptionSearchToken>(tokenTable.name)
271
+ .select({ fields: ['recordId', 'token'] })
272
+ .condition({ field: 'columnName', operator: '=', value: this.column.name })
273
+ .condition({ field: 'token', operator: 'IN', value: Array.from(fingerprintSources.keys()) });
274
+ const tokenRows = (await this.runtime.systemQuery(tokenTable, tokenQb)) as EncryptionSearchToken[];
275
+
276
+ // recordId -> keyIndex -> matched fragments
277
+ const coverage = new Map<string, Map<number, Set<string>>>();
278
+ for (const tokenRow of tokenRows) {
279
+ const sources = fingerprintSources.get(tokenRow.token);
280
+ if (!sources) {
281
+ continue;
282
+ }
283
+ let byKey = coverage.get(tokenRow.recordId);
284
+ if (!byKey) {
285
+ byKey = new Map();
286
+ coverage.set(tokenRow.recordId, byKey);
287
+ }
288
+ for (const source of sources) {
289
+ let matched = byKey.get(source.keyIndex);
290
+ if (!matched) {
291
+ matched = new Set();
292
+ byKey.set(source.keyIndex, matched);
293
+ }
294
+ matched.add(source.fragment);
295
+ }
296
+ }
297
+
298
+ const candidates: string[] = [];
299
+ for (const [recordId, byKey] of Array.from(coverage.entries())) {
300
+ for (const matched of Array.from(byKey.values())) {
301
+ if (matched.size === fragments.length) {
302
+ candidates.push(recordId);
303
+ break;
304
+ }
305
+ }
306
+ }
307
+
308
+ return candidates;
309
+ }
310
+
311
+ private matches(value: string, shape: LikeShape, caseSensitive: boolean): boolean {
312
+ const haystack = caseSensitive ? value : value.toLowerCase();
313
+ const needle = caseSensitive ? shape.needle : shape.needle.toLowerCase();
314
+ switch (shape.kind) {
315
+ case 'contains':
316
+ return haystack.includes(needle);
317
+ case 'prefix':
318
+ return haystack.startsWith(needle);
319
+ case 'suffix':
320
+ return haystack.endsWith(needle);
321
+ case 'exact':
322
+ return haystack === needle;
323
+ default:
324
+ return false;
325
+ }
326
+ }
327
+
328
+ private parseLikePattern(pattern: unknown): LikeShape {
329
+ if (typeof pattern !== 'string') {
330
+ throw new EncryptedColumnQueryError(
331
+ `Encrypted column \`${this.table.name}.${this.prop}\` LIKE patterns must be strings; ` +
332
+ `got ${typeof pattern}.`
333
+ );
334
+ }
335
+
336
+ const rejectPattern = () => {
337
+ throw new EncryptedColumnQueryError(
338
+ `Cannot apply LIKE pattern ${JSON.stringify(pattern)} to encrypted column ` +
339
+ `\`${this.table.name}.${this.prop}\`. Supported: contains ('%q%'), prefix ('q%'), suffix ('%q'), ` +
340
+ `and exact ('q'). Mid-pattern wildcards, '_' wildcards, and escapes are not supported on ` +
341
+ `encrypted columns; search a metadata column for those shapes.`
342
+ );
343
+ };
344
+
345
+ const leading = pattern.startsWith('%');
346
+ const trailing = pattern.endsWith('%') && pattern.length > (leading ? 1 : 0);
347
+ const needle = pattern.slice(leading ? 1 : 0, trailing ? -1 : undefined);
348
+ if (needle.length === 0) {
349
+ if (leading || trailing) {
350
+ return { kind: 'match-any', needle: '' };
351
+ }
352
+ return { kind: 'exact', needle: '' };
353
+ }
354
+ if (needle.includes('%') || needle.includes('_') || needle.includes('\\')) {
355
+ rejectPattern();
356
+ }
357
+
358
+ if (leading && trailing) {
359
+ return { kind: 'contains', needle };
360
+ }
361
+ if (trailing) {
362
+ return { kind: 'prefix', needle };
363
+ }
364
+ if (leading) {
365
+ return { kind: 'suffix', needle };
366
+ }
367
+ return { kind: 'exact', needle };
368
+ }
369
+
370
+ private async accessibleIndexKeys(): Promise<DataKeyMaterial[]> {
371
+ const config = getDbEncryptionConfig();
372
+ if (!config.getAccessibleKeyOwners) {
373
+ throw new EncryptedColumnQueryError(
374
+ `A query searches an encrypted column but DbEncryptionConfig.getAccessibleKeyOwners is not ` +
375
+ `configured. Search fingerprints are keyed per owner; the config must supply the caller's ` +
376
+ `accessible key owners (their own id plus owners sharing with them).`
377
+ );
378
+ }
379
+
380
+ const owners = await config.getAccessibleKeyOwners({ runAsSystem: this.runtime.runAsSystem });
381
+ return await new DataKeyStore().getQueryIndexKeys(owners);
382
+ }
383
+ }