@proteinjs/db 1.54.3 → 1.55.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/.eslintrc.js +15 -1
  2. package/CHANGELOG.md +22 -0
  3. package/dist/generated/test/index.d.ts.map +1 -1
  4. package/dist/generated/test/index.js +15 -1
  5. package/dist/generated/test/index.js.map +1 -1
  6. package/dist/src/Columns.d.ts +20 -2
  7. package/dist/src/Columns.d.ts.map +1 -1
  8. package/dist/src/Columns.js +46 -45
  9. package/dist/src/Columns.js.map +1 -1
  10. package/dist/src/Db.d.ts +8 -0
  11. package/dist/src/Db.d.ts.map +1 -1
  12. package/dist/src/Db.js +9 -1
  13. package/dist/src/Db.js.map +1 -1
  14. package/dist/src/Record.d.ts +15 -0
  15. package/dist/src/Record.d.ts.map +1 -1
  16. package/dist/src/Record.js +118 -67
  17. package/dist/src/Record.js.map +1 -1
  18. package/dist/src/auth/TableServiceAuth.js.map +1 -1
  19. package/dist/src/schema/TableManager.d.ts +8 -0
  20. package/dist/src/schema/TableManager.d.ts.map +1 -1
  21. package/dist/src/schema/TableManager.js +22 -0
  22. package/dist/src/schema/TableManager.js.map +1 -1
  23. package/dist/src/source/SourceRecordSyncRunner.js +1 -1
  24. package/dist/src/source/SourceRecordSyncRunner.js.map +1 -1
  25. package/dist/test/SettleThenFail.test.d.ts +2 -0
  26. package/dist/test/SettleThenFail.test.d.ts.map +1 -0
  27. package/dist/test/SettleThenFail.test.js +237 -0
  28. package/dist/test/SettleThenFail.test.js.map +1 -0
  29. package/dist/test/StoredValueSeating.test.d.ts +2 -0
  30. package/dist/test/StoredValueSeating.test.d.ts.map +1 -0
  31. package/dist/test/StoredValueSeating.test.js +243 -0
  32. package/dist/test/StoredValueSeating.test.js.map +1 -0
  33. package/dist/test/SyncRowHydration.test.js.map +1 -1
  34. package/dist/test/UndeclaredColumnCensus.test.d.ts +2 -0
  35. package/dist/test/UndeclaredColumnCensus.test.d.ts.map +1 -0
  36. package/dist/test/UndeclaredColumnCensus.test.js +139 -0
  37. package/dist/test/UndeclaredColumnCensus.test.js.map +1 -0
  38. package/dist/test/index.d.ts +2 -0
  39. package/dist/test/index.d.ts.map +1 -1
  40. package/dist/test/index.js +2 -0
  41. package/dist/test/index.js.map +1 -1
  42. package/dist/test/reusable/CascadeDeleteTests.d.ts.map +1 -1
  43. package/dist/test/reusable/CascadeDeleteTests.js +132 -0
  44. package/dist/test/reusable/CascadeDeleteTests.js.map +1 -1
  45. package/dist/test/reusable/StoredValueContractTests.d.ts +3 -0
  46. package/dist/test/reusable/StoredValueContractTests.d.ts.map +1 -0
  47. package/dist/test/reusable/StoredValueContractTests.js +666 -0
  48. package/dist/test/reusable/StoredValueContractTests.js.map +1 -0
  49. package/dist/test/reusable/TransactionTests.js +8 -8
  50. package/dist/test/util/DbTestEnvironment.d.ts.map +1 -1
  51. package/dist/test/util/DbTestEnvironment.js +5 -1
  52. package/dist/test/util/DbTestEnvironment.js.map +1 -1
  53. package/dist/test/util/tables/cascadeDeleteTestTables.d.ts +38 -0
  54. package/dist/test/util/tables/cascadeDeleteTestTables.d.ts.map +1 -1
  55. package/dist/test/util/tables/cascadeDeleteTestTables.js +119 -1
  56. package/dist/test/util/tables/cascadeDeleteTestTables.js.map +1 -1
  57. package/dist/test/util/tables/storedValueContractTestTables.d.ts +66 -0
  58. package/dist/test/util/tables/storedValueContractTestTables.d.ts.map +1 -0
  59. package/dist/test/util/tables/storedValueContractTestTables.js +85 -0
  60. package/dist/test/util/tables/storedValueContractTestTables.js.map +1 -0
  61. package/generated/test/index.ts +15 -1
  62. package/package.json +3 -3
  63. package/src/Columns.ts +50 -27
  64. package/src/Db.ts +9 -1
  65. package/src/Record.ts +125 -66
  66. package/src/auth/TableServiceAuth.ts +1 -1
  67. package/src/schema/TableManager.ts +25 -0
  68. package/src/source/SourceRecordSyncRunner.ts +1 -1
  69. package/test/SettleThenFail.test.ts +167 -0
  70. package/test/StoredValueSeating.test.ts +169 -0
  71. package/test/SyncRowHydration.test.ts +1 -1
  72. package/test/UndeclaredColumnCensus.test.ts +87 -0
  73. package/test/index.ts +2 -0
  74. package/test/reusable/CascadeDeleteTests.ts +91 -15
  75. package/test/reusable/StoredValueContractTests.ts +419 -0
  76. package/test/reusable/TransactionTests.ts +1 -1
  77. package/test/util/DbTestEnvironment.ts +3 -0
  78. package/test/util/tables/cascadeDeleteTestTables.ts +85 -0
  79. package/test/util/tables/storedValueContractTestTables.ts +62 -0
@@ -0,0 +1,419 @@
1
+ import {
2
+ Db,
3
+ DbDriver,
4
+ Table,
5
+ DefaultTransactionContextFactory,
6
+ QueryBuilderFactory,
7
+ Record,
8
+ Reference,
9
+ ReferenceArray,
10
+ StringColumn,
11
+ withRecordColumns,
12
+ } from '@proteinjs/db';
13
+ import moment from 'moment';
14
+ import { StatementFactory } from '@proteinjs/db-query';
15
+ import { StatementConfigFactory } from '../../src/StatementConfigFactory';
16
+ import { DbTestEnvironment } from '../util/DbTestEnvironment';
17
+ import { Member, Mixed, storedValueContractTestTables } from '../util/tables/storedValueContractTestTables';
18
+ import { columnTypesTestTables, TestRecord } from '../util/tables/columnTypesTestTables';
19
+ import { dynamicReferenceTestTables } from '../util/tables/dynamicReferenceColumnTestTables';
20
+
21
+ /**
22
+ * THE STORED-VALUE CONTRACT: what the row reader makes of each cell a database actually holds —
23
+ * written through the driver, the way a hand-run statement, an older writer or a cutover leaves
24
+ * it — not only what the serializer writes. One asserted law per shape, against a real database
25
+ * through each driver.
26
+ *
27
+ * Part 1 — a legacy column that shares a property's name. After a cutover (`tags` the text
28
+ * column → `tag_list` the array column, property `tags` both times) a row carries two cells: the
29
+ * undeclared legacy `tags` and the declared `tag_list`. The law: a stored value is seated by its
30
+ * COLUMN name alone, so the legacy cell — whatever it holds, whichever physical position it has —
31
+ * never reaches the array column's parser and never displaces the array. Both physical orders
32
+ * are built, because a row's keys arrive in the table's column order and the reader walks them
33
+ * in that order: the deployed shape (legacy first — the old column predates the new one) and the
34
+ * other (legacy added after).
35
+ *
36
+ * Part 2 — every shipped column × { null, undefined, well-formed, a malformed value written raw }.
37
+ * null reads as the column's null; undefined is refused at the write, naming the field; a
38
+ * well-formed value round-trips; a malformed cell is either refused by the storage type (the
39
+ * typed columns) or, where the storage holds text the column must parse, omitted from the record
40
+ * at one WARN naming table and column — never the value — while the row and the result set serve.
41
+ *
42
+ * Part 3 — a result set mixing the async road (a column whose `deserialize` answers with a
43
+ * promise) with a rejecting cell and a synchronous malformed cell resolves with every row served,
44
+ * and leaves no unhandled rejection behind.
45
+ */
46
+
47
+ /** The stored value no warning may ever carry — distinctive, so a leak is unmistakable. */
48
+ const LEGACY_WORD = 'legacyword7';
49
+
50
+ /**
51
+ * The table as a pre-cutover build declared it: a text column named `tags` under the property
52
+ * `tags`. Derived from the live table (same prototype, same name), never registered: loading it
53
+ * creates — or adds — the physical `tags` column the cutover left behind.
54
+ */
55
+ const legacyShapeOf = (table: Table<Member>): Table<any> => {
56
+ const shape = Object.create(Object.getPrototypeOf(table));
57
+ Object.assign(shape, table);
58
+ shape.columns = withRecordColumns<any>({
59
+ name: new StringColumn('name'),
60
+ tags: new StringColumn('tags'),
61
+ });
62
+ return shape;
63
+ };
64
+
65
+ /** The unhandled rejections Node reported while `run` ran, counted after the loop drained twice. */
66
+ const unhandledRejectionsDuring = async <T>(run: () => Promise<T>): Promise<{ unhandled: number; value: T }> => {
67
+ let unhandled = 0;
68
+ const listener = () => {
69
+ unhandled++;
70
+ };
71
+ process.on('unhandledRejection', listener);
72
+ try {
73
+ const value = await run();
74
+ await new Promise((resolve) => setTimeout(resolve, 0));
75
+ await new Promise((resolve) => setTimeout(resolve, 0));
76
+ return { unhandled, value };
77
+ } finally {
78
+ process.off('unhandledRejection', listener);
79
+ }
80
+ };
81
+
82
+ export const storedValueContractTests = (
83
+ driver: DbDriver,
84
+ transactionContextFactory: DefaultTransactionContextFactory,
85
+ dropTable: (table: Table<any>) => Promise<void>
86
+ ) => {
87
+ return () => {
88
+ const db = new Db(driver, undefined, transactionContextFactory);
89
+ const testEnv = new DbTestEnvironment(driver, dropTable);
90
+ const { Member: memberTable, MemberAfter: memberAfterTable, Mixed: mixedTable } = storedValueContractTestTables;
91
+ const typesTable: Table<TestRecord> = columnTypesTestTables.Test;
92
+ const statementConfigFactory = new StatementConfigFactory(driver.getDbName());
93
+
94
+ beforeAll(async () => {
95
+ await testEnv.beforeAll();
96
+ // The deployed shape: the legacy `tags` column predates `tag_list`.
97
+ await dropTable(memberTable);
98
+ await driver.getTableManager().loadTable(legacyShapeOf(memberTable));
99
+ await driver.getTableManager().loadTable(memberTable);
100
+ // The other order: `tag_list` first, the legacy `tags` column added after it.
101
+ await dropTable(memberAfterTable);
102
+ await driver.getTableManager().loadTable(memberAfterTable);
103
+ await driver.getTableManager().loadTable(legacyShapeOf(memberAfterTable));
104
+ }, 120000);
105
+
106
+ afterAll(async () => {
107
+ await dropTable(memberTable);
108
+ await dropTable(memberAfterTable);
109
+ await testEnv.afterAll();
110
+ }, 120000);
111
+
112
+ /** The table as a statement names it for this driver: qualified with the database name where the driver needs it. */
113
+ const qualified = (table: Table<any>, config: { dbName?: string }) =>
114
+ `${config.dbName ? `\`${config.dbName}\`.` : ''}\`${table.name}\``;
115
+
116
+ /** The row as the database holds it — every physical cell, keyed by column name, in column order. */
117
+ const storedRow = async (table: Table<any>, id: string) => {
118
+ const rows = await driver.runQuery((config) => ({
119
+ sql: `SELECT * FROM ${qualified(table, statementConfigFactory.getStatementConfig(config))} WHERE id = '${id}'`,
120
+ }));
121
+ return rows[0];
122
+ };
123
+
124
+ /** A cell written past the serializer: the statement itself, handed the value as stored text (or NULL). */
125
+ const writeRawCell = async (table: Table<any>, id: string, columnName: string, value: string | null) => {
126
+ const literal = value === null ? 'NULL' : `'${value}'`;
127
+ await driver.runDml((config) => ({
128
+ sql: `UPDATE ${qualified(table, statementConfigFactory.getStatementConfig(config))} SET ${columnName} = ${literal} WHERE id = '${id}'`,
129
+ }));
130
+ };
131
+
132
+ /**
133
+ * A cell written past the serializer into a column whose storage holds text the column must
134
+ * parse: the update statement the db layer itself emits, handed the cells as stored text.
135
+ */
136
+ const writeRawParsedCell = async (table: Table<any>, id: string, cells: { [columnName: string]: string }) => {
137
+ const row = new QueryBuilderFactory().getQueryBuilder(table).condition({ field: 'id', operator: '=', value: id });
138
+ await driver.runDml((config) =>
139
+ new StatementFactory<any>().update(table.name, cells, row, statementConfigFactory.getStatementConfig(config))
140
+ );
141
+ };
142
+
143
+ describe('part 1: a legacy column sharing a property name', () => {
144
+ const legacyCases: [string, string | null][] = [
145
+ ['a bare word', LEGACY_WORD],
146
+ ['an empty string', ''],
147
+ ['NULL', null],
148
+ ];
149
+
150
+ describe.each([
151
+ ['the deployed shape: the legacy column before the declared one', memberTable, 'tags', 'tag_list'],
152
+ ['the other order: the legacy column after the declared one', memberAfterTable, 'tag_list', 'tags'],
153
+ ])('%s', (_, table, firstKey, secondKey) => {
154
+ test.each(legacyCases)(
155
+ 'the declared array survives beside a legacy cell holding %s: a get and a query both carry it',
156
+ async (_, legacy) => {
157
+ const member = await db.insert(table, { name: `member ${legacy}`, tags: ['a', 'b'] });
158
+ await writeRawCell(table, member.id, 'tags', legacy);
159
+
160
+ const stored = await storedRow(table, member.id);
161
+ const keys = Object.keys(stored);
162
+ // The premise: the row holds both cells, in the physical order this block built.
163
+ expect(keys).toEqual(expect.arrayContaining(['tags', 'tag_list']));
164
+ expect(keys.indexOf(firstKey)).toBeLessThan(keys.indexOf(secondKey));
165
+ expect(stored['tags']).toBe(legacy);
166
+
167
+ const fetched = await db.get(table, { id: member.id });
168
+ expect(fetched.tags).toEqual(['a', 'b']);
169
+ expect(fetched).toMatchObject({ id: member.id, name: `member ${legacy}` });
170
+
171
+ const queried = await db.query(table, { id: member.id });
172
+ expect(queried).toHaveLength(1);
173
+ expect(queried[0].tags).toEqual(['a', 'b']);
174
+
175
+ await db.delete(table, { id: member.id });
176
+ },
177
+ 60000
178
+ );
179
+ });
180
+ });
181
+
182
+ describe('part 2: every shipped column × { null, undefined, well-formed, malformed raw }', () => {
183
+ type Property = keyof Omit<TestRecord, keyof Record>;
184
+ const properties: Property[] = [
185
+ 'integerColumn',
186
+ 'stringColumn',
187
+ 'floatColumn',
188
+ 'decimalColumn',
189
+ 'booleanColumn',
190
+ 'dateColumn',
191
+ 'dateTimeColumn',
192
+ 'binaryColumn',
193
+ 'uuidColumn',
194
+ 'passwordColumn',
195
+ 'objectColumn',
196
+ 'arrayColumn',
197
+ 'referenceArrayColumn',
198
+ 'referenceColumn',
199
+ ];
200
+ const allNull = (): Omit<TestRecord, keyof Record> =>
201
+ properties.reduce((cells, property) => ({ ...cells, [property]: null }), {}) as Omit<TestRecord, keyof Record>;
202
+
203
+ describe('null', () => {
204
+ let fetched: TestRecord;
205
+
206
+ beforeAll(async () => {
207
+ const inserted = await db.insert(typesTable, allNull());
208
+ fetched = await db.get(typesTable, { id: inserted.id });
209
+ }, 60000);
210
+
211
+ afterAll(async () => {
212
+ if (fetched) {
213
+ await db.delete(typesTable, { id: fetched.id });
214
+ }
215
+ }, 60000);
216
+
217
+ test.each(properties.filter((property) => property !== 'referenceArrayColumn'))(
218
+ '%s: a null cell reads as null',
219
+ (property) => {
220
+ expect(fetched[property]).toBeNull();
221
+ }
222
+ );
223
+
224
+ test('referenceArrayColumn: a null cell reads as an empty reference array', () => {
225
+ expect(fetched.referenceArrayColumn).toHaveProperty('_ids', []);
226
+ });
227
+ });
228
+
229
+ describe('undefined', () => {
230
+ test("uuidColumn: undefined takes the column's default — a generated uuid", async () => {
231
+ const inserted = await db.insert(typesTable, { ...allNull(), uuidColumn: undefined });
232
+ try {
233
+ const fetched = await db.get(typesTable, { id: inserted.id });
234
+ expect(fetched.uuidColumn).toMatch(/^[0-9a-f-]{36}$/);
235
+ } finally {
236
+ await db.delete(typesTable, { id: inserted.id });
237
+ }
238
+ });
239
+
240
+ test.each(properties.filter((property) => property !== 'uuidColumn'))(
241
+ '%s: undefined is refused at the write, naming the field',
242
+ async (property) => {
243
+ await expect(db.insert(typesTable, { ...allNull(), [property]: undefined })).rejects.toThrow(
244
+ `Undefined was found for field: ${typesTable.name}.${property}`
245
+ );
246
+ }
247
+ );
248
+ });
249
+
250
+ describe('well-formed', () => {
251
+ const wellFormed: Omit<TestRecord, keyof Record> = {
252
+ integerColumn: 7,
253
+ stringColumn: 'text',
254
+ floatColumn: 1.5,
255
+ decimalColumn: null,
256
+ booleanColumn: true,
257
+ dateColumn: new Date(2024, 0, 2, 12),
258
+ dateTimeColumn: moment.utc('2024-01-02T03:04:05Z'),
259
+ binaryColumn: null,
260
+ uuidColumn: 'a4b2f5f2-2f8b-4c1e-9b2e-6d3f8a1c0e11',
261
+ passwordColumn: 'stored-as-given',
262
+ objectColumn: { theme: 'dark', depth: 2 },
263
+ arrayColumn: ['a', 'b'],
264
+ referenceArrayColumn: new ReferenceArray('reference_table', ['r1', 'r2']),
265
+ referenceColumn: new Reference('reference_table', 'r3'),
266
+ };
267
+ let fetched: TestRecord;
268
+
269
+ beforeAll(async () => {
270
+ const inserted = await db.insert(typesTable, wellFormed);
271
+ fetched = await db.get(typesTable, { id: inserted.id });
272
+ }, 60000);
273
+
274
+ afterAll(async () => {
275
+ if (fetched) {
276
+ await db.delete(typesTable, { id: fetched.id });
277
+ }
278
+ }, 60000);
279
+
280
+ test('integerColumn round-trips', () => expect(fetched.integerColumn).toBe(7));
281
+ test('stringColumn round-trips', () => expect(fetched.stringColumn).toBe('text'));
282
+ test('floatColumn round-trips', () => expect(Number(fetched.floatColumn)).toBe(1.5));
283
+ // A JS number does not bind to a NUMERIC parameter through the driver (the backend refuses the
284
+ // statement: "Could not parse … 2.25 as NUMERIC"); the law is unpinned until the binding is.
285
+ test.todo('decimalColumn round-trips (a JS number does not bind to NUMERIC through the driver)');
286
+ test('booleanColumn round-trips', () => expect(fetched.booleanColumn).toBe(true));
287
+ test('dateTimeColumn round-trips to the stored instant', () =>
288
+ expect(moment(fetched.dateTimeColumn as never).valueOf()).toBe(Date.UTC(2024, 0, 2, 3, 4, 5)));
289
+ test('dateColumn round-trips to the stored calendar day', () => {
290
+ const day = new Date(fetched.dateColumn as Date);
291
+ expect([day.getFullYear(), day.getMonth(), day.getDate()]).toEqual([2024, 0, 2]);
292
+ });
293
+ test('uuidColumn round-trips', () => expect(fetched.uuidColumn).toBe('a4b2f5f2-2f8b-4c1e-9b2e-6d3f8a1c0e11'));
294
+ test('passwordColumn stores what it is given', () => expect(fetched.passwordColumn).toBe('stored-as-given'));
295
+ test('objectColumn round-trips', () => expect(fetched.objectColumn).toEqual({ theme: 'dark', depth: 2 }));
296
+ test('arrayColumn round-trips', () => expect(fetched.arrayColumn).toEqual(['a', 'b']));
297
+ test('referenceArrayColumn round-trips its ids', () =>
298
+ expect(fetched.referenceArrayColumn).toHaveProperty('_ids', ['r1', 'r2']));
299
+ test('referenceColumn round-trips its id', () => expect(fetched.referenceColumn).toHaveProperty('_id', 'r3'));
300
+ });
301
+
302
+ describe('a malformed value written raw through the driver', () => {
303
+ let legacy: TestRecord;
304
+ let intact: TestRecord;
305
+
306
+ beforeAll(async () => {
307
+ legacy = await db.insert(typesTable, { ...allNull(), stringColumn: 'legacy' });
308
+ intact = await db.insert(typesTable, { ...allNull(), stringColumn: 'intact', arrayColumn: ['c'] });
309
+ }, 60000);
310
+
311
+ afterAll(async () => {
312
+ await db.delete(typesTable, { id: legacy.id });
313
+ await db.delete(typesTable, { id: intact.id });
314
+ }, 60000);
315
+
316
+ // The storage type is the guard: a word is refused before it can be stored.
317
+ test.each([
318
+ ['integerColumn', 'integer_column'],
319
+ ['floatColumn', 'float_column'],
320
+ ['decimalColumn', 'decimal_column'],
321
+ ['booleanColumn', 'boolean_column'],
322
+ ['dateColumn', 'date_column'],
323
+ ['dateTimeColumn', 'date_time_column'],
324
+ ])('%s: the storage type refuses a word', async (_, columnName) => {
325
+ await expect(writeRawCell(typesTable, legacy.id, columnName, LEGACY_WORD)).rejects.toThrow();
326
+ });
327
+
328
+ // Text the column does not parse: whatever is stored is what is read.
329
+ test.each([
330
+ ['stringColumn', 'string_column'],
331
+ ['uuidColumn', 'uuid_column'],
332
+ ['passwordColumn', 'password_column'],
333
+ ])('%s: a stored word reads back as stored', async (property, columnName) => {
334
+ await writeRawCell(typesTable, legacy.id, columnName, LEGACY_WORD);
335
+ const fetched = await db.get(typesTable, { id: legacy.id });
336
+ expect(fetched[property as Property]).toBe(LEGACY_WORD);
337
+ });
338
+
339
+ test('referenceColumn: a stored word is the id the reference carries', async () => {
340
+ await writeRawCell(typesTable, legacy.id, 'reference_column', LEGACY_WORD);
341
+ const fetched = await db.get(typesTable, { id: legacy.id });
342
+ expect(fetched.referenceColumn).toHaveProperty('_id', LEGACY_WORD);
343
+ });
344
+
345
+ // Text the column parses: a cell it cannot parse is omitted — the row serves, the set resolves.
346
+ test('objectColumn, arrayColumn, referenceArrayColumn: an unparseable cell is omitted; the row and the set serve', async () => {
347
+ await writeRawParsedCell(typesTable, legacy.id, {
348
+ object_column: LEGACY_WORD,
349
+ array_column: LEGACY_WORD,
350
+ reference_array_column: LEGACY_WORD,
351
+ });
352
+
353
+ const fetched = await db.get(typesTable, { id: legacy.id });
354
+ expect(fetched).toMatchObject({ id: legacy.id });
355
+ expect(fetched).not.toHaveProperty('objectColumn');
356
+ expect(fetched).not.toHaveProperty('arrayColumn');
357
+ expect(fetched).not.toHaveProperty('referenceArrayColumn');
358
+
359
+ const all = await db.query(typesTable, {});
360
+ expect(all.map((record) => record.id)).toEqual(expect.arrayContaining([legacy.id, intact.id]));
361
+ expect(all.find((record) => record.id === intact.id)).toMatchObject({
362
+ stringColumn: 'intact',
363
+ arrayColumn: ['c'],
364
+ });
365
+ });
366
+
367
+ test('a dynamic reference whose companion table-name cell is empty is omitted; the row serves', async () => {
368
+ const { ProjectAssignment: assignments, Engineer: engineers } = dynamicReferenceTestTables;
369
+ const engineer = await db.insert(engineers, { name: 'eng', yearsOfExperience: 3 });
370
+ const assignment = await db.insert(assignments, {
371
+ projectName: 'p',
372
+ employeeTableName: engineers.name,
373
+ employeeRef: new Reference(engineers.name, engineer.id),
374
+ startDate: '2024-01-02',
375
+ });
376
+ try {
377
+ await writeRawCell(assignments, assignment.id, 'employee_table_name', null);
378
+
379
+ const fetched = await db.get(assignments, { id: assignment.id });
380
+ expect(fetched).toMatchObject({ id: assignment.id, projectName: 'p', startDate: '2024-01-02' });
381
+ expect(fetched).not.toHaveProperty('employeeRef');
382
+ } finally {
383
+ await driver.runDml((config) => ({
384
+ sql: `DELETE FROM ${qualified(assignments, statementConfigFactory.getStatementConfig(config))} WHERE id = '${assignment.id}'`,
385
+ }));
386
+ await db.delete(engineers, { id: engineer.id });
387
+ }
388
+ });
389
+ });
390
+ });
391
+
392
+ describe('part 3: a result set mixing the async road, a rejecting cell and a malformed cell', () => {
393
+ test('resolves with every row served, and leaves no unhandled rejection', async () => {
394
+ const plain = await db.insert(mixedTable, { title: 'plain', later: 1, tags: ['a'] });
395
+ const unreadable = await db.insert(mixedTable, { title: 'unreadable', later: -1, tags: ['b'] });
396
+ const malformed = await db.insert(mixedTable, { title: 'malformed', later: 2, tags: ['c'] });
397
+ await writeRawParsedCell(mixedTable, malformed.id, { tags: LEGACY_WORD });
398
+ try {
399
+ const { unhandled, value: records } = await unhandledRejectionsDuring(() => db.query(mixedTable, {}));
400
+
401
+ expect(unhandled).toBe(0);
402
+ const byId: { [id: string]: Mixed } = {};
403
+ for (const record of records) {
404
+ byId[record.id] = record;
405
+ }
406
+ expect(byId[plain.id]).toMatchObject({ title: 'plain', later: 1, tags: ['a'] });
407
+ expect(byId[unreadable.id]).toMatchObject({ title: 'unreadable', tags: ['b'] });
408
+ expect(byId[unreadable.id]).not.toHaveProperty('later');
409
+ expect(byId[malformed.id]).toMatchObject({ title: 'malformed', later: 2 });
410
+ expect(byId[malformed.id]).not.toHaveProperty('tags');
411
+ } finally {
412
+ for (const record of [plain, unreadable, malformed]) {
413
+ await db.delete(mixedTable, { id: record.id });
414
+ }
415
+ }
416
+ }, 60000);
417
+ });
418
+ };
419
+ };
@@ -81,7 +81,7 @@ export const transactionTests = (
81
81
  return emp1;
82
82
  });
83
83
  fail('Transaction should have thrown an error');
84
- } catch (error) {
84
+ } catch {
85
85
  // Verify the first insert was rolled back
86
86
  const result = await db.get(emplyeeTable, { id: insertedId });
87
87
  expect(result).toBeFalsy();
@@ -6,6 +6,7 @@ import { crudTestTables } from './tables/crudTestTables';
6
6
  import { dynamicReferenceTestTables } from './tables/dynamicReferenceColumnTestTables';
7
7
  import { recordIteratorTestTables } from './tables/recordIteratorTestTables';
8
8
  import { sourceRecordSyncTestTables } from './tables/sourceRecordSyncTestTables';
9
+ import { storedValueContractTestTables } from './tables/storedValueContractTestTables';
9
10
  import { tableManagerTestTables } from './tables/tableManagerTestTables';
10
11
  import { transactionTestTables } from './tables/transactionTestTables';
11
12
 
@@ -16,6 +17,8 @@ const TABLES: Table<any>[] = [
16
17
  ...Object.values(dynamicReferenceTestTables),
17
18
  ...Object.values(recordIteratorTestTables),
18
19
  ...Object.values(sourceRecordSyncTestTables),
20
+ // The member tables are the stored-value suite's own: it builds each physical column order itself.
21
+ storedValueContractTestTables.Mixed,
19
22
  ...Object.values(tableManagerTestTables),
20
23
  ...Object.values(transactionTestTables),
21
24
  ];
@@ -80,6 +80,28 @@ export interface Mission extends Record {
80
80
  assigneeRef?: Reference<Pilot | Robot> | null;
81
81
  }
82
82
 
83
+ // --- Cascade under the write's authority: a guarded child (GuardedHolder* -> GuardedMember)
84
+ // The child's table refuses every non-system query, the shape of a scoped table (its column adds
85
+ // the caller's scope to each query and throws when the context carries no user). A holder deleted
86
+ // AS SYSTEM must take its children with it: the cascade runs with the authority of the write that
87
+ // fired it, never as whoever happens to be in the session.
88
+ export interface GuardedMember extends Record {
89
+ name: string;
90
+ }
91
+ export interface GuardedHolderRef extends Record {
92
+ holderName: string;
93
+ memberRef?: Reference<GuardedMember> | null;
94
+ }
95
+ export interface GuardedHolderArr extends Record {
96
+ holderName: string;
97
+ memberRefs?: ReferenceArray<GuardedMember> | null;
98
+ }
99
+ export interface GuardedHolderDyn extends Record {
100
+ holderName: string;
101
+ memberDynTableName?: string | null;
102
+ memberDynRef?: Reference<GuardedMember> | null;
103
+ }
104
+
83
105
  /**
84
106
  * ---------- Table Names ----------
85
107
  */
@@ -112,6 +134,15 @@ const PILOT_TABLE = 'db_test_cd_pilots';
112
134
  const ROBOT_TABLE = 'db_test_cd_robots';
113
135
  const MISSION_TABLE = 'db_test_cd_missions';
114
136
 
137
+ // Cascade under the write's authority (guarded child)
138
+ const GUARDED_MEMBER_TABLE = 'db_test_cd_guarded_members';
139
+ const GUARDED_HOLDER_REF_TABLE = 'db_test_cd_guarded_holders_ref';
140
+ const GUARDED_HOLDER_ARR_TABLE = 'db_test_cd_guarded_holders_arr';
141
+ const GUARDED_HOLDER_DYN_TABLE = 'db_test_cd_guarded_holders_dyn';
142
+
143
+ /** The guarded table's refusal of a non-system query — the words a suite asserts on. */
144
+ export const GUARDED_QUERY_REFUSAL = 'no user in scope';
145
+
115
146
  /**
116
147
  * ---------- Table Classes ----------
117
148
  */
@@ -261,6 +292,56 @@ export class MissionTable extends Table<Mission> {
261
292
  });
262
293
  }
263
294
 
295
+ // Cascade under the write's authority: the guarded child and its three holders
296
+ export class GuardedMemberTable extends Table<GuardedMember> {
297
+ name = GUARDED_MEMBER_TABLE;
298
+ columns: Table<GuardedMember>['columns'] = withRecordColumns<GuardedMember>({
299
+ name: new StringColumn('name', {
300
+ // The scoped-table contract without a dependency on it: a non-system query of this table
301
+ // would need the context's user to scope it to, and there is none — refuse it by name.
302
+ addToQuery: async (qb, runAsSystem) => {
303
+ if (!runAsSystem) {
304
+ throw new Error(GUARDED_QUERY_REFUSAL);
305
+ }
306
+ },
307
+ }),
308
+ });
309
+ }
310
+ export class GuardedHolderRefTable extends Table<GuardedHolderRef> {
311
+ name = GUARDED_HOLDER_REF_TABLE;
312
+ columns: Table<GuardedHolderRef>['columns'] = withRecordColumns<GuardedHolderRef>({
313
+ holderName: new StringColumn('holder_name'),
314
+ memberRef: new ReferenceColumn<GuardedMember>(
315
+ 'member_id',
316
+ GUARDED_MEMBER_TABLE,
317
+ true // cascade: deleting the holder deletes the referenced GuardedMember
318
+ ),
319
+ });
320
+ }
321
+ export class GuardedHolderArrTable extends Table<GuardedHolderArr> {
322
+ name = GUARDED_HOLDER_ARR_TABLE;
323
+ columns: Table<GuardedHolderArr>['columns'] = withRecordColumns<GuardedHolderArr>({
324
+ holderName: new StringColumn('holder_name'),
325
+ memberRefs: new ReferenceArrayColumn<GuardedMember>(
326
+ 'member_ids',
327
+ GUARDED_MEMBER_TABLE,
328
+ true // cascade: deleting the holder deletes every referenced GuardedMember
329
+ ),
330
+ });
331
+ }
332
+ export class GuardedHolderDynTable extends Table<GuardedHolderDyn> {
333
+ name = GUARDED_HOLDER_DYN_TABLE;
334
+ columns: Table<GuardedHolderDyn>['columns'] = withRecordColumns<GuardedHolderDyn>({
335
+ holderName: new StringColumn('holder_name'),
336
+ memberDynTableName: new DynamicReferenceTableNameColumn('member_dyn_table_name', 'member_dyn_ref'),
337
+ memberDynRef: new DynamicReferenceColumn<GuardedMember>(
338
+ 'member_dyn_ref',
339
+ 'member_dyn_table_name',
340
+ true // cascade: deleting the holder deletes the dynamically referenced GuardedMember
341
+ ),
342
+ });
343
+ }
344
+
264
345
  export const cascadeDeleteTestTables = {
265
346
  MemberRef: new MemberRefTable() as Table<MemberRef>,
266
347
  GroupRef: new GroupRefTable() as Table<GroupRef>,
@@ -277,4 +358,8 @@ export const cascadeDeleteTestTables = {
277
358
  Pilot: new PilotTable() as Table<Pilot>,
278
359
  Robot: new RobotTable() as Table<Robot>,
279
360
  Mission: new MissionTable() as Table<Mission>,
361
+ GuardedMember: new GuardedMemberTable() as Table<GuardedMember>,
362
+ GuardedHolderRef: new GuardedHolderRefTable() as Table<GuardedHolderRef>,
363
+ GuardedHolderArr: new GuardedHolderArrTable() as Table<GuardedHolderArr>,
364
+ GuardedHolderDyn: new GuardedHolderDynTable() as Table<GuardedHolderDyn>,
280
365
  };
@@ -0,0 +1,62 @@
1
+ import { ArrayColumn, IntegerColumn, Record, StringColumn, Table, withRecordColumns } from '@proteinjs/db';
2
+
3
+ /**
4
+ * A table whose `tags` property moved from a text column named `tags` to an array column named
5
+ * `tag_list` — the cutover shape: the new column added beside the old one (a column cannot be
6
+ * retyped in place), the old one left physically present and undeclared. A stored row then holds
7
+ * BOTH a `tags` cell and a `tag_list` cell, and the stored key `tags` is also the property's name.
8
+ */
9
+ export interface Member extends Record {
10
+ name: string;
11
+ tags?: string[] | null;
12
+ }
13
+
14
+ /** The current declaration: `tags` lives in `tag_list`. */
15
+ export class MemberTable extends Table<Member> {
16
+ public name = 'db_test_svc_member';
17
+ public columns = withRecordColumns<Member>({
18
+ name: new StringColumn('name'),
19
+ tags: new ArrayColumn<string>('tag_list'),
20
+ });
21
+ }
22
+
23
+ /** The same declaration for a second table, so the two physical column orders can each be built. */
24
+ export class MemberAfterTable extends Table<Member> {
25
+ public name = 'db_test_svc_member_after';
26
+ public columns = withRecordColumns<Member>({
27
+ name: new StringColumn('name'),
28
+ tags: new ArrayColumn<string>('tag_list'),
29
+ });
30
+ }
31
+
32
+ /**
33
+ * A column whose `deserialize` answers with a promise (the async road a stored cell can take):
34
+ * resolves the stored number, rejects for the sentinel `-1` — a cell the column cannot read.
35
+ */
36
+ export class LaterColumn extends IntegerColumn {
37
+ deserialize(storedValue: number | null): Promise<number | null> {
38
+ return storedValue === -1 ? Promise.reject(new RangeError('unreadable')) : Promise.resolve(storedValue);
39
+ }
40
+ }
41
+
42
+ /** A row mixing the async road (`later`) with synchronous JSON cells (`tags`). */
43
+ export interface Mixed extends Record {
44
+ title: string;
45
+ later?: number | null;
46
+ tags?: string[] | null;
47
+ }
48
+
49
+ export class MixedTable extends Table<Mixed> {
50
+ public name = 'db_test_svc_mixed';
51
+ public columns = withRecordColumns<Mixed>({
52
+ title: new StringColumn('title'),
53
+ later: new LaterColumn('later'),
54
+ tags: new ArrayColumn<string>('tags'),
55
+ });
56
+ }
57
+
58
+ export const storedValueContractTestTables = {
59
+ Member: new MemberTable() as Table<Member>,
60
+ MemberAfter: new MemberAfterTable() as Table<Member>,
61
+ Mixed: new MixedTable() as Table<Mixed>,
62
+ };