@powersync/service-core 1.23.3 → 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 +45 -0
- package/dist/api/RouteAPI.d.ts +17 -3
- package/dist/entry/commands/compact-action.js +4 -0
- package/dist/entry/commands/compact-action.js.map +1 -1
- package/dist/replication/AbstractReplicator.d.ts +9 -4
- package/dist/replication/AbstractReplicator.js +35 -11
- package/dist/replication/AbstractReplicator.js.map +1 -1
- package/dist/routes/configure-fastify.d.ts +31 -0
- package/dist/routes/endpoints/checkpointing.d.ts +62 -0
- package/dist/routes/endpoints/checkpointing.js +63 -3
- package/dist/routes/endpoints/checkpointing.js.map +1 -1
- package/dist/storage/BucketStorageBatch.d.ts +4 -1
- package/dist/storage/BucketStorageBatch.js.map +1 -1
- package/dist/storage/BucketStorageFactory.d.ts +2 -2
- package/dist/storage/CheckpointChecksumInvalidatedError.d.ts +12 -0
- package/dist/storage/CheckpointChecksumInvalidatedError.js +17 -0
- package/dist/storage/CheckpointChecksumInvalidatedError.js.map +1 -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 +32 -1
- package/dist/storage/SyncRulesBucketStorage.js +3 -0
- package/dist/storage/SyncRulesBucketStorage.js.map +1 -1
- package/dist/storage/WriteCheckpointAPI.d.ts +60 -3
- package/dist/storage/WriteCheckpointAPI.js +33 -0
- package/dist/storage/WriteCheckpointAPI.js.map +1 -1
- package/dist/storage/implementation/BucketDefinitionMapping.d.ts +4 -4
- package/dist/storage/implementation/BucketDefinitionMapping.js.map +1 -1
- package/dist/storage/storage-index.d.ts +2 -0
- package/dist/storage/storage-index.js +2 -0
- package/dist/storage/storage-index.js.map +1 -1
- package/dist/sync/BucketChecksumState.d.ts +7 -0
- package/dist/sync/BucketChecksumState.js +33 -13
- package/dist/sync/BucketChecksumState.js.map +1 -1
- package/dist/sync/sync.js +34 -6
- package/dist/sync/sync.js.map +1 -1
- package/dist/sync/util.d.ts +5 -0
- package/dist/sync/util.js +9 -0
- package/dist/sync/util.js.map +1 -1
- package/dist/util/checkpointing.d.ts +1 -0
- package/dist/util/checkpointing.js +1 -1
- package/dist/util/checkpointing.js.map +1 -1
- package/dist/util/config/compound-config-collector.js +9 -1
- package/dist/util/config/compound-config-collector.js.map +1 -1
- package/dist/util/config/defaults.d.ts +1 -0
- package/dist/util/config/defaults.js +1 -0
- package/dist/util/config/defaults.js.map +1 -1
- package/dist/util/config/types.d.ts +1 -0
- package/dist/util/utils.d.ts +1 -1
- package/dist/util/utils.js +1 -1
- package/dist/util/utils.js.map +1 -1
- package/dist/util/write-checkpoint-batcher.d.ts +1 -1
- package/dist/util/write-checkpoint-batcher.js +18 -8
- package/dist/util/write-checkpoint-batcher.js.map +1 -1
- package/package.json +5 -5
- package/src/api/RouteAPI.ts +18 -3
- package/src/entry/commands/compact-action.ts +6 -0
- package/src/replication/AbstractReplicator.ts +42 -12
- package/src/routes/endpoints/checkpointing.ts +77 -3
- package/src/storage/BucketStorageBatch.ts +4 -1
- package/src/storage/BucketStorageFactory.ts +2 -2
- package/src/storage/CheckpointChecksumInvalidatedError.ts +17 -0
- 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 +38 -1
- package/src/storage/WriteCheckpointAPI.ts +108 -3
- package/src/storage/implementation/BucketDefinitionMapping.ts +4 -4
- package/src/storage/storage-index.ts +2 -0
- package/src/sync/BucketChecksumState.ts +38 -13
- package/src/sync/sync.ts +33 -7
- package/src/sync/util.ts +12 -0
- package/src/util/checkpointing.ts +2 -1
- package/src/util/config/compound-config-collector.ts +12 -0
- package/src/util/config/defaults.ts +1 -0
- package/src/util/config/types.ts +1 -0
- package/src/util/utils.ts +1 -1
- package/src/util/write-checkpoint-batcher.ts +30 -10
- package/test/src/AbstractReplicator.test.ts +147 -0
- package/test/src/config.test.ts +60 -0
- package/test/src/routes/checkpointing.test.ts +54 -0
- package/test/src/source-table-reconciler.test.ts +244 -0
- package/test/src/sync/BucketChecksumState.test.ts +57 -11
- package/test/src/sync/util.test.ts +13 -1
- package/test/src/util/checkpointing.test.ts +99 -20
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -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
|
+
});
|
|
@@ -1183,7 +1183,7 @@ streams:
|
|
|
1183
1183
|
).rejects.toThrow('Too many buckets: 60 (limit of 50');
|
|
1184
1184
|
|
|
1185
1185
|
// Verify error log includes breakdown
|
|
1186
|
-
expect(errorMessages[0]).toContain('
|
|
1186
|
+
expect(errorMessages[0]).toContain('(limit of 50)\nBuckets by sync stream:');
|
|
1187
1187
|
expect(errorMessages[0]).toContain('projects: 30');
|
|
1188
1188
|
expect(errorMessages[0]).toContain('tasks: 20');
|
|
1189
1189
|
expect(errorMessages[0]).toContain('comments: 10');
|
|
@@ -1197,15 +1197,60 @@ streams:
|
|
|
1197
1197
|
});
|
|
1198
1198
|
});
|
|
1199
1199
|
|
|
1200
|
-
test('
|
|
1201
|
-
|
|
1200
|
+
test('labels the bucket breakdown as bucket definitions for legacy sync rules', async () => {
|
|
1201
|
+
const legacySyncRules = SqlSyncRules.fromYaml(
|
|
1202
|
+
`
|
|
1203
|
+
bucket_definitions:
|
|
1204
|
+
projects:
|
|
1205
|
+
parameters: SELECT id FROM projects WHERE user_id = request.user_id()
|
|
1206
|
+
data: []
|
|
1207
|
+
`,
|
|
1208
|
+
{ defaultSchema: 'public' }
|
|
1209
|
+
).config.hydrate({ hydrationState: versionedHydrationState(5), sqlite: nodeSqlite(sqlite) });
|
|
1210
|
+
|
|
1211
|
+
const storage = new MockBucketChecksumStateStorage();
|
|
1212
|
+
const errorMessages: string[] = [];
|
|
1213
|
+
const state = new BucketChecksumState({
|
|
1214
|
+
syncContext: new SyncContext({
|
|
1215
|
+
maxBuckets: 50,
|
|
1216
|
+
maxParameterQueryResults: 100,
|
|
1217
|
+
maxDataFetchConcurrency: 10
|
|
1218
|
+
}),
|
|
1219
|
+
tokenPayload: new JwtPayload({ sub: 'u1' }),
|
|
1220
|
+
syncRequest,
|
|
1221
|
+
syncRules: legacySyncRules,
|
|
1222
|
+
bucketStorage: storage,
|
|
1223
|
+
logger: {
|
|
1224
|
+
info: () => {},
|
|
1225
|
+
error: (message: string) => errorMessages.push(message),
|
|
1226
|
+
warn: () => {},
|
|
1227
|
+
debug: () => {}
|
|
1228
|
+
} as any
|
|
1229
|
+
});
|
|
1230
|
+
|
|
1231
|
+
await expect(
|
|
1232
|
+
state.buildNextCheckpointLine({
|
|
1233
|
+
base: storage.makeCheckpoint(1n, (lookups) => [
|
|
1234
|
+
{ lookup: lookups[0], rows: Array.from({ length: 60 }, (_, id) => ({ id })) }
|
|
1235
|
+
]),
|
|
1236
|
+
writeCheckpoint: null,
|
|
1237
|
+
update: CHECKPOINT_INVALIDATE_ALL
|
|
1238
|
+
})
|
|
1239
|
+
).rejects.toThrow('Too many buckets: 60 (limit of 50)');
|
|
1240
|
+
|
|
1241
|
+
expect(errorMessages[0]).toContain('(limit of 50)\nBuckets by bucket definition:');
|
|
1242
|
+
expect(errorMessages[0]).toContain('projects: 60');
|
|
1243
|
+
});
|
|
1244
|
+
|
|
1245
|
+
test('limits bucket breakdown to top 100 sync streams', async () => {
|
|
1246
|
+
// Create 105 sync streams with dynamic parameters.
|
|
1202
1247
|
let yamlDefinitions = `
|
|
1203
1248
|
config:
|
|
1204
1249
|
edition: 3
|
|
1205
1250
|
|
|
1206
1251
|
streams:
|
|
1207
1252
|
`;
|
|
1208
|
-
for (let i = 1; i <=
|
|
1253
|
+
for (let i = 1; i <= 105; i++) {
|
|
1209
1254
|
yamlDefinitions += ` def${i}:\n`;
|
|
1210
1255
|
yamlDefinitions += ` auto_subscribe: true\n`;
|
|
1211
1256
|
yamlDefinitions += ` query: SELECT * FROM tbl WHERE b = auth.parameter('${i}')\n`;
|
|
@@ -1229,7 +1274,7 @@ streams:
|
|
|
1229
1274
|
};
|
|
1230
1275
|
|
|
1231
1276
|
const smallContext = new SyncContext({
|
|
1232
|
-
maxBuckets:
|
|
1277
|
+
maxBuckets: 100,
|
|
1233
1278
|
maxParameterQueryResults: 10,
|
|
1234
1279
|
maxDataFetchConcurrency: 10
|
|
1235
1280
|
});
|
|
@@ -1238,7 +1283,7 @@ streams:
|
|
|
1238
1283
|
syncContext: smallContext,
|
|
1239
1284
|
tokenPayload: new JwtPayload({
|
|
1240
1285
|
sub: 'u1',
|
|
1241
|
-
...Object.fromEntries(Array.from({ length:
|
|
1286
|
+
...Object.fromEntries(Array.from({ length: 106 }, (_, i) => [i.toString(), BigInt(i)]))
|
|
1242
1287
|
}),
|
|
1243
1288
|
syncRequest,
|
|
1244
1289
|
syncRules: SYNC_RULES_MANY,
|
|
@@ -1252,15 +1297,16 @@ streams:
|
|
|
1252
1297
|
writeCheckpoint: null,
|
|
1253
1298
|
update: CHECKPOINT_INVALIDATE_ALL
|
|
1254
1299
|
})
|
|
1255
|
-
).rejects.toThrow('Too many buckets:
|
|
1300
|
+
).rejects.toThrow('Too many buckets: 105 (limit of 100)');
|
|
1256
1301
|
|
|
1257
|
-
// Verify only
|
|
1302
|
+
// Verify only the first 100 are shown.
|
|
1258
1303
|
const errorMessage = errorMessages[0];
|
|
1259
|
-
expect(errorMessage).toContain('
|
|
1304
|
+
expect(errorMessage).toContain('Buckets by sync stream:');
|
|
1305
|
+
expect(errorMessage).toContain('... and 5 more buckets from 5 sync streams');
|
|
1260
1306
|
|
|
1261
|
-
// Count how many
|
|
1307
|
+
// Count how many sync streams are listed (should be exactly 100).
|
|
1262
1308
|
const defMatches = errorMessage.match(/def\d+:/g);
|
|
1263
|
-
expect(defMatches?.length).toBe(
|
|
1309
|
+
expect(defMatches?.length).toBe(100);
|
|
1264
1310
|
});
|
|
1265
1311
|
});
|
|
1266
1312
|
});
|
|
@@ -1,7 +1,19 @@
|
|
|
1
|
-
import { acquireSemaphoreAbortable } from '@/index.js';
|
|
1
|
+
import { acquireSemaphoreAbortable, isAbortError } from '@/index.js';
|
|
2
2
|
import { Semaphore, SemaphoreInterface } from 'async-mutex';
|
|
3
|
+
import { AbortError } from 'ix/aborterror.js';
|
|
3
4
|
import { describe, expect, test, vi } from 'vitest';
|
|
4
5
|
|
|
6
|
+
describe('isAbortError', () => {
|
|
7
|
+
test('recognizes ix and native abort errors', () => {
|
|
8
|
+
const controller = new AbortController();
|
|
9
|
+
controller.abort();
|
|
10
|
+
|
|
11
|
+
expect(isAbortError(new AbortError())).toBe(true);
|
|
12
|
+
expect(isAbortError(controller.signal.reason)).toBe(true);
|
|
13
|
+
expect(isAbortError(new Error('not aborted'))).toBe(false);
|
|
14
|
+
});
|
|
15
|
+
});
|
|
16
|
+
|
|
5
17
|
describe('acquireSemaphoreAbortable', () => {
|
|
6
18
|
test('can acquire', async () => {
|
|
7
19
|
const semaphore = new Semaphore(1);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { createWriteCheckpoint, WriteCheckpointBatcher } from '@/index.js';
|
|
1
|
+
import { createWriteCheckpoint, storage, WriteCheckpointBatcher } from '@/index.js';
|
|
2
2
|
import { describe, expect, test, vi } from 'vitest';
|
|
3
3
|
|
|
4
4
|
function deferred<T = void>() {
|
|
@@ -10,11 +10,14 @@ async function waitForAsyncWork() {
|
|
|
10
10
|
await new Promise((resolve) => setImmediate(resolve));
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
-
function createStorage() {
|
|
13
|
+
function createStorage(options?: { shouldAdvance?: (checkpoint: { user_id: string }) => boolean }) {
|
|
14
14
|
let nextCheckpoint = 1n;
|
|
15
15
|
const bucketStorage = {
|
|
16
16
|
createManagedWriteCheckpoints: vi.fn(async (checkpoints: { user_id: string }[]) => {
|
|
17
|
-
return
|
|
17
|
+
return {
|
|
18
|
+
writeCheckpoints: new Map(checkpoints.map((checkpoint) => [checkpoint.user_id, nextCheckpoint++])),
|
|
19
|
+
shouldAdvance: checkpoints.some((checkpoint) => options?.shouldAdvance?.(checkpoint) ?? true)
|
|
20
|
+
};
|
|
18
21
|
})
|
|
19
22
|
};
|
|
20
23
|
|
|
@@ -25,6 +28,31 @@ function createStorage() {
|
|
|
25
28
|
return { bucketStorage, storage };
|
|
26
29
|
}
|
|
27
30
|
|
|
31
|
+
/**
|
|
32
|
+
* Mock RouteAPI.createReplicationHead. The callback persists the write-checkpoint
|
|
33
|
+
* mapping and returns { response, shouldAdvance }; the source marker (tracked by
|
|
34
|
+
* `advanceMarker`) is only forced when shouldAdvance is true. An optional gate per
|
|
35
|
+
* batch keeps the call in flight so concurrency can be exercised.
|
|
36
|
+
*/
|
|
37
|
+
function createApi(options?: { gates?: PromiseWithResolvers<void>[] }) {
|
|
38
|
+
const advanceMarker = vi.fn(async (_head: string) => undefined);
|
|
39
|
+
const createReplicationHead = vi.fn(
|
|
40
|
+
async (callback: (head: string) => Promise<{ response: unknown; shouldAdvance: boolean }>) => {
|
|
41
|
+
const batch = createReplicationHead.mock.calls.length;
|
|
42
|
+
const head = `head-${batch}`;
|
|
43
|
+
const { response, shouldAdvance } = await callback(head);
|
|
44
|
+
if (shouldAdvance) {
|
|
45
|
+
await advanceMarker(head);
|
|
46
|
+
}
|
|
47
|
+
if (options?.gates) {
|
|
48
|
+
await options.gates[batch - 1].promise;
|
|
49
|
+
}
|
|
50
|
+
return response;
|
|
51
|
+
}
|
|
52
|
+
);
|
|
53
|
+
return { createReplicationHead, advanceMarker };
|
|
54
|
+
}
|
|
55
|
+
|
|
28
56
|
function createBatcher(api: any, storage: any) {
|
|
29
57
|
return new WriteCheckpointBatcher(
|
|
30
58
|
() => api,
|
|
@@ -33,17 +61,25 @@ function createBatcher(api: any, storage: any) {
|
|
|
33
61
|
}
|
|
34
62
|
|
|
35
63
|
describe('write checkpoint batching', () => {
|
|
64
|
+
test('deduplicates managed checkpoints by greatest supplied request id per user', () => {
|
|
65
|
+
expect(
|
|
66
|
+
storage.uniqueManagedWriteCheckpoints([
|
|
67
|
+
{ user_id: 'user-a', heads: { '1': 'generated' } },
|
|
68
|
+
{ user_id: 'user-a', heads: { '1': 'stale' }, checkpoint_request_id: 41n },
|
|
69
|
+
{ user_id: 'user-a', heads: { '1': 'winner' }, checkpoint_request_id: 43n },
|
|
70
|
+
{ user_id: 'user-a', heads: { '1': 'lower' }, checkpoint_request_id: 42n },
|
|
71
|
+
{ user_id: 'user-b', heads: { '1': 'generated-b' } }
|
|
72
|
+
])
|
|
73
|
+
).toEqual([
|
|
74
|
+
{ user_id: 'user-a', heads: { '1': 'winner' }, checkpoint_request_id: 43n },
|
|
75
|
+
{ user_id: 'user-b', heads: { '1': 'generated-b' } }
|
|
76
|
+
]);
|
|
77
|
+
});
|
|
78
|
+
|
|
36
79
|
test('coalesces same-turn requests and dispatches queued requests as capacity becomes available', async () => {
|
|
37
80
|
const gates = [deferred(), deferred()];
|
|
38
81
|
const { bucketStorage, storage } = createStorage();
|
|
39
|
-
const api = {
|
|
40
|
-
createReplicationHead: vi.fn(async (callback: (head: string) => Promise<unknown>) => {
|
|
41
|
-
const batch = api.createReplicationHead.mock.calls.length;
|
|
42
|
-
const result = await callback(`head-${batch}`);
|
|
43
|
-
await gates[batch - 1].promise;
|
|
44
|
-
return result;
|
|
45
|
-
})
|
|
46
|
-
};
|
|
82
|
+
const api = createApi({ gates });
|
|
47
83
|
const batcher = createBatcher(api, storage);
|
|
48
84
|
|
|
49
85
|
const firstBatch = [
|
|
@@ -92,17 +128,58 @@ describe('write checkpoint batching', () => {
|
|
|
92
128
|
]);
|
|
93
129
|
});
|
|
94
130
|
|
|
131
|
+
test('passes supplied checkpoint request ids into the storage batch', async () => {
|
|
132
|
+
const { bucketStorage, storage } = createStorage();
|
|
133
|
+
const api = createApi();
|
|
134
|
+
const batcher = createBatcher(api, storage);
|
|
135
|
+
|
|
136
|
+
await expect(
|
|
137
|
+
createWriteCheckpoint({
|
|
138
|
+
userId: 'user-a',
|
|
139
|
+
clientId: 'client-1',
|
|
140
|
+
checkpointRequestId: 42n,
|
|
141
|
+
batcher
|
|
142
|
+
})
|
|
143
|
+
).resolves.toEqual({ writeCheckpoint: '1', replicationHead: 'head-1' });
|
|
144
|
+
|
|
145
|
+
expect(bucketStorage.createManagedWriteCheckpoints).toHaveBeenCalledWith([
|
|
146
|
+
{
|
|
147
|
+
user_id: 'user-a/client-1',
|
|
148
|
+
heads: { '1': 'head-1' },
|
|
149
|
+
checkpoint_request_id: 42n
|
|
150
|
+
}
|
|
151
|
+
]);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test('does not advance the source marker when storage reports no checkpoint updates', async () => {
|
|
155
|
+
const { storage } = createStorage({ shouldAdvance: () => false });
|
|
156
|
+
const api = createApi();
|
|
157
|
+
const batcher = createBatcher(api, storage);
|
|
158
|
+
|
|
159
|
+
await expect(createWriteCheckpoint({ userId: 'user-a', clientId: undefined, batcher })).resolves.toEqual({
|
|
160
|
+
writeCheckpoint: '1',
|
|
161
|
+
replicationHead: 'head-1'
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
expect(api.createReplicationHead).toHaveBeenCalledTimes(1);
|
|
165
|
+
expect(api.advanceMarker).not.toHaveBeenCalled();
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test('advances the source marker when storage reports a checkpoint update', async () => {
|
|
169
|
+
const { storage } = createStorage();
|
|
170
|
+
const api = createApi();
|
|
171
|
+
const batcher = createBatcher(api, storage);
|
|
172
|
+
|
|
173
|
+
await createWriteCheckpoint({ userId: 'user-a', clientId: undefined, batcher });
|
|
174
|
+
|
|
175
|
+
expect(api.advanceMarker).toHaveBeenCalledTimes(1);
|
|
176
|
+
expect(api.advanceMarker).toHaveBeenCalledWith('head-1');
|
|
177
|
+
});
|
|
178
|
+
|
|
95
179
|
test('allows three executing batches and queues later requests until one completes', async () => {
|
|
96
180
|
const gates = [deferred(), deferred(), deferred(), deferred()];
|
|
97
181
|
const { storage } = createStorage();
|
|
98
|
-
const api = {
|
|
99
|
-
createReplicationHead: vi.fn(async (callback: (head: string) => Promise<unknown>) => {
|
|
100
|
-
const batch = api.createReplicationHead.mock.calls.length;
|
|
101
|
-
const result = await callback(`head-${batch}`);
|
|
102
|
-
await gates[batch - 1].promise;
|
|
103
|
-
return result;
|
|
104
|
-
})
|
|
105
|
-
};
|
|
182
|
+
const api = createApi({ gates });
|
|
106
183
|
const batcher = createBatcher(api, storage);
|
|
107
184
|
|
|
108
185
|
const first = createWriteCheckpoint({
|
|
@@ -160,11 +237,13 @@ describe('write checkpoint batching', () => {
|
|
|
160
237
|
const api = {
|
|
161
238
|
createReplicationHead: vi.fn(async () => {
|
|
162
239
|
throw error;
|
|
163
|
-
})
|
|
240
|
+
}),
|
|
241
|
+
advanceMarker: vi.fn(async () => undefined)
|
|
164
242
|
};
|
|
165
243
|
const batcher = createBatcher(api, storage);
|
|
166
244
|
|
|
167
245
|
await expect(createWriteCheckpoint({ userId: 'user-a', clientId: undefined, batcher })).rejects.toBe(error);
|
|
168
246
|
expect(api.createReplicationHead).toHaveBeenCalledTimes(1);
|
|
247
|
+
expect(api.advanceMarker).not.toHaveBeenCalled();
|
|
169
248
|
});
|
|
170
249
|
});
|