@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,172 @@
1
+ import { Table } from '../src/Table';
2
+ import { EncryptionDerivedTableRegistry } from '../src/encryption/EncryptionDerivedTableRegistry';
3
+ import { withRecordColumns, Record } from '../src/Record';
4
+ import { ObjectColumn, ReferenceColumn, StringColumn, UuidColumn } from '../src/Columns';
5
+ import { EncryptedColumns } from '../src/encryption/EncryptedColumns';
6
+ import { EncryptedColumnConfigError } from '../src/encryption/DbEncryptionConfig';
7
+
8
+ interface Note extends Record {
9
+ title?: string;
10
+ body?: string;
11
+ label?: string;
12
+ attachments?: string;
13
+ plain?: string;
14
+ }
15
+
16
+ const noteTable = (columns: any): Table<Note> =>
17
+ new (class extends Table<Note> {
18
+ name = `enc_schema_test_${schemaTestTableCounter++}`;
19
+ columns = columns;
20
+ })();
21
+
22
+ let schemaTestTableCounter = 0;
23
+
24
+ /**
25
+ * The schema side of `ColumnOptions.encrypted`: declaration validation (invalid states
26
+ * rejected loudly), derived companion columns / token table, ciphertext widening, and the
27
+ * mandatory-declaration gate.
28
+ */
29
+ describe('EncryptedColumns schema derivation', () => {
30
+ const encryptedColumns = new EncryptedColumns();
31
+
32
+ test('derives companions automatically: MAX widening, equality fingerprint column + index, sort prefix column, token table', () => {
33
+ const table = noteTable(
34
+ withRecordColumns<any>({
35
+ title: new StringColumn('title', { encrypted: { searchable: 'contains' } }),
36
+ label: new StringColumn('label', { encrypted: { searchable: 'equality', sortKey: { revealPrefix: 3 } } }),
37
+ body: new ObjectColumn('body', { encrypted: {} }),
38
+ plain: new StringColumn('plain', { encrypted: false }),
39
+ })
40
+ );
41
+ encryptedColumns.ensureSchema(table);
42
+
43
+ // ciphertext outgrows declared widths
44
+ expect((table.columns.title as StringColumn).maxLength).toBe('MAX');
45
+ expect((table.columns.label as StringColumn).maxLength).toBe('MAX');
46
+ // plaintext column untouched
47
+ expect((table.columns.plain as StringColumn).maxLength).toBe(255);
48
+
49
+ // equality fingerprint companion, indexed
50
+ const eqCompanion = (table.columns as any)[encryptedColumns.eqCompanionProp(table, 'label')];
51
+ expect(eqCompanion).toBeTruthy();
52
+ expect(eqCompanion.name).toBe('label_enc_eq');
53
+ expect(table.indexes.some((index) => index.name === `${table.name}_label_enc_eq_idx`)).toBe(true);
54
+
55
+ // sort prefix companion bounded to the declared reveal
56
+ const sortCompanion = (table.columns as any)[encryptedColumns.sortCompanionProp(table, 'label')];
57
+ expect(sortCompanion.name).toBe('label_enc_srt');
58
+ expect(sortCompanion.maxLength).toBe(3);
59
+
60
+ // token table registered in the derived registry (tableByName's fallback — the
61
+ // full tableByName resolution is covered by the driver integration tests, which run
62
+ // with a real reflection graph)
63
+ const tokenTable = encryptedColumns.tokenTableFor(table)!;
64
+ expect(tokenTable.name).toBe(`${table.name}_enc_tok`);
65
+ expect(EncryptionDerivedTableRegistry.get(tokenTable.name)).toBe(tokenTable);
66
+
67
+ // idempotent: a second pass adds nothing
68
+ const columnCount = Object.keys(table.columns).length;
69
+ const indexCount = table.indexes.length;
70
+ encryptedColumns.ensureSchema(table);
71
+ expect(Object.keys(table.columns).length).toBe(columnCount);
72
+ expect(table.indexes.length).toBe(indexCount);
73
+ });
74
+
75
+ test('a unique declaration moves onto the equality fingerprint (per-owner value uniqueness)', () => {
76
+ const table = noteTable(
77
+ withRecordColumns<any>({
78
+ label: new StringColumn('label', {
79
+ unique: { unique: true },
80
+ encrypted: { searchable: 'equality' },
81
+ }),
82
+ })
83
+ );
84
+ encryptedColumns.ensureSchema(table);
85
+ expect((table.columns.label as StringColumn).options?.unique).toBeUndefined();
86
+ const eqCompanion = (table.columns as any)[encryptedColumns.eqCompanionProp(table, 'label')];
87
+ expect(eqCompanion.options.unique.unique).toBe(true);
88
+ });
89
+
90
+ test('rejects: unique without equality declaration (a unique index on ciphertext cannot bite)', () => {
91
+ const table = noteTable(
92
+ withRecordColumns<any>({
93
+ label: new StringColumn('label', { unique: { unique: true }, encrypted: {} }),
94
+ })
95
+ );
96
+ expect(() => encryptedColumns.ensureSchema(table)).toThrow(EncryptedColumnConfigError);
97
+ expect(() => encryptedColumns.ensureSchema(table)).toThrow(/searchable: 'equality'/);
98
+ });
99
+
100
+ test('rejects: encrypted on reference/id columns (metadata by construction)', () => {
101
+ const table = noteTable(
102
+ withRecordColumns<any>({
103
+ attachments: new ReferenceColumn('attachment', 'other_table', false, { encrypted: {} } as any),
104
+ })
105
+ );
106
+ expect(() => encryptedColumns.ensureSchema(table)).toThrow(EncryptedColumnConfigError);
107
+ });
108
+
109
+ test('rejects: searchable/sortKey on serialized-object columns (derivatives cover text, not serialization)', () => {
110
+ const table = noteTable(
111
+ withRecordColumns<any>({
112
+ body: new ObjectColumn('body', { encrypted: { searchable: 'contains' } }),
113
+ })
114
+ );
115
+ expect(() => encryptedColumns.ensureSchema(table)).toThrow(EncryptedColumnConfigError);
116
+ });
117
+
118
+ test('rejects: malformed nested config values', () => {
119
+ const badSearchable = noteTable(
120
+ withRecordColumns<any>({
121
+ title: new StringColumn('title', { encrypted: { searchable: 'fuzzy' } as any }),
122
+ })
123
+ );
124
+ expect(() => encryptedColumns.ensureSchema(badSearchable)).toThrow(EncryptedColumnConfigError);
125
+
126
+ const badReveal = noteTable(
127
+ withRecordColumns<any>({
128
+ title: new StringColumn('title', { encrypted: { sortKey: { revealPrefix: 0 } } }),
129
+ })
130
+ );
131
+ expect(() => encryptedColumns.ensureSchema(badReveal)).toThrow(EncryptedColumnConfigError);
132
+ });
133
+
134
+ test('a rejected declaration stays rejected on every use (no half-applied schema)', () => {
135
+ const table = noteTable(
136
+ withRecordColumns<any>({
137
+ title: new StringColumn('title', { encrypted: { searchable: 'fuzzy' } as any }),
138
+ })
139
+ );
140
+ expect(() => encryptedColumns.ensureSchema(table)).toThrow(EncryptedColumnConfigError);
141
+ expect(() => encryptedColumns.ensureSchema(table)).toThrow(EncryptedColumnConfigError);
142
+ });
143
+ });
144
+
145
+ describe('EncryptedColumns mandatory declarations (the sweep-wave gate)', () => {
146
+ const encryptedColumns = new EncryptedColumns();
147
+
148
+ test('a text column without an encrypted declaration fails validation, naming the column', () => {
149
+ const table = noteTable(
150
+ withRecordColumns<any>({
151
+ title: new StringColumn('title'),
152
+ plain: new StringColumn('plain', { encrypted: false }),
153
+ })
154
+ );
155
+ encryptedColumns.ensureSchema(table);
156
+ expect(() => encryptedColumns.validateDeclarations(table)).toThrow(EncryptedColumnConfigError);
157
+ expect(() => encryptedColumns.validateDeclarations(table)).toThrow(/title/);
158
+ });
159
+
160
+ test('declared columns (false or config), id/reference columns, and derived companions pass', () => {
161
+ const table = noteTable(
162
+ withRecordColumns<any>({
163
+ title: new StringColumn('title', { encrypted: { searchable: 'equality' } }),
164
+ plain: new StringColumn('plain', { encrypted: false }),
165
+ attachments: new ReferenceColumn('attachment', 'other_table', false),
166
+ label: new UuidColumn('label'),
167
+ })
168
+ );
169
+ encryptedColumns.ensureSchema(table);
170
+ expect(() => encryptedColumns.validateDeclarations(table)).not.toThrow();
171
+ });
172
+ });
@@ -0,0 +1,66 @@
1
+ import { randomBytes } from 'crypto';
2
+ import { EncryptionEnvelope } from '../src/encryption/EncryptionEnvelope';
3
+ import { DataKeyMaterial } from '../src/encryption/DataKeyStore';
4
+ import { InMemoryMasterKeyProvider } from '../src/encryption/InMemoryMasterKeyProvider';
5
+
6
+ const keyFor = (owner: string, version = 1): DataKeyMaterial => ({
7
+ owner,
8
+ version,
9
+ cipherKey: randomBytes(32),
10
+ indexKey: randomBytes(32),
11
+ });
12
+
13
+ describe('EncryptionEnvelope', () => {
14
+ const envelope = new EncryptionEnvelope();
15
+
16
+ test('round trip: encrypt -> parse -> decrypt restores the exact plaintext', () => {
17
+ const key = keyFor('11111111-2222-3333-4444-555555555555', 3);
18
+ const plaintext = 'Therapy notes — divorce; emoji 🙂 and unicode ünïcode';
19
+ const stored = envelope.encrypt(plaintext, key);
20
+ expect(stored.startsWith(EncryptionEnvelope.PREFIX)).toBe(true);
21
+ expect(stored).not.toContain('Therapy');
22
+
23
+ const parsed = envelope.parse(stored)!;
24
+ expect(parsed.owner).toBe(key.owner);
25
+ expect(parsed.version).toBe(3);
26
+ expect(envelope.decrypt(stored, key)).toBe(plaintext);
27
+ });
28
+
29
+ test('self-describing: the envelope names key owner and version (rotation is a rewrite, not a redesign)', () => {
30
+ const key = keyFor('owner-a', 7);
31
+ const parsed = envelope.parse(envelope.encrypt('x', key))!;
32
+ expect(parsed).toMatchObject({ owner: 'owner-a', version: 7 });
33
+ });
34
+
35
+ test('plaintext and non-envelope strings do not parse as envelopes', () => {
36
+ expect(envelope.parse('an ordinary title')).toBeUndefined();
37
+ expect(envelope.parse('pjenc:1:not-an-envelope')).toBeUndefined();
38
+ expect(envelope.parse(null)).toBeUndefined();
39
+ expect(envelope.parse(42)).toBeUndefined();
40
+ });
41
+
42
+ test('tampered ciphertext fails loudly (GCM auth)', () => {
43
+ const key = keyFor('owner-a');
44
+ const stored = envelope.encrypt('sensitive', key);
45
+ const flipped = stored.slice(0, -2) + (stored.endsWith('AA') ? 'BB' : 'AA');
46
+ expect(() => envelope.decrypt(flipped, key)).toThrow();
47
+ });
48
+
49
+ test('wrong key fails loudly, never returns garbage', () => {
50
+ const stored = envelope.encrypt('sensitive', keyFor('owner-a'));
51
+ expect(() => envelope.decrypt(stored, keyFor('owner-a'))).toThrow();
52
+ });
53
+ });
54
+
55
+ describe('InMemoryMasterKeyProvider', () => {
56
+ test('wrap/unwrap round trip; same secret unwraps across instances; wrong secret fails', async () => {
57
+ const material = randomBytes(64);
58
+ const wrapped = await new InMemoryMasterKeyProvider('test-secret').wrapDataKey(material);
59
+ expect(wrapped).not.toContain(material.toString('base64'));
60
+
61
+ const unwrapped = await new InMemoryMasterKeyProvider('test-secret').unwrapDataKey(wrapped);
62
+ expect(unwrapped.equals(material)).toBe(true);
63
+
64
+ await expect(new InMemoryMasterKeyProvider('other-secret').unwrapDataKey(wrapped)).rejects.toThrow();
65
+ });
66
+ });
@@ -0,0 +1,195 @@
1
+ import { QueryBuilder } from '@proteinjs/db-query';
2
+ import { ColumnQueryRuntime, Table } from '../src/Table';
3
+ import { withRecordColumns, Record } from '../src/Record';
4
+ import { StringColumn } from '../src/Columns';
5
+ import { TableQueryTransformProvider } from '../src/TableQueryTransformProvider';
6
+ import { EncryptedColumnQueryError } from '../src/encryption/EncryptedColumnQueryError';
7
+ import { EncryptedColumns } from '../src/encryption/EncryptedColumns';
8
+
9
+ interface Doc extends Record {
10
+ title?: string; // encrypted + contains
11
+ label?: string; // encrypted + equality
12
+ body?: string; // encrypted, no search
13
+ sorted?: string; // encrypted + sortKey
14
+ status?: string; // plaintext
15
+ }
16
+
17
+ let contractTableCounter = 0;
18
+ const docTable = (): Table<Doc> =>
19
+ new (class extends Table<Doc> {
20
+ name = `enc_contract_test_${contractTableCounter++}`;
21
+ columns = withRecordColumns<Doc>({
22
+ title: new StringColumn('title', { encrypted: { searchable: 'contains' } }),
23
+ label: new StringColumn('label', { encrypted: { searchable: 'equality' } }),
24
+ body: new StringColumn('body', { encrypted: {} }),
25
+ sorted: new StringColumn('sorted', { encrypted: { sortKey: { revealPrefix: 3 } } }),
26
+ status: new StringColumn('status', { encrypted: false }),
27
+ });
28
+ })();
29
+
30
+ const runtime: ColumnQueryRuntime = {
31
+ runAsSystem: false,
32
+ query: async () => {
33
+ throw new Error('query runner should not be reached by rejection paths');
34
+ },
35
+ systemQuery: async () => {
36
+ throw new Error('systemQuery runner should not be reached by rejection paths');
37
+ },
38
+ };
39
+
40
+ /**
41
+ * The framework path under test: the column's own query contract
42
+ * (`Column.queryTransform`, derived by EncryptedColumns.ensureSchema) applied through
43
+ * db-query's `QueryBuilder.applyColumnTransforms` — exactly as `Db.addColumnQueries`
44
+ * invokes it.
45
+ */
46
+ const translate = (table: Table<Doc>, qb: QueryBuilder<Doc>) => {
47
+ new EncryptedColumns().ensureSchema(table);
48
+ return qb.applyColumnTransforms(
49
+ new TableQueryTransformProvider((name) => {
50
+ if (name !== table.name) {
51
+ throw new Error(`Unable to find table: ${name}`);
52
+ }
53
+ return table;
54
+ }, runtime)
55
+ );
56
+ };
57
+
58
+ /**
59
+ * THE COMPATIBILITY CONTRACT's rejected set — every out-of-contract shape on an encrypted
60
+ * column throws at query-build time with an error naming the limitation and the sanctioned
61
+ * paths ("a limitation a developer cannot hit silently is a contract").
62
+ */
63
+ describe('Encrypted-column query contract: loud rejections', () => {
64
+ test('DB-side ORDER BY without a declared sortKey — the spec error, naming the three options', async () => {
65
+ const table = docTable();
66
+ const qb = new QueryBuilder<Doc>(table.name).sort([{ field: 'title' }]);
67
+ await expect(translate(table, qb)).rejects.toThrow(EncryptedColumnQueryError);
68
+
69
+ const qb2 = new QueryBuilder<Doc>(table.name).sort([{ field: 'title' }]);
70
+ await expect(translate(table, qb2)).rejects.toThrow(
71
+ /Cannot ORDER BY encrypted column.*no sortKey declared.*sort by a metadata column.*sort the fetched rows.*revealPrefix/s
72
+ );
73
+ });
74
+
75
+ test('ORDER BY with a declared sortKey translates onto the reveal-prefix companion', async () => {
76
+ const table = docTable();
77
+ const qb = new QueryBuilder<Doc>(table.name).sort([{ field: 'sorted', desc: true }]);
78
+ await translate(table, qb);
79
+ expect(qb.getSortCriteria()[0].field).toBe(new EncryptedColumns().sortCompanionProp(table, 'sorted'));
80
+ });
81
+
82
+ test('range conditions are rejected', async () => {
83
+ const table = docTable();
84
+ for (const operator of ['<', '>', '<=', '>='] as const) {
85
+ const qb = new QueryBuilder<Doc>(table.name).condition({ field: 'title', operator, value: 'm' });
86
+ await expect(translate(table, qb)).rejects.toThrow(/range condition/);
87
+ }
88
+ const between = new QueryBuilder<Doc>(table.name).condition({
89
+ field: 'title',
90
+ operator: 'BETWEEN',
91
+ value: ['a', 'z'] as any,
92
+ });
93
+ await expect(translate(table, between)).rejects.toThrow(EncryptedColumnQueryError);
94
+ });
95
+
96
+ test('LIKE shapes beyond contains/prefix/suffix/exact are rejected', async () => {
97
+ for (const pattern of ['a%b', '%a%b%', 'a_b', 'a\\%b']) {
98
+ const table = docTable();
99
+ const qb = new QueryBuilder<Doc>(table.name).condition({ field: 'title', operator: 'LIKE', value: pattern });
100
+ await expect(translate(table, qb)).rejects.toThrow(/Supported: contains/);
101
+ }
102
+ });
103
+
104
+ test('NOT LIKE / negated equality are rejected', async () => {
105
+ for (const operator of ['NOT LIKE', '<>', '!=', 'NOT IN'] as const) {
106
+ const table = docTable();
107
+ const qb = new QueryBuilder<Doc>(table.name).condition({
108
+ field: 'title',
109
+ operator,
110
+ value: operator === 'NOT IN' ? (['x'] as any) : 'x',
111
+ });
112
+ await expect(translate(table, qb)).rejects.toThrow(EncryptedColumnQueryError);
113
+ }
114
+ });
115
+
116
+ test('aggregation over the value (MIN/MAX/SUM/AVG) is rejected; COUNT is native', async () => {
117
+ for (const fn of ['MIN', 'MAX', 'SUM', 'AVG'] as const) {
118
+ const table = docTable();
119
+ const qb = new QueryBuilder<Doc>(table.name).aggregate({ function: fn, field: 'title' });
120
+ await expect(translate(table, qb)).rejects.toThrow(/Aggregation over/);
121
+ }
122
+
123
+ const countTable = docTable();
124
+ const countQb = new QueryBuilder<Doc>(countTable.name).aggregate({ function: 'COUNT', resultProp: 'count' });
125
+ await expect(translate(countTable, countQb)).resolves.toBeUndefined();
126
+ });
127
+
128
+ test('GROUP BY the value is rejected', async () => {
129
+ const table = docTable();
130
+ const qb = new QueryBuilder<Doc>(table.name).groupBy(['title']);
131
+ await expect(translate(table, qb)).rejects.toThrow(/GROUP BY/);
132
+ });
133
+
134
+ test('equality without a searchable: equality declaration is rejected, naming the declaration', async () => {
135
+ const table = docTable();
136
+ const qb = new QueryBuilder<Doc>(table.name).condition({ field: 'body', operator: '=', value: 'x' });
137
+ await expect(translate(table, qb)).rejects.toThrow(/searchable: 'equality'/);
138
+ });
139
+
140
+ test('LIKE without a searchable: contains declaration is rejected, naming the declaration', async () => {
141
+ const table = docTable();
142
+ const qb = new QueryBuilder<Doc>(table.name).condition({ field: 'body', operator: 'LIKE', value: '%x%' });
143
+ await expect(translate(table, qb)).rejects.toThrow(/searchable: 'contains'/);
144
+ });
145
+
146
+ test('case-insensitive equality is rejected (fingerprints cover the exact value)', async () => {
147
+ const table = docTable();
148
+ const qb = new QueryBuilder<Doc>(table.name);
149
+ qb.condition({ field: 'label', operator: '=', value: 'x' }, undefined, false);
150
+ await expect(translate(table, qb)).rejects.toThrow(/case-insensitively/);
151
+ });
152
+
153
+ test('subquery comparison against an encrypted column is rejected', async () => {
154
+ const table = docTable();
155
+ const sub = new QueryBuilder<any>('other_table').select({ fields: ['id'] });
156
+ const qb = new QueryBuilder<Doc>(table.name).condition({ field: 'label', operator: 'IN', value: sub as any });
157
+ await expect(translate(table, qb)).rejects.toThrow(/subquery/);
158
+ });
159
+
160
+ test('value-CASE ordering (byValues) on an encrypted column is rejected', async () => {
161
+ const table = docTable();
162
+ const qb = new QueryBuilder<Doc>(table.name).sort([{ field: 'sorted', byValues: ['a', 'b'] }]);
163
+ await expect(translate(table, qb)).rejects.toThrow(/ORDER BY specific values/);
164
+ });
165
+
166
+ test('null-checks stay native: IS NULL / IS NOT NULL / = null pass through untouched', async () => {
167
+ const table = docTable();
168
+ const qb = new QueryBuilder<Doc>(table.name)
169
+ .condition({ field: 'title', operator: 'IS NULL' })
170
+ .condition({ field: 'body', operator: 'IS NOT NULL' })
171
+ .condition({ field: 'label', operator: '=', value: null });
172
+ await expect(translate(table, qb)).resolves.toBeUndefined();
173
+ });
174
+
175
+ test('everything on OTHER columns is untouched', async () => {
176
+ const table = docTable();
177
+ const qb = new QueryBuilder<Doc>(table.name)
178
+ .condition({ field: 'status', operator: 'LIKE', value: '%anything%' })
179
+ .sort([{ field: 'status' }])
180
+ .groupBy(['status'])
181
+ .aggregate({ function: 'MAX', field: 'status' });
182
+ await expect(translate(table, qb)).resolves.toBeUndefined();
183
+ });
184
+
185
+ test('LIKE %% (match-any) rewrites to a native IS NOT NULL', async () => {
186
+ const table = docTable();
187
+ const qb = new QueryBuilder<Doc>(table.name).condition({ field: 'title', operator: 'LIKE', value: '%%' });
188
+ await translate(table, qb);
189
+ const conditionNode: any = qb.graph
190
+ .nodes()
191
+ .map((id: string) => qb.graph.node(id))
192
+ .find((node: any) => node?.type === 'CONDITION');
193
+ expect(conditionNode.operator).toBe('IS NOT NULL');
194
+ });
195
+ });
@@ -0,0 +1,67 @@
1
+ import { randomBytes } from 'crypto';
2
+ import { SearchTokenizer } from '../src/encryption/SearchTokenizer';
3
+
4
+ /**
5
+ * The token/fingerprint contract behind encrypted contains-search (§ Firmed-up plan,
6
+ * TRUST_AND_COMPLIANCE): word + trigram + short-prefix tokens at write; per-word fragment
7
+ * covers at query; HMAC fingerprints computable only with the owner's index key.
8
+ */
9
+ describe('SearchTokenizer', () => {
10
+ const tokenizer = new SearchTokenizer();
11
+
12
+ test('value tokens: words, trigrams, and 1-2 letter word prefixes', () => {
13
+ const tokens = tokenizer.tokensForValue('Groceries list');
14
+ expect(tokens).toContain('w:groceries');
15
+ expect(tokens).toContain('w:list');
16
+ // the doc's example trigrams
17
+ for (const trigram of ['gro', 'roc', 'oce', 'cer', 'eri', 'rie', 'ies']) {
18
+ expect(tokens).toContain(`t:${trigram}`);
19
+ }
20
+ expect(tokens).toContain('p:g');
21
+ expect(tokens).toContain('p:gr');
22
+ expect(tokens).toContain('p:l');
23
+ expect(tokens).toContain('p:li');
24
+ });
25
+
26
+ test('normalization: case folds, punctuation splits words, numbers are word characters', () => {
27
+ const tokens = tokenizer.tokensForValue('N3xa-Dev NOTES');
28
+ expect(tokens).toContain('w:n3xa');
29
+ expect(tokens).toContain('w:dev');
30
+ expect(tokens).toContain('w:notes');
31
+ });
32
+
33
+ test('query fragments: >=3 char words cover by trigrams; 1-2 char words by word-prefix', () => {
34
+ const fragments = tokenizer.fragmentsForQuery('cake ry');
35
+ expect(fragments).toContain('t:cak');
36
+ expect(fragments).toContain('t:ake');
37
+ expect(fragments).toContain('p:ry');
38
+ expect(fragments).not.toContain('t:ry');
39
+ });
40
+
41
+ test('a true substring match always covers: value tokens superset query fragments (>=3 char words)', () => {
42
+ // "cake factory" appears inside "pancake factory list" — every query fragment must be
43
+ // present in the value's token set, or the index would miss a true match.
44
+ const valueTokens = new Set(tokenizer.tokensForValue('Pancake factory list'));
45
+ for (const fragment of tokenizer.fragmentsForQuery('cake factory')) {
46
+ expect(valueTokens.has(fragment)).toBe(true);
47
+ }
48
+ });
49
+
50
+ test('fingerprints are keyed: same token, different keys, unrelated fingerprints', () => {
51
+ const keyA = randomBytes(32);
52
+ const keyB = randomBytes(32);
53
+ expect(tokenizer.fingerprint('w:divorce', keyA)).not.toBe(tokenizer.fingerprint('w:divorce', keyB));
54
+ expect(tokenizer.fingerprint('w:divorce', keyA)).toBe(tokenizer.fingerprint('w:divorce', keyA));
55
+ });
56
+
57
+ test('equality fingerprint covers the exact value (case-exact, distinct from token space)', () => {
58
+ const key = randomBytes(32);
59
+ expect(tokenizer.equalityFingerprint('Divorce', key)).not.toBe(tokenizer.equalityFingerprint('divorce', key));
60
+ expect(tokenizer.equalityFingerprint('divorce', key)).not.toBe(tokenizer.fingerprint('w:divorce', key));
61
+ });
62
+
63
+ test('sort prefix: normalized first N characters, nothing else', () => {
64
+ expect(tokenizer.sortPrefix('Therapy notes', 3)).toBe('the');
65
+ expect(tokenizer.sortPrefix('ab', 3)).toBe('ab');
66
+ });
67
+ });
@@ -42,8 +42,8 @@ export const cascadeDeleteTests = (
42
42
  const db = new Db(driver, undefined, transactionContextFactory);
43
43
  const testEnv = new DbTestEnvironment(driver, dropTable);
44
44
 
45
- beforeAll(async () => await testEnv.beforeAll(), 30000);
46
- afterAll(async () => await testEnv.afterAll(), 30000);
45
+ beforeAll(async () => await testEnv.beforeAll(), 120000);
46
+ afterAll(async () => await testEnv.afterAll(), 120000);
47
47
 
48
48
  /**
49
49
  * -------------------- Cascade Delete (holder → referenced) --------------------
@@ -11,8 +11,8 @@ export const columnTypeTests = (
11
11
  const db = new Db(driver, undefined, transactionContextFactory);
12
12
  const testEnv = new DbTestEnvironment(driver, dropTable);
13
13
 
14
- beforeAll(async () => await testEnv.beforeAll(), 30000);
15
- afterAll(async () => await testEnv.afterAll(), 30000);
14
+ beforeAll(async () => await testEnv.beforeAll(), 120000);
15
+ afterAll(async () => await testEnv.afterAll(), 120000);
16
16
 
17
17
  test('Insert record with all null values', async () => {
18
18
  const testRecord: Omit<TestRecord, keyof Record> = {
@@ -12,8 +12,8 @@ export const crudTests = (
12
12
  const db = new Db(driver, undefined, transactionContextFactory);
13
13
  const testEnv = new DbTestEnvironment(driver, dropTable);
14
14
 
15
- beforeAll(async () => await testEnv.beforeAll(), 30000);
16
- afterAll(async () => await testEnv.afterAll(), 30000);
15
+ beforeAll(async () => await testEnv.beforeAll(), 120000);
16
+ afterAll(async () => await testEnv.afterAll(), 120000);
17
17
 
18
18
  test('Insert', async () => {
19
19
  const testEmployee: Omit<Employee, keyof Record> = { name: 'Veronica' };
@@ -14,8 +14,8 @@ export const dynamicReferenceColumnTests = (driver: DbDriver, dropTable: (table:
14
14
  const db = new Db(driver);
15
15
  const testEnv = new DbTestEnvironment(driver, dropTable);
16
16
 
17
- beforeAll(async () => await testEnv.beforeAll(), 30000);
18
- afterAll(async () => await testEnv.afterAll(), 30000);
17
+ beforeAll(async () => await testEnv.beforeAll(), 120000);
18
+ afterAll(async () => await testEnv.afterAll(), 120000);
19
19
 
20
20
  test('should handle references to different types', async () => {
21
21
  // Create an engineer
@@ -27,8 +27,8 @@ export const preloadReferencesTests = (driver: DbDriver, dropTable: (table: Tabl
27
27
  const db = new Db(countingDriver);
28
28
  const testEnv = new DbTestEnvironment(driver, dropTable);
29
29
 
30
- beforeAll(async () => await testEnv.beforeAll(), 30000);
31
- afterAll(async () => await testEnv.afterAll(), 30000);
30
+ beforeAll(async () => await testEnv.beforeAll(), 120000);
31
+ afterAll(async () => await testEnv.afterAll(), 120000);
32
32
 
33
33
  test('preload hydrates dynamic references with one query per referenced table', async () => {
34
34
  const engineerA = await db.insert(dynamicReferenceTestTables.Engineer, {
@@ -33,8 +33,8 @@ export const recordIteratorTests = (
33
33
  const table = recordIteratorTestTables.IterationRow;
34
34
  const WINDOW = 3;
35
35
 
36
- beforeAll(async () => await testEnv.beforeAll(), 30000);
37
- afterAll(async () => await testEnv.afterAll(), 30000);
36
+ beforeAll(async () => await testEnv.beforeAll(), 120000);
37
+ afterAll(async () => await testEnv.afterAll(), 120000);
38
38
 
39
39
  beforeEach(async () => {
40
40
  await db.delete(
@@ -34,8 +34,8 @@ export const tableManagerTests = (
34
34
  const tableManager = driver.getTableManager();
35
35
  const testEnv = new DbTestEnvironment(driver, dropTable);
36
36
 
37
- beforeAll(async () => await testEnv.beforeAll(), 30000);
38
- afterAll(async () => await testEnv.afterAll(), 30000);
37
+ beforeAll(async () => await testEnv.beforeAll(), 120000);
38
+ afterAll(async () => await testEnv.afterAll(), 120000);
39
39
 
40
40
  afterEach(async () => {
41
41
  await dropTable(tableManagerTestTables.ColumnTypes);
@@ -16,8 +16,8 @@ export const transactionTests = (
16
16
  const db = new Db(driver, undefined, transactionContextFactory);
17
17
  const testEnv = new DbTestEnvironment(driver, dropTable);
18
18
 
19
- beforeAll(async () => await testEnv.beforeAll(), 30000);
20
- afterAll(async () => await testEnv.afterAll(), 30000);
19
+ beforeAll(async () => await testEnv.beforeAll(), 120000);
20
+ afterAll(async () => await testEnv.afterAll(), 120000);
21
21
 
22
22
  test('Transaction with successful operations', async () => {
23
23
  const testEmployee1: Omit<TransactionEmployee, keyof Record> = {