@powersync/service-core 1.24.0 → 1.25.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/CHANGELOG.md +17 -0
- package/dist/storage/SourceEntity.d.ts +7 -2
- package/dist/storage/SourceTable.d.ts +24 -1
- package/dist/storage/SourceTable.js +34 -6
- package/dist/storage/SourceTable.js.map +1 -1
- package/dist/storage/SourceTableReconciler.d.ts +83 -0
- package/dist/storage/SourceTableReconciler.js +95 -0
- package/dist/storage/SourceTableReconciler.js.map +1 -0
- package/dist/storage/SyncRulesBucketStorage.d.ts +6 -0
- package/dist/storage/SyncRulesBucketStorage.js.map +1 -1
- package/dist/storage/storage-index.d.ts +1 -0
- package/dist/storage/storage-index.js +1 -0
- package/dist/storage/storage-index.js.map +1 -1
- package/dist/util/utils.d.ts +1 -1
- package/dist/util/utils.js +1 -1
- package/dist/util/utils.js.map +1 -1
- package/package.json +1 -1
- package/src/storage/SourceEntity.ts +6 -2
- package/src/storage/SourceTable.ts +51 -7
- package/src/storage/SourceTableReconciler.ts +188 -0
- package/src/storage/SyncRulesBucketStorage.ts +6 -0
- package/src/storage/storage-index.ts +1 -0
- package/src/util/utils.ts +1 -1
- package/test/src/source-table-reconciler.test.ts +244 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { ServiceAssertionError } from '@powersync/lib-services-framework';
|
|
2
|
+
import { isDeepStrictEqual } from 'node:util';
|
|
3
|
+
import { JsonValue, SourceEntityDescriptor } from './SourceEntity.js';
|
|
4
|
+
import { SourceTable, SourceTableCandidate, SourceTableId, sourceTableIdEquals } from './SourceTable.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A source connector's classification of overlapping persisted tables.
|
|
8
|
+
*/
|
|
9
|
+
export interface SourceTableCandidateResolution {
|
|
10
|
+
/**
|
|
11
|
+
* Records storage can reuse. Copies may include updated source metadata.
|
|
12
|
+
*/
|
|
13
|
+
compatibleTables: ReadonlyArray<SourceTableCandidate>;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Records that cannot be reused. Every candidate must appear in exactly one result list.
|
|
17
|
+
*/
|
|
18
|
+
incompatibleTables: ReadonlyArray<SourceTableCandidate>;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Values for records storage creates during this resolution.
|
|
22
|
+
*/
|
|
23
|
+
newTableValues: SourceTableCreateValues;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface SourceTableCreateValues {
|
|
27
|
+
/**
|
|
28
|
+
* Source metadata for new records. Null means no metadata.
|
|
29
|
+
*/
|
|
30
|
+
sourceMetadata: JsonValue;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Input to a source-owned reconciliation callback. The callback may run inside a storage
|
|
35
|
+
* transaction, so it must not mutate storage or perform slow external work.
|
|
36
|
+
*/
|
|
37
|
+
export interface SourceTableCandidateReconcilerInput {
|
|
38
|
+
/**
|
|
39
|
+
* Source entity being resolved.
|
|
40
|
+
*/
|
|
41
|
+
source: SourceEntityDescriptor;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Persisted tables overlapping by name or object id.
|
|
45
|
+
*/
|
|
46
|
+
candidates: ReadonlyArray<SourceTableCandidate>;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export type SourceTableCandidateReconciler = (
|
|
50
|
+
input: SourceTableCandidateReconcilerInput
|
|
51
|
+
) => SourceTableCandidateResolution | Promise<SourceTableCandidateResolution>;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Compare replica-id columns in order.
|
|
55
|
+
*/
|
|
56
|
+
export function sameReplicaIdColumns(
|
|
57
|
+
left: SourceTableCandidate['replicaIdColumns'],
|
|
58
|
+
right: SourceEntityDescriptor
|
|
59
|
+
): boolean {
|
|
60
|
+
const target = right.replicaIdColumns;
|
|
61
|
+
return (
|
|
62
|
+
left.length == target.length &&
|
|
63
|
+
left.every(
|
|
64
|
+
(column, index) =>
|
|
65
|
+
column.name == target[index].name && column.type == target[index].type && column.typeId == target[index].typeId
|
|
66
|
+
)
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Compare the shared source-table identity fields.
|
|
72
|
+
*/
|
|
73
|
+
export function sourceIdentityCompatible(source: SourceEntityDescriptor, candidate: SourceTableCandidate): boolean {
|
|
74
|
+
return (
|
|
75
|
+
candidate.schema == source.schema &&
|
|
76
|
+
candidate.name == source.name &&
|
|
77
|
+
(source.objectId == null || candidate.objectId == source.objectId) &&
|
|
78
|
+
sameReplicaIdColumns(candidate.replicaIdColumns, source)
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Default identity-based reconciliation for connectors without source-specific metadata.
|
|
84
|
+
*/
|
|
85
|
+
export const defaultSourceTableReconciler: SourceTableCandidateReconciler = ({ source, candidates }) => {
|
|
86
|
+
const compatibleTables: SourceTableCandidate[] = [];
|
|
87
|
+
const incompatibleTables: SourceTableCandidate[] = [];
|
|
88
|
+
for (const candidate of candidates) {
|
|
89
|
+
if (sourceIdentityCompatible(source, candidate)) {
|
|
90
|
+
compatibleTables.push(candidate);
|
|
91
|
+
} else {
|
|
92
|
+
incompatibleTables.push(candidate);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return { compatibleTables, incompatibleTables, newTableValues: { sourceMetadata: null } };
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Check that every candidate was classified exactly once.
|
|
100
|
+
*/
|
|
101
|
+
export function validateSourceTableCandidateResolution(
|
|
102
|
+
candidates: ReadonlyArray<SourceTableCandidate>,
|
|
103
|
+
resolution: SourceTableCandidateResolution
|
|
104
|
+
): void {
|
|
105
|
+
const classifiedTables = [...resolution.compatibleTables, ...resolution.incompatibleTables];
|
|
106
|
+
|
|
107
|
+
for (const candidate of candidates) {
|
|
108
|
+
const classifications = classifiedTables.filter((table) => sourceTableIdEquals(table.id, candidate.id));
|
|
109
|
+
if (classifications.length !== 1) {
|
|
110
|
+
throw new ServiceAssertionError(
|
|
111
|
+
`Source table candidate ${candidate.id.toString()} must be classified exactly once, got ${classifications.length}`
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
for (const table of classifiedTables) {
|
|
117
|
+
if (!candidates.some((candidate) => sourceTableIdEquals(candidate.id, table.id))) {
|
|
118
|
+
throw new ServiceAssertionError(`Source table reconciliation returned unknown candidate ${table.id.toString()}`);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* A source-metadata update to persist.
|
|
125
|
+
*/
|
|
126
|
+
export interface SourceTableMetadataUpdate {
|
|
127
|
+
id: SourceTableId;
|
|
128
|
+
sourceMetadata: JsonValue;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Rebuild a resolution from storage-owned tables, applying only reconciler-owned metadata to
|
|
133
|
+
* compatible tables. All other mutable table state comes from storage.
|
|
134
|
+
*
|
|
135
|
+
* Reconciler candidates are typed as read-only, but TypeScript types provide no runtime protection:
|
|
136
|
+
* callback code can cast a cloned candidate and mutate it. Rematerializing by id ensures those
|
|
137
|
+
* mutations are not trusted even when the compile-time boundary is bypassed.
|
|
138
|
+
*/
|
|
139
|
+
export function materializeSourceTableResolution(
|
|
140
|
+
tables: ReadonlyArray<SourceTable>,
|
|
141
|
+
resolution: SourceTableCandidateResolution
|
|
142
|
+
): MaterializedSourceTableResolution {
|
|
143
|
+
const findTable = (candidate: SourceTableCandidate): SourceTable => {
|
|
144
|
+
const table = tables.find((table) => sourceTableIdEquals(table.id, candidate.id));
|
|
145
|
+
if (table == null) {
|
|
146
|
+
throw new ServiceAssertionError(`Source table candidate ${candidate.id.toString()} was not persisted`);
|
|
147
|
+
}
|
|
148
|
+
return table;
|
|
149
|
+
};
|
|
150
|
+
return {
|
|
151
|
+
compatibleTables: resolution.compatibleTables.map((candidate) =>
|
|
152
|
+
findTable(candidate).withSourceMetadata(candidate.sourceMetadata)
|
|
153
|
+
),
|
|
154
|
+
incompatibleTables: resolution.incompatibleTables.map(findTable),
|
|
155
|
+
newTableValues: resolution.newTableValues
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export interface MaterializedSourceTableResolution {
|
|
160
|
+
compatibleTables: SourceTable[];
|
|
161
|
+
incompatibleTables: SourceTable[];
|
|
162
|
+
newTableValues: SourceTableCreateValues;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Return source-metadata changes from compatible candidates, comparing metadata by value against
|
|
167
|
+
* the original storage-owned tables. The reconciler may mutate its isolated candidate clones, so
|
|
168
|
+
* those clones cannot be used as the persisted baseline.
|
|
169
|
+
*/
|
|
170
|
+
export function diffSourceTableUpdates(
|
|
171
|
+
persistedTables: ReadonlyArray<SourceTable>,
|
|
172
|
+
resolution: SourceTableCandidateResolution
|
|
173
|
+
): SourceTableMetadataUpdate[] {
|
|
174
|
+
const updates: SourceTableMetadataUpdate[] = [];
|
|
175
|
+
for (const resolvedTable of resolution.compatibleTables) {
|
|
176
|
+
const persistedTable = persistedTables.find((table) => sourceTableIdEquals(table.id, resolvedTable.id));
|
|
177
|
+
if (persistedTable == null) {
|
|
178
|
+
throw new ServiceAssertionError(
|
|
179
|
+
`Source table reconciliation returned unknown candidate ${resolvedTable.id.toString()}`
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
if (isDeepStrictEqual(persistedTable.sourceMetadata, resolvedTable.sourceMetadata)) {
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
updates.push({ id: resolvedTable.id, sourceMetadata: resolvedTable.sourceMetadata });
|
|
186
|
+
}
|
|
187
|
+
return updates;
|
|
188
|
+
}
|
|
@@ -14,6 +14,7 @@ import { ParsedSyncConfigSet } from './ParsedSyncConfigSet.js';
|
|
|
14
14
|
import { ParseSyncConfigOptions } from './PersistedSyncConfigContent.js';
|
|
15
15
|
import { SourceEntityDescriptor } from './SourceEntity.js';
|
|
16
16
|
import { SourceTable } from './SourceTable.js';
|
|
17
|
+
import { SourceTableCandidateReconciler } from './SourceTableReconciler.js';
|
|
17
18
|
import { StorageVersionConfig } from './StorageVersionConfig.js';
|
|
18
19
|
import { SyncStorageWriteCheckpointAPI } from './WriteCheckpointAPI.js';
|
|
19
20
|
|
|
@@ -212,6 +213,11 @@ export interface ResolveTablesOptions {
|
|
|
212
213
|
* Source table or collection metadata discovered during snapshot or streaming.
|
|
213
214
|
*/
|
|
214
215
|
source: SourceEntityDescriptor;
|
|
216
|
+
/**
|
|
217
|
+
* Classifies overlapping persisted tables. Defaults to identity-based reconciliation.
|
|
218
|
+
* This may run inside a storage transaction and must not mutate storage.
|
|
219
|
+
*/
|
|
220
|
+
reconcileSourceTables?: SourceTableCandidateReconciler;
|
|
215
221
|
/**
|
|
216
222
|
* For tests only - custom id generator for stable ids.
|
|
217
223
|
*/
|
|
@@ -13,6 +13,7 @@ export * from './ReplicationLock.js';
|
|
|
13
13
|
export * from './ReportStorage.js';
|
|
14
14
|
export * from './SourceEntity.js';
|
|
15
15
|
export * from './SourceTable.js';
|
|
16
|
+
export * from './SourceTableReconciler.js';
|
|
16
17
|
export * from './storage-metrics.js';
|
|
17
18
|
export * from './StorageEngine.js';
|
|
18
19
|
export * from './StorageProvider.js';
|
package/src/util/utils.ts
CHANGED
|
@@ -33,7 +33,7 @@ export interface PartialChecksum {
|
|
|
33
33
|
*/
|
|
34
34
|
export type InternalOpId = bigint;
|
|
35
35
|
|
|
36
|
-
export const ID_NAMESPACE = 'a396dd91-09fc-4017-a28d-3df722f651e9';
|
|
36
|
+
export const ID_NAMESPACE = uuid.parse('a396dd91-09fc-4017-a28d-3df722f651e9');
|
|
37
37
|
|
|
38
38
|
export function escapeIdentifier(identifier: string) {
|
|
39
39
|
return `"${identifier.replace(/"/g, '""').replace(/\./g, '"."')}"`;
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
import { SourceEntityDescriptor } from '@/storage/SourceEntity.js';
|
|
2
|
+
import { SourceTable, sourceTableIdEquals } from '@/storage/SourceTable.js';
|
|
3
|
+
import {
|
|
4
|
+
defaultSourceTableReconciler,
|
|
5
|
+
diffSourceTableUpdates,
|
|
6
|
+
materializeSourceTableResolution,
|
|
7
|
+
sourceIdentityCompatible,
|
|
8
|
+
validateSourceTableCandidateResolution
|
|
9
|
+
} from '@/storage/SourceTableReconciler.js';
|
|
10
|
+
import * as bson from 'bson';
|
|
11
|
+
import { describe, expect, it } from 'vitest';
|
|
12
|
+
|
|
13
|
+
function descriptor(overrides: Partial<SourceEntityDescriptor> = {}): SourceEntityDescriptor {
|
|
14
|
+
return {
|
|
15
|
+
connectionTag: 'default',
|
|
16
|
+
schema: 'public',
|
|
17
|
+
name: 'users',
|
|
18
|
+
objectId: 100,
|
|
19
|
+
replicaIdColumns: [{ name: 'id', type: 'int', typeId: 23 }],
|
|
20
|
+
...overrides
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function candidate(overrides: Partial<ConstructorParameters<typeof SourceTable>[0]> = {}): SourceTable {
|
|
25
|
+
return new SourceTable({
|
|
26
|
+
id: overrides.id ?? 'table-1',
|
|
27
|
+
ref: overrides.ref ?? { connectionTag: 'default', schema: 'public', name: 'users' },
|
|
28
|
+
objectId: 'objectId' in overrides ? overrides.objectId! : 100,
|
|
29
|
+
replicaIdColumns: overrides.replicaIdColumns ?? [{ name: 'id', type: 'int', typeId: 23 }],
|
|
30
|
+
snapshotComplete: overrides.snapshotComplete ?? true,
|
|
31
|
+
bucketDataSources: overrides.bucketDataSources ?? [],
|
|
32
|
+
parameterLookupSources: overrides.parameterLookupSources ?? [],
|
|
33
|
+
sourceMetadata: overrides.sourceMetadata
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
describe('sourceIdentityCompatible', () => {
|
|
38
|
+
it('matches identical identity', () => {
|
|
39
|
+
expect(sourceIdentityCompatible(descriptor(), candidate())).toBe(true);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('rejects a different object id', () => {
|
|
43
|
+
expect(sourceIdentityCompatible(descriptor({ objectId: 200 }), candidate({ objectId: 100 }))).toBe(false);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('rejects a different schema/name', () => {
|
|
47
|
+
expect(sourceIdentityCompatible(descriptor({ name: 'accounts' }), candidate())).toBe(false);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('rejects changed replica-id columns', () => {
|
|
51
|
+
expect(
|
|
52
|
+
sourceIdentityCompatible(
|
|
53
|
+
descriptor(),
|
|
54
|
+
candidate({ replicaIdColumns: [{ name: 'id', type: 'bigint', typeId: 20 }] })
|
|
55
|
+
)
|
|
56
|
+
).toBe(false);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('treats an undefined descriptor object id as a wildcard on object id', () => {
|
|
60
|
+
expect(sourceIdentityCompatible(descriptor({ objectId: undefined }), candidate({ objectId: 999 }))).toBe(true);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
describe('defaultSourceTableReconciler', () => {
|
|
65
|
+
it('returns all identity-compatible candidates and no metadata', async () => {
|
|
66
|
+
const a = candidate({ id: 'a' });
|
|
67
|
+
const b = candidate({ id: 'b', ref: { connectionTag: 'default', schema: 'public', name: 'accounts' } });
|
|
68
|
+
const resolution = await defaultSourceTableReconciler({ source: descriptor(), candidates: [a, b] });
|
|
69
|
+
expect(resolution.compatibleTables).toEqual([a]);
|
|
70
|
+
expect(resolution.incompatibleTables).toEqual([b]);
|
|
71
|
+
expect(resolution.newTableValues).toEqual({ sourceMetadata: null });
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('returns an empty set when nothing matches', async () => {
|
|
75
|
+
const resolution = await defaultSourceTableReconciler({
|
|
76
|
+
source: descriptor({ objectId: 200 }),
|
|
77
|
+
candidates: [candidate({ objectId: 100 })]
|
|
78
|
+
});
|
|
79
|
+
expect(resolution.compatibleTables).toHaveLength(0);
|
|
80
|
+
expect(resolution.incompatibleTables.map((table) => table.id)).toEqual(['table-1']);
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe('validateSourceTableCandidateResolution', () => {
|
|
85
|
+
it('accepts an explicit compatible/incompatible partition', () => {
|
|
86
|
+
const a = candidate({ id: 'a' });
|
|
87
|
+
const b = candidate({ id: 'b' });
|
|
88
|
+
expect(() =>
|
|
89
|
+
validateSourceTableCandidateResolution([a, b], {
|
|
90
|
+
compatibleTables: [a],
|
|
91
|
+
incompatibleTables: [b],
|
|
92
|
+
newTableValues: { sourceMetadata: null }
|
|
93
|
+
})
|
|
94
|
+
).not.toThrow();
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('rejects omitted, duplicate, and unknown candidates', () => {
|
|
98
|
+
const a = candidate({ id: 'a' });
|
|
99
|
+
expect(() =>
|
|
100
|
+
validateSourceTableCandidateResolution([a], {
|
|
101
|
+
compatibleTables: [],
|
|
102
|
+
incompatibleTables: [],
|
|
103
|
+
newTableValues: { sourceMetadata: null }
|
|
104
|
+
})
|
|
105
|
+
).toThrow(/exactly once/);
|
|
106
|
+
expect(() =>
|
|
107
|
+
validateSourceTableCandidateResolution([a], {
|
|
108
|
+
compatibleTables: [a],
|
|
109
|
+
incompatibleTables: [a],
|
|
110
|
+
newTableValues: { sourceMetadata: null }
|
|
111
|
+
})
|
|
112
|
+
).toThrow(/exactly once/);
|
|
113
|
+
expect(() =>
|
|
114
|
+
validateSourceTableCandidateResolution([a], {
|
|
115
|
+
compatibleTables: [a],
|
|
116
|
+
incompatibleTables: [candidate({ id: 'unknown' })],
|
|
117
|
+
newTableValues: { sourceMetadata: null }
|
|
118
|
+
})
|
|
119
|
+
).toThrow(/unknown candidate/);
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
describe('diffSourceTableUpdates', () => {
|
|
124
|
+
function resolution(compatibleTables: SourceTable[]) {
|
|
125
|
+
return { compatibleTables, incompatibleTables: [], newTableValues: { sourceMetadata: null } };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
it('returns nothing when the reconciler returned the candidates untouched', () => {
|
|
129
|
+
const a = candidate({ id: 'a', sourceMetadata: { captureTableObjectId: 7 } });
|
|
130
|
+
expect(diffSourceTableUpdates([a], resolution([a]))).toEqual([]);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('compares by value, not by reference', () => {
|
|
134
|
+
// A new object with equal metadata should not cause a write.
|
|
135
|
+
const a = candidate({ id: 'a', sourceMetadata: { captureTableObjectId: 7 } });
|
|
136
|
+
const rebuilt = a.withSourceMetadata({ captureTableObjectId: 7 });
|
|
137
|
+
|
|
138
|
+
expect(rebuilt.sourceMetadata).not.toBe(a.sourceMetadata);
|
|
139
|
+
expect(diffSourceTableUpdates([a], resolution([rebuilt]))).toEqual([]);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('returns changed metadata', () => {
|
|
143
|
+
const a = candidate({ id: 'a', sourceMetadata: { captureTableObjectId: 7 } });
|
|
144
|
+
expect(diffSourceTableUpdates([a], resolution([a.withSourceMetadata({ captureTableObjectId: 8 })]))).toEqual([
|
|
145
|
+
{ id: 'a', sourceMetadata: { captureTableObjectId: 8 } }
|
|
146
|
+
]);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('returns metadata added to a legacy record', () => {
|
|
150
|
+
const legacy = candidate({ id: 'a' });
|
|
151
|
+
expect(legacy.sourceMetadata).toBeNull();
|
|
152
|
+
expect(
|
|
153
|
+
diffSourceTableUpdates([legacy], resolution([legacy.withSourceMetadata({ captureTableObjectId: 7 })]))
|
|
154
|
+
).toEqual([{ id: 'a', sourceMetadata: { captureTableObjectId: 7 } }]);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('returns cleared metadata as null', () => {
|
|
158
|
+
const a = candidate({ id: 'a', sourceMetadata: { captureTableObjectId: 7 } });
|
|
159
|
+
expect(diffSourceTableUpdates([a], resolution([a.withSourceMetadata(null)]))).toEqual([
|
|
160
|
+
{ id: 'a', sourceMetadata: null }
|
|
161
|
+
]);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('materializes only source metadata from reconciler results', () => {
|
|
165
|
+
const persisted = candidate({ id: 'a', snapshotComplete: false });
|
|
166
|
+
const modified = persisted.withSourceMetadata({ captureTableObjectId: 8 });
|
|
167
|
+
modified.snapshotComplete = true;
|
|
168
|
+
modified.syncData = false;
|
|
169
|
+
|
|
170
|
+
const [materialized] = materializeSourceTableResolution([persisted], resolution([modified])).compatibleTables;
|
|
171
|
+
|
|
172
|
+
expect(materialized.sourceMetadata).toEqual({ captureTableObjectId: 8 });
|
|
173
|
+
expect(materialized.snapshotComplete).toBe(false);
|
|
174
|
+
expect(materialized.syncData).toBe(persisted.syncData);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('does not trust incompatible candidate mutations made through a cast', () => {
|
|
178
|
+
const persisted = candidate({ id: 'a', snapshotComplete: false });
|
|
179
|
+
const exposed = persisted.clone();
|
|
180
|
+
// Simulate callback code bypassing the read-only type boundary.
|
|
181
|
+
(exposed as any).snapshotComplete = true;
|
|
182
|
+
|
|
183
|
+
const materialized = materializeSourceTableResolution([persisted], {
|
|
184
|
+
compatibleTables: [],
|
|
185
|
+
incompatibleTables: [exposed],
|
|
186
|
+
newTableValues: { sourceMetadata: null }
|
|
187
|
+
}).incompatibleTables[0];
|
|
188
|
+
|
|
189
|
+
expect(exposed.snapshotComplete).toBe(true);
|
|
190
|
+
expect(materialized).toBe(persisted);
|
|
191
|
+
expect(materialized.snapshotComplete).toBe(false);
|
|
192
|
+
expect(materialized.syncData).toBe(persisted.syncData);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it('only reports the candidates that changed', () => {
|
|
196
|
+
const a = candidate({ id: 'a', sourceMetadata: { captureTableObjectId: 7 } });
|
|
197
|
+
const b = candidate({ id: 'b', sourceMetadata: { captureTableObjectId: 7 } });
|
|
198
|
+
const updates = diffSourceTableUpdates([a, b], resolution([a, b.withSourceMetadata({ captureTableObjectId: 9 })]));
|
|
199
|
+
expect(updates).toEqual([{ id: 'b', sourceMetadata: { captureTableObjectId: 9 } }]);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it('rejects a compatible table that was never a candidate', () => {
|
|
203
|
+
expect(() => diffSourceTableUpdates([candidate({ id: 'a' })], resolution([candidate({ id: 'ghost' })]))).toThrow(
|
|
204
|
+
/unknown candidate/
|
|
205
|
+
);
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
describe('SourceTable.clone', () => {
|
|
210
|
+
it('isolates mutable identity and metadata values', () => {
|
|
211
|
+
const persisted = candidate({ id: 'a', sourceMetadata: { captureTableObjectId: 7 } });
|
|
212
|
+
const exposed = persisted.clone();
|
|
213
|
+
|
|
214
|
+
(exposed.ref as any).name = 'changed';
|
|
215
|
+
exposed.replicaIdColumns[0].name = 'changed';
|
|
216
|
+
(exposed.sourceMetadata as any).captureTableObjectId = 8;
|
|
217
|
+
|
|
218
|
+
expect(persisted.name).toBe('users');
|
|
219
|
+
expect(persisted.replicaIdColumns[0].name).toBe('id');
|
|
220
|
+
expect(persisted.sourceMetadata).toEqual({ captureTableObjectId: 7 });
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
describe('sourceTableIdEquals', () => {
|
|
225
|
+
it('compares string ids by value', () => {
|
|
226
|
+
expect(sourceTableIdEquals('table-1', 'table-1')).toBe(true);
|
|
227
|
+
expect(sourceTableIdEquals('table-1', 'table-2')).toBe(false);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it('compares BSON ObjectIds by value', () => {
|
|
231
|
+
const id = new bson.ObjectId();
|
|
232
|
+
const copy = new bson.ObjectId(id.toHexString());
|
|
233
|
+
|
|
234
|
+
expect(id).not.toBe(copy);
|
|
235
|
+
expect(sourceTableIdEquals(id, copy)).toBe(true);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it('does not mix string and BSON ObjectId representations', () => {
|
|
239
|
+
const id = new bson.ObjectId();
|
|
240
|
+
|
|
241
|
+
expect(sourceTableIdEquals(id.toHexString(), id)).toBe(false);
|
|
242
|
+
expect(sourceTableIdEquals(id, id.toHexString())).toBe(false);
|
|
243
|
+
});
|
|
244
|
+
});
|