@proteinjs/db 1.54.3 → 1.55.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 (63) hide show
  1. package/.eslintrc.js +15 -1
  2. package/CHANGELOG.md +11 -0
  3. package/dist/generated/test/index.d.ts.map +1 -1
  4. package/dist/generated/test/index.js +7 -1
  5. package/dist/generated/test/index.js.map +1 -1
  6. package/dist/src/Record.d.ts +15 -0
  7. package/dist/src/Record.d.ts.map +1 -1
  8. package/dist/src/Record.js +118 -67
  9. package/dist/src/Record.js.map +1 -1
  10. package/dist/src/auth/TableServiceAuth.js.map +1 -1
  11. package/dist/src/schema/TableManager.d.ts +8 -0
  12. package/dist/src/schema/TableManager.d.ts.map +1 -1
  13. package/dist/src/schema/TableManager.js +22 -0
  14. package/dist/src/schema/TableManager.js.map +1 -1
  15. package/dist/src/source/SourceRecordSyncRunner.js +1 -1
  16. package/dist/src/source/SourceRecordSyncRunner.js.map +1 -1
  17. package/dist/test/SettleThenFail.test.d.ts +2 -0
  18. package/dist/test/SettleThenFail.test.d.ts.map +1 -0
  19. package/dist/test/SettleThenFail.test.js +237 -0
  20. package/dist/test/SettleThenFail.test.js.map +1 -0
  21. package/dist/test/StoredValueSeating.test.d.ts +2 -0
  22. package/dist/test/StoredValueSeating.test.d.ts.map +1 -0
  23. package/dist/test/StoredValueSeating.test.js +243 -0
  24. package/dist/test/StoredValueSeating.test.js.map +1 -0
  25. package/dist/test/SyncRowHydration.test.js.map +1 -1
  26. package/dist/test/UndeclaredColumnCensus.test.d.ts +2 -0
  27. package/dist/test/UndeclaredColumnCensus.test.d.ts.map +1 -0
  28. package/dist/test/UndeclaredColumnCensus.test.js +139 -0
  29. package/dist/test/UndeclaredColumnCensus.test.js.map +1 -0
  30. package/dist/test/index.d.ts +2 -0
  31. package/dist/test/index.d.ts.map +1 -1
  32. package/dist/test/index.js +2 -0
  33. package/dist/test/index.js.map +1 -1
  34. package/dist/test/reusable/CascadeDeleteTests.d.ts.map +1 -1
  35. package/dist/test/reusable/CascadeDeleteTests.js.map +1 -1
  36. package/dist/test/reusable/StoredValueContractTests.d.ts +3 -0
  37. package/dist/test/reusable/StoredValueContractTests.d.ts.map +1 -0
  38. package/dist/test/reusable/StoredValueContractTests.js +666 -0
  39. package/dist/test/reusable/StoredValueContractTests.js.map +1 -0
  40. package/dist/test/reusable/TransactionTests.js +8 -8
  41. package/dist/test/util/DbTestEnvironment.d.ts.map +1 -1
  42. package/dist/test/util/DbTestEnvironment.js +5 -1
  43. package/dist/test/util/DbTestEnvironment.js.map +1 -1
  44. package/dist/test/util/tables/storedValueContractTestTables.d.ts +66 -0
  45. package/dist/test/util/tables/storedValueContractTestTables.d.ts.map +1 -0
  46. package/dist/test/util/tables/storedValueContractTestTables.js +85 -0
  47. package/dist/test/util/tables/storedValueContractTestTables.js.map +1 -0
  48. package/generated/test/index.ts +7 -1
  49. package/package.json +3 -3
  50. package/src/Record.ts +125 -66
  51. package/src/auth/TableServiceAuth.ts +1 -1
  52. package/src/schema/TableManager.ts +25 -0
  53. package/src/source/SourceRecordSyncRunner.ts +1 -1
  54. package/test/SettleThenFail.test.ts +167 -0
  55. package/test/StoredValueSeating.test.ts +169 -0
  56. package/test/SyncRowHydration.test.ts +1 -1
  57. package/test/UndeclaredColumnCensus.test.ts +87 -0
  58. package/test/index.ts +2 -0
  59. package/test/reusable/CascadeDeleteTests.ts +1 -15
  60. package/test/reusable/StoredValueContractTests.ts +419 -0
  61. package/test/reusable/TransactionTests.ts +1 -1
  62. package/test/util/DbTestEnvironment.ts +3 -0
  63. package/test/util/tables/storedValueContractTestTables.ts +62 -0
@@ -4,7 +4,7 @@ import { Db, DbDriver } from '../src/Db';
4
4
  import { TableWatcherRunner } from '../src/TableWatcherRunner';
5
5
  import { Column, Table } from '../src/Table';
6
6
  import { Record, RecordSerializer, SerializedRecord, withRecordColumns } from '../src/Record';
7
- import { IntegerColumn, StringColumn } from '../src/Columns';
7
+ import { StringColumn } from '../src/Columns';
8
8
  import { DefaultTransactionContextFactory, TransactionContextData } from '../src/transaction/TransactionContextFactory';
9
9
  import { DataKeyMaterial, DataKeyStore } from '../src/encryption/DataKeyStore';
10
10
  import { EncryptionEnvelope } from '../src/encryption/EncryptionEnvelope';
@@ -0,0 +1,87 @@
1
+ import { inspect } from 'util';
2
+ import { Logger } from '@proteinjs/logger';
3
+ import { TableManager } from '../src/schema/TableManager';
4
+ import { Table } from '../src/Table';
5
+ import { ArrayColumn, StringColumn } from '../src/Columns';
6
+ import { Record, withRecordColumns } from '../src/Record';
7
+
8
+ /**
9
+ * The census (`TableManager.warnUndeclaredColumns`): at every schema sync of an existing table,
10
+ * the physical columns no declared column owns are named in ONE warning — a column a rename
11
+ * retired (`tags` beside the `tag_list` that replaced it), a column the table dropped. Names
12
+ * only: the warning carries no stored value (the census reads the schema, never a row).
13
+ */
14
+
15
+ interface Member extends Record {
16
+ name: string;
17
+ tags: string[];
18
+ }
19
+
20
+ class MemberTable extends Table<Member> {
21
+ public name = 'census_member';
22
+ public columns = withRecordColumns<Member>({
23
+ name: new StringColumn('name'),
24
+ tags: new ArrayColumn<string>('tag_list'),
25
+ });
26
+ }
27
+
28
+ type CapturedLog = { logLevel: string; message?: string; obj?: any };
29
+ type ManagerInternals = {
30
+ logger: Logger;
31
+ getTableChanges(table: Table<any>): Promise<unknown>;
32
+ getIndexOperations(table: Table<any>): Promise<{ indexesToCreate: unknown[]; indexesToDrop: unknown[] }>;
33
+ };
34
+
35
+ const printedLine = (log: CapturedLog) => [log.message ?? '', inspect(log.obj, { depth: 10 })].join('\n');
36
+
37
+ describe('the undeclared-column census', () => {
38
+ const table = new MemberTable() as Table<Member>;
39
+ let captured: CapturedLog[];
40
+
41
+ const managerOver = (physicalColumns: string[]) => {
42
+ const columnMetadata: { [name: string]: { type: string; isNullable: boolean } } = {};
43
+ for (const name of physicalColumns) {
44
+ columnMetadata[name] = { type: 'STRING(MAX)', isNullable: true };
45
+ }
46
+ const manager = new TableManager(
47
+ {} as never,
48
+ { getType: () => 'STRING(MAX)' } as never,
49
+ {} as never,
50
+ {
51
+ getColumnMetadata: async () => columnMetadata,
52
+ getUniqueColumns: async () => [],
53
+ getForeignKeys: async () => ({}),
54
+ } as never
55
+ );
56
+ const internals = manager as unknown as ManagerInternals;
57
+ internals.logger = new Logger({
58
+ name: 'TableManager',
59
+ logWriter: { write: (log: CapturedLog) => captured.push(log) } as never,
60
+ });
61
+ internals.getIndexOperations = async () => ({ indexesToCreate: [], indexesToDrop: [] });
62
+ return internals;
63
+ };
64
+
65
+ beforeEach(() => {
66
+ captured = [];
67
+ });
68
+
69
+ test('a physical column no declared column owns is named, once, with the table — names only', async () => {
70
+ const manager = managerOver(['id', 'created', 'updated', 'name', 'tag_list', 'tags']);
71
+
72
+ await manager.getTableChanges(table);
73
+
74
+ const warnings = captured.filter((log) => log.logLevel === 'warn');
75
+ expect(warnings).toHaveLength(1);
76
+ expect(warnings[0].obj).toEqual({ table: 'census_member', columns: ['tags'] });
77
+ expect(printedLine(warnings[0])).toContain('census_member');
78
+ });
79
+
80
+ test('a table whose every physical column is declared warns nothing', async () => {
81
+ const manager = managerOver(['id', 'created', 'updated', 'name', 'tag_list']);
82
+
83
+ await manager.getTableChanges(table);
84
+
85
+ expect(captured.filter((log) => log.logLevel === 'warn')).toHaveLength(0);
86
+ });
87
+ });
package/test/index.ts CHANGED
@@ -7,6 +7,7 @@ export * from './reusable/CascadeDeleteTests';
7
7
  export * from './reusable/PreloadReferencesTests';
8
8
  export * from './reusable/RecordIteratorTests';
9
9
  export * from './reusable/SourceRecordSyncTests';
10
+ export * from './reusable/StoredValueContractTests';
10
11
 
11
12
  export * from './util/testUser';
12
13
 
@@ -16,5 +17,6 @@ export * from './util/tables/crudTestTables';
16
17
  export * from './util/tables/dynamicReferenceColumnTestTables';
17
18
  export * from './util/tables/recordIteratorTestTables';
18
19
  export * from './util/tables/sourceRecordSyncTestTables';
20
+ export * from './util/tables/storedValueContractTestTables';
19
21
  export * from './util/tables/tableManagerTestTables';
20
22
  export * from './util/tables/transactionTestTables';
@@ -1,18 +1,4 @@
1
- import {
2
- StringColumn,
3
- ReferenceColumn,
4
- ReferenceArrayColumn,
5
- DynamicReferenceColumn,
6
- DynamicReferenceTableNameColumn,
7
- Db,
8
- DbDriver,
9
- Table,
10
- withRecordColumns,
11
- Record,
12
- Reference,
13
- ReferenceArray,
14
- DefaultTransactionContextFactory,
15
- } from '@proteinjs/db';
1
+ import { Db, DbDriver, Table, Reference, ReferenceArray, DefaultTransactionContextFactory } from '@proteinjs/db';
16
2
  import { QueryBuilder } from '@proteinjs/db-query';
17
3
  import { DbTestEnvironment } from '../util/DbTestEnvironment';
18
4
  import {
@@ -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
  ];
@@ -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
+ };