@proteinjs/db 1.54.2 → 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.
- package/.eslintrc.js +15 -1
- package/CHANGELOG.md +22 -0
- package/dist/generated/test/index.d.ts.map +1 -1
- package/dist/generated/test/index.js +7 -1
- package/dist/generated/test/index.js.map +1 -1
- package/dist/src/Record.d.ts +31 -0
- package/dist/src/Record.d.ts.map +1 -1
- package/dist/src/Record.js +159 -58
- package/dist/src/Record.js.map +1 -1
- package/dist/src/auth/TableServiceAuth.js.map +1 -1
- package/dist/src/schema/TableManager.d.ts +8 -0
- package/dist/src/schema/TableManager.d.ts.map +1 -1
- package/dist/src/schema/TableManager.js +22 -0
- package/dist/src/schema/TableManager.js.map +1 -1
- package/dist/src/source/SourceRecordSyncRunner.js +1 -1
- package/dist/src/source/SourceRecordSyncRunner.js.map +1 -1
- package/dist/test/LegacyCellHydration.test.d.ts +2 -0
- package/dist/test/LegacyCellHydration.test.d.ts.map +1 -0
- package/dist/test/LegacyCellHydration.test.js +321 -0
- package/dist/test/LegacyCellHydration.test.js.map +1 -0
- package/dist/test/SettleThenFail.test.d.ts +2 -0
- package/dist/test/SettleThenFail.test.d.ts.map +1 -0
- package/dist/test/SettleThenFail.test.js +237 -0
- package/dist/test/SettleThenFail.test.js.map +1 -0
- package/dist/test/StoredValueSeating.test.d.ts +2 -0
- package/dist/test/StoredValueSeating.test.d.ts.map +1 -0
- package/dist/test/StoredValueSeating.test.js +243 -0
- package/dist/test/StoredValueSeating.test.js.map +1 -0
- package/dist/test/SyncRowHydration.test.js.map +1 -1
- package/dist/test/UndeclaredColumnCensus.test.d.ts +2 -0
- package/dist/test/UndeclaredColumnCensus.test.d.ts.map +1 -0
- package/dist/test/UndeclaredColumnCensus.test.js +139 -0
- package/dist/test/UndeclaredColumnCensus.test.js.map +1 -0
- package/dist/test/index.d.ts +2 -0
- package/dist/test/index.d.ts.map +1 -1
- package/dist/test/index.js +2 -0
- package/dist/test/index.js.map +1 -1
- package/dist/test/reusable/CascadeDeleteTests.d.ts.map +1 -1
- package/dist/test/reusable/CascadeDeleteTests.js.map +1 -1
- package/dist/test/reusable/ColumnTypesTests.d.ts.map +1 -1
- package/dist/test/reusable/ColumnTypesTests.js +55 -0
- package/dist/test/reusable/ColumnTypesTests.js.map +1 -1
- package/dist/test/reusable/StoredValueContractTests.d.ts +3 -0
- package/dist/test/reusable/StoredValueContractTests.d.ts.map +1 -0
- package/dist/test/reusable/StoredValueContractTests.js +666 -0
- package/dist/test/reusable/StoredValueContractTests.js.map +1 -0
- package/dist/test/reusable/TransactionTests.js +8 -8
- package/dist/test/util/DbTestEnvironment.d.ts.map +1 -1
- package/dist/test/util/DbTestEnvironment.js +5 -1
- package/dist/test/util/DbTestEnvironment.js.map +1 -1
- package/dist/test/util/tables/storedValueContractTestTables.d.ts +66 -0
- package/dist/test/util/tables/storedValueContractTestTables.d.ts.map +1 -0
- package/dist/test/util/tables/storedValueContractTestTables.js +85 -0
- package/dist/test/util/tables/storedValueContractTestTables.js.map +1 -0
- package/generated/test/index.ts +7 -1
- package/package.json +3 -3
- package/src/Record.ts +166 -55
- package/src/auth/TableServiceAuth.ts +1 -1
- package/src/schema/TableManager.ts +25 -0
- package/src/source/SourceRecordSyncRunner.ts +1 -1
- package/test/LegacyCellHydration.test.ts +234 -0
- package/test/SettleThenFail.test.ts +167 -0
- package/test/StoredValueSeating.test.ts +169 -0
- package/test/SyncRowHydration.test.ts +1 -1
- package/test/UndeclaredColumnCensus.test.ts +87 -0
- package/test/index.ts +2 -0
- package/test/reusable/CascadeDeleteTests.ts +1 -15
- package/test/reusable/ColumnTypesTests.ts +47 -1
- package/test/reusable/StoredValueContractTests.ts +419 -0
- package/test/reusable/TransactionTests.ts +1 -1
- package/test/util/DbTestEnvironment.ts +3 -0
- package/test/util/tables/storedValueContractTestTables.ts +62 -0
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { Column, Table } from '../src/Table';
|
|
2
|
+
import { Record, RecordSerializer, SerializedRecord, withRecordColumns } from '../src/Record';
|
|
3
|
+
import { StringColumn } from '../src/Columns';
|
|
4
|
+
import { EncryptionRecordHooks } from '../src/encryption/EncryptionRecordHooks';
|
|
5
|
+
import { EncryptionEnvelope } from '../src/encryption/EncryptionEnvelope';
|
|
6
|
+
import { DataKeyStore, DataKeyMaterial } from '../src/encryption/DataKeyStore';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Settle, then fail. The set reader hydrates rows in order and hands them to ONE promise after the
|
|
10
|
+
* loop; a row that needs an await (a ciphertext envelope whose key is read asynchronously) is a
|
|
11
|
+
* promise in the set from the moment its hook answers. If a LATER row then throws synchronously,
|
|
12
|
+
* the loop ends before that one promise exists — and the earlier row's promise is observed by
|
|
13
|
+
* nobody: its rejection is an `unhandledRejection`, which no caller can catch and a fatal boundary
|
|
14
|
+
* turns into an exit. The law: a synchronous throw fails the set only after every promise the set
|
|
15
|
+
* already created has settled — one rejection, carrying the throw, and no unhandled rejection. The
|
|
16
|
+
* same inside one row, for its cells.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
interface Doc extends Record {
|
|
20
|
+
title: string;
|
|
21
|
+
later: number;
|
|
22
|
+
after: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** A column whose `deserialize` answers with a promise: resolves the number, rejects for -1. */
|
|
26
|
+
class LaterColumn implements Column<number, number> {
|
|
27
|
+
constructor(public name: string) {}
|
|
28
|
+
|
|
29
|
+
deserialize(storedValue: number | null): Promise<number | null> {
|
|
30
|
+
return storedValue === -1 ? Promise.reject(new RangeError('unreadable')) : Promise.resolve(storedValue);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
class DocTable extends Table<Doc> {
|
|
35
|
+
public name = 'settle_doc';
|
|
36
|
+
public columns = withRecordColumns<Doc>({
|
|
37
|
+
title: new StringColumn('title'),
|
|
38
|
+
later: new LaterColumn('later'),
|
|
39
|
+
after: new StringColumn('after'),
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const docTable = new DocTable() as Table<Doc>;
|
|
44
|
+
|
|
45
|
+
const keyFor = (owner: string, version: number): DataKeyMaterial => ({
|
|
46
|
+
owner,
|
|
47
|
+
version,
|
|
48
|
+
cipherKey: Buffer.alloc(32, 7),
|
|
49
|
+
indexKey: Buffer.alloc(32, 9),
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
const storedRow = (id: string, cells: Partial<SerializedRecord> = {}): SerializedRecord => ({
|
|
53
|
+
id,
|
|
54
|
+
created: new Date(1_700_000_000_000),
|
|
55
|
+
updated: new Date(1_700_000_000_000),
|
|
56
|
+
title: `title ${id}`,
|
|
57
|
+
later: 1,
|
|
58
|
+
after: `after ${id}`,
|
|
59
|
+
...cells,
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
/** The unhandled rejections Node reported while `run` ran, counted after the loop drained twice. */
|
|
63
|
+
const unhandledRejectionsDuring = async (
|
|
64
|
+
run: () => Promise<unknown>
|
|
65
|
+
): Promise<{ unhandled: number; outcome: unknown }> => {
|
|
66
|
+
let unhandled = 0;
|
|
67
|
+
const listener = () => {
|
|
68
|
+
unhandled++;
|
|
69
|
+
};
|
|
70
|
+
process.on('unhandledRejection', listener);
|
|
71
|
+
try {
|
|
72
|
+
const outcome = await run().then(
|
|
73
|
+
(value) => ({ resolved: value }),
|
|
74
|
+
(error) => ({ rejected: error })
|
|
75
|
+
);
|
|
76
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
77
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
78
|
+
return { unhandled, outcome };
|
|
79
|
+
} finally {
|
|
80
|
+
process.off('unhandledRejection', listener);
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
describe('settle, then fail', () => {
|
|
85
|
+
afterEach(() => {
|
|
86
|
+
jest.restoreAllMocks();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('an envelope row whose key read rejects, beside a later row that throws synchronously: one rejection, nothing unhandled', async () => {
|
|
90
|
+
const key = keyFor('owner-1', 4);
|
|
91
|
+
jest.spyOn(DataKeyStore.prototype, 'getKeyByVersion').mockRejectedValue(new Error('key unavailable'));
|
|
92
|
+
const onDeserialize = EncryptionRecordHooks.prototype.onDeserialize;
|
|
93
|
+
jest.spyOn(EncryptionRecordHooks.prototype, 'onDeserialize').mockImplementation(function (
|
|
94
|
+
this: EncryptionRecordHooks,
|
|
95
|
+
table,
|
|
96
|
+
row
|
|
97
|
+
) {
|
|
98
|
+
if (row.id === 'r2') {
|
|
99
|
+
throw new Error('hook failed');
|
|
100
|
+
}
|
|
101
|
+
return onDeserialize.call(this, table, row);
|
|
102
|
+
});
|
|
103
|
+
const rows = [
|
|
104
|
+
storedRow('r1', { title: new EncryptionEnvelope().encrypt('secret', key) }),
|
|
105
|
+
storedRow('r2'),
|
|
106
|
+
storedRow('r3'),
|
|
107
|
+
];
|
|
108
|
+
|
|
109
|
+
const { unhandled, outcome } = await unhandledRejectionsDuring(async () =>
|
|
110
|
+
new RecordSerializer<Doc>(docTable).deserializeAll(rows)
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
expect(outcome).toEqual({ rejected: new Error('hook failed') });
|
|
114
|
+
expect(unhandled).toBe(0);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test('inside one row: a cell whose promise rejects, beside a later cell that throws synchronously', async () => {
|
|
118
|
+
// A stored value whose `then` getter throws — the one synchronous throw the duck-typed
|
|
119
|
+
// promise check can meet after a column has answered.
|
|
120
|
+
const throwingThenable = {
|
|
121
|
+
get then(): never {
|
|
122
|
+
throw new Error('not a promise after all');
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
class ThenableColumn implements Column<unknown, unknown> {
|
|
126
|
+
constructor(public name: string) {}
|
|
127
|
+
|
|
128
|
+
deserialize(): unknown {
|
|
129
|
+
return throwingThenable;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
interface Cells extends Record {
|
|
133
|
+
later: number;
|
|
134
|
+
odd: unknown;
|
|
135
|
+
}
|
|
136
|
+
class CellsTable extends Table<Cells> {
|
|
137
|
+
public name = 'settle_cells';
|
|
138
|
+
public columns = withRecordColumns<Cells>({
|
|
139
|
+
later: new LaterColumn('later'),
|
|
140
|
+
odd: new ThenableColumn('odd'),
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
const rows = [storedRow('c1', { later: -1, odd: 'x' })];
|
|
144
|
+
|
|
145
|
+
const { unhandled, outcome } = await unhandledRejectionsDuring(async () =>
|
|
146
|
+
new RecordSerializer<Cells>(new CellsTable() as Table<Cells>).deserializeAll(rows)
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
expect(outcome).toEqual({ rejected: new Error('not a promise after all') });
|
|
150
|
+
expect(unhandled).toBe(0);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test('a set that mixes the async road with a rejecting cell and a synchronous cell resolves, every row served', async () => {
|
|
154
|
+
const rows = [storedRow('r1'), storedRow('r2', { later: -1 }), storedRow('r3', { later: 3 })];
|
|
155
|
+
|
|
156
|
+
const { unhandled, outcome } = await unhandledRejectionsDuring(async () =>
|
|
157
|
+
new RecordSerializer<Doc>(docTable).deserializeAll(rows)
|
|
158
|
+
);
|
|
159
|
+
|
|
160
|
+
expect(unhandled).toBe(0);
|
|
161
|
+
const records = (outcome as { resolved: Doc[] }).resolved;
|
|
162
|
+
expect(records.map((record) => record.id)).toEqual(['r1', 'r2', 'r3']);
|
|
163
|
+
expect(records[0].later).toBe(1);
|
|
164
|
+
expect(records[1]).not.toHaveProperty('later');
|
|
165
|
+
expect(records[2].later).toBe(3);
|
|
166
|
+
});
|
|
167
|
+
});
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { Column, Table } from '../src/Table';
|
|
2
|
+
import {
|
|
3
|
+
DuplicateColumnNameError,
|
|
4
|
+
FieldSerializer,
|
|
5
|
+
MissingFieldError,
|
|
6
|
+
Record,
|
|
7
|
+
RecordSerializer,
|
|
8
|
+
SerializedRecord,
|
|
9
|
+
withRecordColumns,
|
|
10
|
+
} from '../src/Record';
|
|
11
|
+
import { ArrayColumn, StringColumn } from '../src/Columns';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Where a stored cell is seated: by the declared COLUMN name alone. A stored key that is only a
|
|
15
|
+
* PROPERTY's name — the text column `tags` a cutover retired beside the array column `tag_list`
|
|
16
|
+
* that replaced it, property `tags` both times — names no seat: it never reaches the array
|
|
17
|
+
* column's parser, never displaces the array, whichever side of the real cell it lands on, and
|
|
18
|
+
* whatever it holds (a bare word, an empty string, NULL). The row reader used to seat by property
|
|
19
|
+
* name as a second pass, which is the only way an undeclared cell could reach a declared column's
|
|
20
|
+
* parser — and, iterated after the real cell, a NULL in it silently replaced the array.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
interface Member extends Record {
|
|
24
|
+
name: string;
|
|
25
|
+
tags: string[] | null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
class MemberTable extends Table<Member> {
|
|
29
|
+
public name = 'seating_member';
|
|
30
|
+
public columns = withRecordColumns<Member>({
|
|
31
|
+
name: new StringColumn('name'),
|
|
32
|
+
tags: new ArrayColumn<string>('tag_list'),
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const memberTable = new MemberTable() as Table<Member>;
|
|
37
|
+
|
|
38
|
+
type SerializerInternals = { logger: { warn: (...args: unknown[]) => void } };
|
|
39
|
+
type SerializerStatics = { legacyCellsWarned: Set<string> };
|
|
40
|
+
|
|
41
|
+
const LEGACY_WORD = 'legacyword7';
|
|
42
|
+
|
|
43
|
+
/** Every warning a serializer logs, as the logger received it. */
|
|
44
|
+
const warningsOf = (serializer: RecordSerializer<any>): unknown[][] => {
|
|
45
|
+
const calls: unknown[][] = [];
|
|
46
|
+
(serializer as unknown as SerializerInternals).logger.warn = (...args: unknown[]) => {
|
|
47
|
+
calls.push(args);
|
|
48
|
+
};
|
|
49
|
+
return calls;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/** A stored row in the given key order — the order a driver's row carries its cells in. */
|
|
53
|
+
const rowOf = (cells: { [columnName: string]: unknown }): SerializedRecord => ({
|
|
54
|
+
id: 'm1',
|
|
55
|
+
created: new Date(1_700_000_000_000),
|
|
56
|
+
updated: new Date(1_700_000_000_000),
|
|
57
|
+
...cells,
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
beforeEach(() => {
|
|
61
|
+
(RecordSerializer as unknown as SerializerStatics).legacyCellsWarned.clear();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
describe('a stored key that is a property name, not a column name', () => {
|
|
65
|
+
test.each([
|
|
66
|
+
['a bare word', LEGACY_WORD],
|
|
67
|
+
['an empty string', ''],
|
|
68
|
+
['NULL', null],
|
|
69
|
+
])('iterated AFTER the declared cell, a legacy cell holding %s leaves the array in place', async (_, legacy) => {
|
|
70
|
+
const serializer = new RecordSerializer<Member>(memberTable);
|
|
71
|
+
const warnings = warningsOf(serializer);
|
|
72
|
+
|
|
73
|
+
const [record] = await serializer.deserializeAll([rowOf({ name: 'm', tag_list: '["a","b"]', tags: legacy })]);
|
|
74
|
+
|
|
75
|
+
expect(record.tags).toEqual(['a', 'b']);
|
|
76
|
+
expect(record).toMatchObject({ id: 'm1', name: 'm' });
|
|
77
|
+
// The legacy cell never reached the array column's parser: no cell-omitted warning.
|
|
78
|
+
expect(warnings).toHaveLength(0);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test.each([
|
|
82
|
+
['a bare word', LEGACY_WORD],
|
|
83
|
+
['an empty string', ''],
|
|
84
|
+
['NULL', null],
|
|
85
|
+
])('iterated BEFORE the declared cell, a legacy cell holding %s leaves the array in place', async (_, legacy) => {
|
|
86
|
+
const serializer = new RecordSerializer<Member>(memberTable);
|
|
87
|
+
const warnings = warningsOf(serializer);
|
|
88
|
+
|
|
89
|
+
const [record] = await serializer.deserializeAll([rowOf({ name: 'm', tags: legacy, tag_list: '["a","b"]' })]);
|
|
90
|
+
|
|
91
|
+
expect(record.tags).toEqual(['a', 'b']);
|
|
92
|
+
expect(warnings).toHaveLength(0);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test('a stored key equal to a column name is that column; the property name alone is nobody', async () => {
|
|
96
|
+
const fieldSerializer = new FieldSerializer<Member>(memberTable);
|
|
97
|
+
|
|
98
|
+
const seated = await fieldSerializer.deserialize('tag_list', '["a"]', {});
|
|
99
|
+
expect(seated).toEqual({ fieldPropertyName: 'tags', fieldValue: ['a'] });
|
|
100
|
+
|
|
101
|
+
await expect(fieldSerializer.deserialize('tags', LEGACY_WORD, {})).rejects.toBeInstanceOf(MissingFieldError);
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
describe('two declared columns sharing one stored name', () => {
|
|
106
|
+
test('is refused at the first hydration, naming the table and both properties', () => {
|
|
107
|
+
interface Twice extends Record {
|
|
108
|
+
first: string;
|
|
109
|
+
second: string;
|
|
110
|
+
}
|
|
111
|
+
class TwiceTable extends Table<Twice> {
|
|
112
|
+
public name = 'seating_twice';
|
|
113
|
+
public columns = withRecordColumns<Twice>({
|
|
114
|
+
first: new StringColumn('shared'),
|
|
115
|
+
second: new StringColumn('shared'),
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
const serializer = new RecordSerializer<Twice>(new TwiceTable() as Table<Twice>);
|
|
119
|
+
|
|
120
|
+
expect(() => serializer.deserializeAll([rowOf({ shared: 'x' })])).toThrow(DuplicateColumnNameError);
|
|
121
|
+
expect(() => serializer.deserializeAll([rowOf({ shared: 'x' })])).toThrow(
|
|
122
|
+
`(seating_twice) Columns first and second both declare the column name 'shared'`
|
|
123
|
+
);
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
describe('the write side', () => {
|
|
128
|
+
class RefusingColumn implements Column<string, string> {
|
|
129
|
+
constructor(public name: string) {}
|
|
130
|
+
|
|
131
|
+
async serialize(): Promise<string> {
|
|
132
|
+
throw new RangeError('cannot be stored');
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
interface Note extends Record {
|
|
136
|
+
title: string;
|
|
137
|
+
body: string;
|
|
138
|
+
}
|
|
139
|
+
class NoteTable extends Table<Note> {
|
|
140
|
+
public name = 'seating_note';
|
|
141
|
+
public columns = withRecordColumns<Note>({
|
|
142
|
+
title: new StringColumn('title'),
|
|
143
|
+
body: new RefusingColumn('body'),
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
const noteTable = new NoteTable() as Table<Note>;
|
|
147
|
+
|
|
148
|
+
test('a column that cannot serialize its value fails the write with that error', async () => {
|
|
149
|
+
const serializer = new RecordSerializer<Note>(noteTable);
|
|
150
|
+
const warnings = warningsOf(serializer);
|
|
151
|
+
|
|
152
|
+
await expect(serializer.serialize({ id: 'n1', title: 't', body: 'b' })).rejects.toThrow(
|
|
153
|
+
new RangeError('cannot be stored')
|
|
154
|
+
);
|
|
155
|
+
expect(warnings).toHaveLength(0);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test('a field no column owns is omitted from the write, and warned', async () => {
|
|
159
|
+
const serializer = new RecordSerializer<Member>(memberTable);
|
|
160
|
+
const warnings = warningsOf(serializer);
|
|
161
|
+
|
|
162
|
+
const serialized = await serializer.serialize({ id: 'm1', name: 'm', tags: ['a'], retired: 'gone' });
|
|
163
|
+
|
|
164
|
+
expect(Object.keys(serialized)).toEqual(['id', 'name', 'tag_list']);
|
|
165
|
+
expect(JSON.parse(serialized.tag_list)).toEqual(['a']);
|
|
166
|
+
expect(warnings).toHaveLength(1);
|
|
167
|
+
expect(warnings[0][0]).toMatchObject({ obj: { omittedFields: ['retired'] } });
|
|
168
|
+
});
|
|
169
|
+
});
|
|
@@ -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 {
|
|
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 {
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import { Db, DbDriver, Record, Table, DefaultTransactionContextFactory } from '@proteinjs/db';
|
|
1
|
+
import { Db, DbDriver, Record, Table, DefaultTransactionContextFactory, QueryBuilderFactory } from '@proteinjs/db';
|
|
2
|
+
import { StatementFactory } from '@proteinjs/db-query';
|
|
3
|
+
import { StatementConfigFactory } from '../../src/StatementConfigFactory';
|
|
2
4
|
import { DbTestEnvironment } from '../util/DbTestEnvironment';
|
|
3
5
|
import { columnTypesTestTables, TestRecord } from '../util/tables/columnTypesTestTables';
|
|
4
6
|
|
|
@@ -78,5 +80,49 @@ export const columnTypeTests = (
|
|
|
78
80
|
|
|
79
81
|
await expect(db.insert(testTable, testRecord)).rejects.toThrow();
|
|
80
82
|
});
|
|
83
|
+
|
|
84
|
+
test('A legacy cell the column cannot deserialize never takes the row with it', async () => {
|
|
85
|
+
// A value written past the serializer — a bare word in a column that stores JSON — the way
|
|
86
|
+
// a hand-run statement or an older writer leaves it: the update statement the db layer
|
|
87
|
+
// itself emits, handed the cells as stored text. The row still serves: that field is
|
|
88
|
+
// omitted, every other field lands, and a query over the table resolves.
|
|
89
|
+
const testTable: Table<TestRecord> = columnTypesTestTables.Test;
|
|
90
|
+
const legacy = await db.insert(testTable, {
|
|
91
|
+
stringColumn: 'legacy',
|
|
92
|
+
objectColumn: { theme: 'dark' },
|
|
93
|
+
arrayColumn: ['a', 'b'],
|
|
94
|
+
});
|
|
95
|
+
const intact = await db.insert(testTable, {
|
|
96
|
+
stringColumn: 'intact',
|
|
97
|
+
objectColumn: { theme: 'light' },
|
|
98
|
+
arrayColumn: ['c'],
|
|
99
|
+
});
|
|
100
|
+
const statementConfigFactory = new StatementConfigFactory(driver.getDbName());
|
|
101
|
+
const legacyRow = new QueryBuilderFactory()
|
|
102
|
+
.getQueryBuilder(testTable)
|
|
103
|
+
.condition({ field: 'id', operator: '=', value: legacy.id });
|
|
104
|
+
await driver.runDml((config) =>
|
|
105
|
+
new StatementFactory<TestRecord>().update(
|
|
106
|
+
testTable.name,
|
|
107
|
+
{ object_column: 'legacyword', array_column: 'legacyword' } as Partial<TestRecord>,
|
|
108
|
+
legacyRow,
|
|
109
|
+
statementConfigFactory.getStatementConfig(config)
|
|
110
|
+
)
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
const fetched = await db.get(testTable, { id: legacy.id });
|
|
114
|
+
expect(fetched).toMatchObject({ id: legacy.id, stringColumn: 'legacy' });
|
|
115
|
+
expect(fetched).not.toHaveProperty('objectColumn');
|
|
116
|
+
expect(fetched).not.toHaveProperty('arrayColumn');
|
|
117
|
+
|
|
118
|
+
const all = await db.query(testTable, {});
|
|
119
|
+
expect(all.map((record) => record.id).sort()).toEqual([legacy.id, intact.id].sort());
|
|
120
|
+
const other = all.find((record) => record.id === intact.id);
|
|
121
|
+
expect(other).toMatchObject({ stringColumn: 'intact', objectColumn: { theme: 'light' }, arrayColumn: ['c'] });
|
|
122
|
+
|
|
123
|
+
// Clean up
|
|
124
|
+
await db.delete(testTable, { id: legacy.id });
|
|
125
|
+
await db.delete(testTable, { id: intact.id });
|
|
126
|
+
});
|
|
81
127
|
};
|
|
82
128
|
};
|