@proteinjs/db 1.21.7 → 1.21.9

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 (31) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/LICENSE +21 -0
  3. package/dist/generated/index.js +7 -7
  4. package/dist/generated/index.js.map +1 -1
  5. package/dist/generated/test/index.js +5 -5
  6. package/dist/generated/test/index.js.map +1 -1
  7. package/dist/src/source/SourceRecordLoader.d.ts +28 -7
  8. package/dist/src/source/SourceRecordLoader.d.ts.map +1 -1
  9. package/dist/src/source/SourceRecordLoader.js +79 -48
  10. package/dist/src/source/SourceRecordLoader.js.map +1 -1
  11. package/dist/test/SourceRecordLoader.test.d.ts +2 -0
  12. package/dist/test/SourceRecordLoader.test.d.ts.map +1 -0
  13. package/dist/test/SourceRecordLoader.test.js +121 -0
  14. package/dist/test/SourceRecordLoader.test.js.map +1 -0
  15. package/dist/test/reusable/CascadeDeleteTests.js +2 -2
  16. package/dist/test/reusable/ColumnTypesTests.js +2 -2
  17. package/dist/test/reusable/CrudTests.js +3 -3
  18. package/dist/test/reusable/DynamicReferenceColumnTests.js +2 -2
  19. package/dist/test/reusable/TableManagerTests.js +2 -2
  20. package/dist/test/reusable/TransactionTests.js +2 -2
  21. package/generated/index.ts +18 -21
  22. package/generated/test/index.ts +41 -44
  23. package/package.json +10 -9
  24. package/src/source/SourceRecordLoader.ts +73 -42
  25. package/test/SourceRecordLoader.test.ts +154 -0
  26. package/test/reusable/CascadeDeleteTests.ts +2 -2
  27. package/test/reusable/ColumnTypesTests.ts +2 -2
  28. package/test/reusable/CrudTests.ts +3 -3
  29. package/test/reusable/DynamicReferenceColumnTests.ts +2 -2
  30. package/test/reusable/TableManagerTests.ts +2 -2
  31. package/test/reusable/TransactionTests.ts +2 -2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@proteinjs/db",
3
- "version": "1.21.7",
3
+ "version": "1.21.9",
4
4
  "main": "./dist/generated/index.js",
5
5
  "types": "./dist/generated/index.d.ts",
6
6
  "exports": {
@@ -41,19 +41,19 @@
41
41
  "test": "jest --passWithNoTests"
42
42
  },
43
43
  "dependencies": {
44
- "@proteinjs/db-query": "^1.4.6",
45
- "@proteinjs/logger": "^1.0.17",
44
+ "@proteinjs/db-query": "^1.4.7",
45
+ "@proteinjs/logger": "^1.0.18",
46
46
  "@proteinjs/reflection": "^1.1.11",
47
- "@proteinjs/serializer": "^1.1.6",
48
- "@proteinjs/server-api": "^3.0.4",
49
- "@proteinjs/service": "^1.2.13",
50
- "@proteinjs/user-auth": "^1.1.11",
47
+ "@proteinjs/serializer": "^1.1.7",
48
+ "@proteinjs/server-api": "^3.0.5",
49
+ "@proteinjs/service": "^1.2.14",
50
+ "@proteinjs/user-auth": "^1.1.12",
51
51
  "@proteinjs/util": "^1.6.0",
52
52
  "moment": "2.29.4",
53
53
  "uuid": "8.3.0"
54
54
  },
55
55
  "devDependencies": {
56
- "@proteinjs/reflection-build": "^1.4.5",
56
+ "@proteinjs/reflection-build": "^1.4.6",
57
57
  "@types/jest": "29.5.5",
58
58
  "@types/node": "14.0.27",
59
59
  "@types/uuid": "8.3.0",
@@ -65,5 +65,6 @@
65
65
  "jest": "29.7.0",
66
66
  "ts-jest": "29.1.1",
67
67
  "typescript": "5.2.2"
68
- }
68
+ },
69
+ "gitHead": "75411714e31c9033803f328cb43fa922d77d9782"
69
70
  }
@@ -67,11 +67,12 @@ export class SourceRecordLoader {
67
67
  /**
68
68
  * Compare source record fields against the existing DB record to detect actual changes.
69
69
  * Only fields present on the source record are compared (ignoring `created`, `updated`).
70
- * Uses serialization to normalize values (e.g. Reference objects, Moment, JSON) before comparison.
70
+ * Uses serialization to normalize values (e.g. Reference objects, Moment, JSON) before
71
+ * comparison, then delegates to {@link findMismatchPath}.
71
72
  *
72
- * The comparison checks that every value in the source record exists with the same value
73
- * in the existing DB record. Extra keys in the DB record are ignored — table watchers
74
- * and hooks may enrich records with additional data after insert/update.
73
+ * Object-valued fields (e.g. `JsonColumn` blobs) are treated as source-authoritative:
74
+ * any structural drift, including extra keys left behind by earlier source versions,
75
+ * triggers a rewrite. Primitive columns retain their existing semantics.
75
76
  */
76
77
  private async hasChanges(table: Table<any>, sourceRecord: any, existingRecord: any): Promise<boolean> {
77
78
  const serializer = new RecordSerializer(table);
@@ -92,11 +93,43 @@ export class SourceRecordLoader {
92
93
  return false;
93
94
  }
94
95
 
96
+ private async getSourceRecordsMap() {
97
+ const sourceRecordsMap: SourceRecordsMap = {};
98
+ const sourceRecordTables = getSourceRecordTables();
99
+ for (const sourceRecordTable of sourceRecordTables) {
100
+ if (!sourceRecordsMap[sourceRecordTable.name]) {
101
+ sourceRecordsMap[sourceRecordTable.name] = { table: sourceRecordTable, records: [], recordIds: [] };
102
+ }
103
+ }
104
+
105
+ const sourceRecordLoaders = getSourceRecordLoaders();
106
+ for (const sourceRecordLoader of sourceRecordLoaders) {
107
+ if (!sourceRecordsMap[sourceRecordLoader.table.name]) {
108
+ sourceRecordsMap[sourceRecordLoader.table.name] = {
109
+ table: sourceRecordLoader.table,
110
+ records: [],
111
+ recordIds: [],
112
+ };
113
+ }
114
+
115
+ sourceRecordsMap[sourceRecordLoader.table.name].records.push(sourceRecordLoader.record);
116
+ sourceRecordsMap[sourceRecordLoader.table.name].recordIds.push(sourceRecordLoader.record.id);
117
+ }
118
+
119
+ return sourceRecordsMap;
120
+ }
121
+
95
122
  /**
96
123
  * Find the first point of divergence between source and existing values.
97
124
  * Returns a description of the mismatch path, or null if they match.
98
- * For objects, extra keys in `existing` are ignored — they may have been added by
99
- * table watchers or hooks after the source record was loaded.
125
+ *
126
+ * For object-valued fields (e.g. a `JsonColumn` blob), source is treated as
127
+ * fully authoritative: any structural drift — extra keys in existing, missing
128
+ * keys in existing, or value differences anywhere in the subtree — produces
129
+ * a mismatch. Comparison goes through {@link SourceRecordLoader.canonicalStringify}
130
+ * so that key ordering (which backing stores may canonicalize alphabetically)
131
+ * does not cause false positives.
132
+ *
100
133
  * For arrays, order and length must match exactly.
101
134
  */
102
135
  private findMismatchPath(source: any, existing: any, path: string): string | null {
@@ -139,47 +172,45 @@ export class SourceRecordLoader {
139
172
  return null;
140
173
  }
141
174
 
142
- for (const key of Object.keys(source)) {
143
- // Skip undefined values they don't survive JSON serialization (JSON.stringify
144
- // drops undefined), so the DB record won't have them.
145
- if (source[key] === undefined) {
146
- continue;
147
- }
148
- if (!(key in existing)) {
149
- return `${path}.${key}: key missing in existing`;
150
- }
151
- const result = this.findMismatchPath(source[key], existing[key], `${path}.${key}`);
152
- if (result) {
153
- return result;
154
- }
175
+ // Both values are non-null, non-array objects. Treat source as authoritative:
176
+ // any structural drift triggers a mismatch. Canonical stringify normalizes
177
+ // key order so storage-side canonicalization (e.g. Spanner alphabetizes JSON
178
+ // keys) doesn't register as drift.
179
+ if (this.canonicalStringify(source) !== this.canonicalStringify(existing)) {
180
+ return `${path}: object differs`;
155
181
  }
156
-
157
182
  return null;
158
183
  }
159
184
 
160
- private async getSourceRecordsMap() {
161
- const sourceRecordsMap: SourceRecordsMap = {};
162
- const sourceRecordTables = getSourceRecordTables();
163
- for (const sourceRecordTable of sourceRecordTables) {
164
- if (!sourceRecordsMap[sourceRecordTable.name]) {
165
- sourceRecordsMap[sourceRecordTable.name] = { table: sourceRecordTable, records: [], recordIds: [] };
166
- }
185
+ /**
186
+ * Canonical JSON stringification with recursively sorted object keys.
187
+ *
188
+ * Why this exists: some stores (notably Spanner) canonicalize JSON object
189
+ * keys alphabetically on storage. Source records declared in TypeScript
190
+ * code don't guarantee alphabetical key order, so a plain `JSON.stringify`
191
+ * comparison between source and the existing DB value would produce false
192
+ * mismatches driven purely by key ordering. Sorting keys on both sides
193
+ * normalizes them so semantic equality maps to string equality.
194
+ *
195
+ * Arrays preserve order (order is semantic for arrays); only object keys
196
+ * are sorted.
197
+ */
198
+ private canonicalStringify(value: unknown): string {
199
+ if (value === null || typeof value !== 'object') {
200
+ return JSON.stringify(value);
167
201
  }
168
-
169
- const sourceRecordLoaders = getSourceRecordLoaders();
170
- for (const sourceRecordLoader of sourceRecordLoaders) {
171
- if (!sourceRecordsMap[sourceRecordLoader.table.name]) {
172
- sourceRecordsMap[sourceRecordLoader.table.name] = {
173
- table: sourceRecordLoader.table,
174
- records: [],
175
- recordIds: [],
176
- };
177
- }
178
-
179
- sourceRecordsMap[sourceRecordLoader.table.name].records.push(sourceRecordLoader.record);
180
- sourceRecordsMap[sourceRecordLoader.table.name].recordIds.push(sourceRecordLoader.record.id);
202
+ if (Array.isArray(value)) {
203
+ // Mirror JSON.stringify: undefined array elements serialize as `null`.
204
+ return '[' + value.map((v) => (v === undefined ? 'null' : this.canonicalStringify(v))).join(',') + ']';
181
205
  }
182
-
183
- return sourceRecordsMap;
206
+ // Mirror JSON.stringify: skip object properties whose value is `undefined`.
207
+ // This keeps source records that declare optional fields (as `undefined`)
208
+ // from being treated as drift vs existing rows that simply don't have the
209
+ // field — `undefined` would never have been written to the DB.
210
+ const obj = value as Record<string, unknown>;
211
+ const keys = Object.keys(obj)
212
+ .filter((k) => obj[k] !== undefined)
213
+ .sort();
214
+ return '{' + keys.map((k) => JSON.stringify(k) + ':' + this.canonicalStringify(obj[k])).join(',') + '}';
184
215
  }
185
216
  }
@@ -0,0 +1,154 @@
1
+ import { SourceRecordLoader } from '../src/source/SourceRecordLoader';
2
+
3
+ /**
4
+ * Covers the two helpers that drive SourceRecordLoader's change-detection:
5
+ * - `canonicalStringify`: deterministic, key-order-independent JSON
6
+ * - `findMismatchPath`: per-field drift detection that uses canonical stringify
7
+ * so object-valued columns (e.g. `JsonColumn`) are compared strictly while
8
+ * remaining immune to storage-side key reordering (Spanner alphabetizes JSON
9
+ * object keys on write).
10
+ *
11
+ * Both are private methods on `SourceRecordLoader`. Tests access them through
12
+ * the instance with a cast — cheaper than wiring full `hasChanges` fixtures
13
+ * (Table, RecordSerializer, Db) and keeps the public API surface of the class
14
+ * unchanged.
15
+ */
16
+
17
+ type LoaderInternals = {
18
+ canonicalStringify: (value: unknown) => string;
19
+ findMismatchPath: (source: any, existing: any, path: string) => string | null;
20
+ };
21
+
22
+ const internals = () => new SourceRecordLoader() as unknown as LoaderInternals;
23
+
24
+ describe('SourceRecordLoader.canonicalStringify', () => {
25
+ it('produces the same string for objects with different key orders', () => {
26
+ const loader = internals();
27
+ const a = { foo: 1, bar: 2, baz: 3 };
28
+ const b = { baz: 3, foo: 1, bar: 2 };
29
+ expect(loader.canonicalStringify(a)).toBe(loader.canonicalStringify(b));
30
+ });
31
+
32
+ it('sorts keys alphabetically in nested objects', () => {
33
+ const loader = internals();
34
+ const value = { z: { c: 1, a: 2, b: 3 }, a: 1 };
35
+ expect(loader.canonicalStringify(value)).toBe('{"a":1,"z":{"a":2,"b":3,"c":1}}');
36
+ });
37
+
38
+ it('preserves array order (arrays are semantically ordered)', () => {
39
+ const loader = internals();
40
+ expect(loader.canonicalStringify([3, 1, 2])).toBe('[3,1,2]');
41
+ });
42
+
43
+ it('handles primitives the same as JSON.stringify', () => {
44
+ const loader = internals();
45
+ expect(loader.canonicalStringify('hello')).toBe('"hello"');
46
+ expect(loader.canonicalStringify(42)).toBe('42');
47
+ expect(loader.canonicalStringify(true)).toBe('true');
48
+ expect(loader.canonicalStringify(null)).toBe('null');
49
+ });
50
+ });
51
+
52
+ describe('SourceRecordLoader.findMismatchPath', () => {
53
+ describe('object comparison', () => {
54
+ it('returns null for identical objects', () => {
55
+ const loader = internals();
56
+ const source = { content: '', type: 'body1', linkedThoughtId: '' };
57
+ const existing = { content: '', type: 'body1', linkedThoughtId: '' };
58
+ expect(loader.findMismatchPath(source, existing, 'newThoughtTemplate')).toBeNull();
59
+ });
60
+
61
+ it('returns null when existing has the same keys in a different order (Spanner alphabetizes)', () => {
62
+ // This is the critical case: Spanner canonicalizes JSON object keys
63
+ // alphabetically on storage. Source declared in TypeScript doesn't
64
+ // guarantee alphabetical key order. The two must still compare equal.
65
+ const loader = internals();
66
+ const source = { content: '', type: 'body1', linkedThoughtId: '' };
67
+ const existing = { content: '', linkedThoughtId: '', type: 'body1' };
68
+ expect(loader.findMismatchPath(source, existing, 'newThoughtTemplate')).toBeNull();
69
+ });
70
+
71
+ it('reports a mismatch when existing has extra keys (ThoughtLink drift case)', () => {
72
+ // Regression guard for the bug that prompted this change: source was
73
+ // simplified to drop TextType drift keys (fontSize, bold, italic, etc.),
74
+ // but the old "extras tolerated" behavior left the stale keys on the DB
75
+ // row, causing type-switch demotion to persist.
76
+ const loader = internals();
77
+ const source = {
78
+ thoughtType: { object: { content: '', type: 'body1', linkedThoughtId: '' } },
79
+ };
80
+ const existing = {
81
+ thoughtType: {
82
+ object: {
83
+ content: '',
84
+ type: 'body1',
85
+ linkedThoughtId: '',
86
+ fontSize: 14,
87
+ bold: false,
88
+ italic: false,
89
+ },
90
+ },
91
+ };
92
+ expect(loader.findMismatchPath(source, existing, 'newThoughtTemplate')).not.toBeNull();
93
+ });
94
+
95
+ it('reports a mismatch when existing is missing a key present in source', () => {
96
+ const loader = internals();
97
+ const source = { content: '', type: 'body1', linkedThoughtId: '' };
98
+ const existing = { content: '', type: 'body1' };
99
+ expect(loader.findMismatchPath(source, existing, 'newThoughtTemplate')).not.toBeNull();
100
+ });
101
+
102
+ it('reports a mismatch for a nested value difference', () => {
103
+ const loader = internals();
104
+ const source = { thoughtType: { object: { type: 'body1' } } };
105
+ const existing = { thoughtType: { object: { type: 'body2' } } };
106
+ expect(loader.findMismatchPath(source, existing, 'newThoughtTemplate')).not.toBeNull();
107
+ });
108
+
109
+ it('treats source-side undefined values as equivalent to missing in existing', () => {
110
+ // `JSON.stringify` drops undefined values, so the DB cannot store them
111
+ // in the first place. Source declaring an undefined field must not be
112
+ // treated as drift vs an existing row that lacks the field.
113
+ const loader = internals();
114
+ const source = { content: 'hi', optional: undefined };
115
+ const existing = { content: 'hi' };
116
+ expect(loader.findMismatchPath(source, existing, 'newThoughtTemplate')).toBeNull();
117
+ });
118
+ });
119
+
120
+ describe('array comparison', () => {
121
+ it('reports a mismatch on array length', () => {
122
+ const loader = internals();
123
+ expect(loader.findMismatchPath([1, 2], [1, 2, 3], 'tags')).not.toBeNull();
124
+ });
125
+
126
+ it('reports a mismatch on array element difference', () => {
127
+ const loader = internals();
128
+ expect(loader.findMismatchPath([1, 2], [1, 3], 'tags')).not.toBeNull();
129
+ });
130
+
131
+ it('returns null for equal arrays', () => {
132
+ const loader = internals();
133
+ expect(loader.findMismatchPath([1, 2, 3], [1, 2, 3], 'tags')).toBeNull();
134
+ });
135
+ });
136
+
137
+ describe('primitive comparison', () => {
138
+ it('returns null for equal primitives', () => {
139
+ const loader = internals();
140
+ expect(loader.findMismatchPath('x', 'x', 'name')).toBeNull();
141
+ expect(loader.findMismatchPath(5, 5, 'count')).toBeNull();
142
+ });
143
+
144
+ it('reports a mismatch for different primitives', () => {
145
+ const loader = internals();
146
+ expect(loader.findMismatchPath('x', 'y', 'name')).not.toBeNull();
147
+ });
148
+
149
+ it('reports a mismatch for type differences', () => {
150
+ const loader = internals();
151
+ expect(loader.findMismatchPath('1', 1, 'value')).not.toBeNull();
152
+ });
153
+ });
154
+ });
@@ -40,8 +40,8 @@ export const cascadeDeleteTests = (
40
40
  const db = new Db(driver, undefined, transactionContextFactory);
41
41
  const testEnv = new DbTestEnvironment(driver, dropTable);
42
42
 
43
- beforeAll(async () => await testEnv.beforeAll(), 10000);
44
- afterAll(async () => await testEnv.afterAll(), 10000);
43
+ beforeAll(async () => await testEnv.beforeAll(), 30000);
44
+ afterAll(async () => await testEnv.afterAll(), 30000);
45
45
 
46
46
  /**
47
47
  * -------------------- Cascade Delete (holder → referenced) --------------------
@@ -11,8 +11,8 @@ export const columnTypeTests = (
11
11
  const db = new Db(driver, undefined, transactionContextFactory);
12
12
  const testEnv = new DbTestEnvironment(driver, dropTable);
13
13
 
14
- beforeAll(async () => await testEnv.beforeAll(), 10000);
15
- afterAll(async () => await testEnv.afterAll(), 10000);
14
+ beforeAll(async () => await testEnv.beforeAll(), 30000);
15
+ afterAll(async () => await testEnv.afterAll(), 30000);
16
16
 
17
17
  test('Insert record with all null values', async () => {
18
18
  const testRecord: Omit<TestRecord, keyof Record> = {
@@ -12,8 +12,8 @@ export const crudTests = (
12
12
  const db = new Db(driver, undefined, transactionContextFactory);
13
13
  const testEnv = new DbTestEnvironment(driver, dropTable);
14
14
 
15
- beforeAll(async () => await testEnv.beforeAll(), 10000);
16
- afterAll(async () => await testEnv.afterAll(), 10000);
15
+ beforeAll(async () => await testEnv.beforeAll(), 30000);
16
+ afterAll(async () => await testEnv.afterAll(), 30000);
17
17
 
18
18
  test('Insert', async () => {
19
19
  const testEmployee: Omit<Employee, keyof Record> = { name: 'Veronica' };
@@ -417,7 +417,7 @@ export const crudTests = (
417
417
  // Clean up
418
418
  await db.delete(table, { id: insertedRecord1.id });
419
419
  await db.delete(table, { id: insertedRecord2.id });
420
- }, 10000);
420
+ }, 30000);
421
421
 
422
422
  test('Case sensitivity', async () => {
423
423
  const testEmployee: Omit<Employee, keyof Record> = {
@@ -14,8 +14,8 @@ export const dynamicReferenceColumnTests = (driver: DbDriver, dropTable: (table:
14
14
  const db = new Db(driver);
15
15
  const testEnv = new DbTestEnvironment(driver, dropTable);
16
16
 
17
- beforeAll(async () => await testEnv.beforeAll(), 10000);
18
- afterAll(async () => await testEnv.afterAll(), 10000);
17
+ beforeAll(async () => await testEnv.beforeAll(), 30000);
18
+ afterAll(async () => await testEnv.afterAll(), 30000);
19
19
 
20
20
  test('should handle references to different types', async () => {
21
21
  // Create an engineer
@@ -34,8 +34,8 @@ export const tableManagerTests = (
34
34
  const tableManager = driver.getTableManager();
35
35
  const testEnv = new DbTestEnvironment(driver, dropTable);
36
36
 
37
- beforeAll(async () => await testEnv.beforeAll(), 10000);
38
- afterAll(async () => await testEnv.afterAll(), 10000);
37
+ beforeAll(async () => await testEnv.beforeAll(), 30000);
38
+ afterAll(async () => await testEnv.afterAll(), 30000);
39
39
 
40
40
  afterEach(async () => {
41
41
  await dropTable(tableManagerTestTables.ColumnTypes);
@@ -16,8 +16,8 @@ export const transactionTests = (
16
16
  const db = new Db(driver, undefined, transactionContextFactory);
17
17
  const testEnv = new DbTestEnvironment(driver, dropTable);
18
18
 
19
- beforeAll(async () => await testEnv.beforeAll(), 10000);
20
- afterAll(async () => await testEnv.afterAll(), 10000);
19
+ beforeAll(async () => await testEnv.beforeAll(), 30000);
20
+ afterAll(async () => await testEnv.afterAll(), 30000);
21
21
 
22
22
  test('Transaction with successful operations', async () => {
23
23
  const testEmployee1: Omit<TransactionEmployee, keyof Record> = {