@syncular/client 0.0.6-136 → 0.0.6-139
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/dist/client.d.ts +8 -8
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +6 -20
- package/dist/client.js.map +1 -1
- package/dist/create-client.d.ts +5 -4
- package/dist/create-client.d.ts.map +1 -1
- package/dist/create-client.js.map +1 -1
- package/dist/engine/SyncEngine.d.ts +8 -0
- package/dist/engine/SyncEngine.d.ts.map +1 -1
- package/dist/engine/SyncEngine.js +154 -19
- package/dist/engine/SyncEngine.js.map +1 -1
- package/dist/engine/types.d.ts +23 -5
- package/dist/engine/types.d.ts.map +1 -1
- package/dist/handlers/types.d.ts +9 -0
- package/dist/handlers/types.d.ts.map +1 -1
- package/dist/pull-engine.d.ts.map +1 -1
- package/dist/pull-engine.js +9 -1
- package/dist/pull-engine.js.map +1 -1
- package/dist/push-engine.d.ts +2 -0
- package/dist/push-engine.d.ts.map +1 -1
- package/dist/push-engine.js +52 -3
- package/dist/push-engine.js.map +1 -1
- package/dist/sync-loop.d.ts +2 -0
- package/dist/sync-loop.d.ts.map +1 -1
- package/dist/sync-loop.js +48 -2
- package/dist/sync-loop.js.map +1 -1
- package/package.json +3 -3
- package/src/client.test.ts +43 -6
- package/src/client.ts +15 -27
- package/src/create-client.ts +5 -4
- package/src/engine/SyncEngine.test.ts +103 -4
- package/src/engine/SyncEngine.ts +207 -21
- package/src/engine/types.ts +26 -4
- package/src/handlers/types.ts +9 -0
- package/src/pull-engine.test.ts +94 -0
- package/src/pull-engine.ts +12 -1
- package/src/push-engine.ts +67 -3
- package/src/sync-loop.ts +70 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@syncular/client",
|
|
3
|
-
"version": "0.0.6-
|
|
3
|
+
"version": "0.0.6-139",
|
|
4
4
|
"description": "Client-side sync engine with offline-first support, outbox, and conflict resolution",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Benjamin Kniffler",
|
|
@@ -46,8 +46,8 @@
|
|
|
46
46
|
"release": "bunx syncular-publish"
|
|
47
47
|
},
|
|
48
48
|
"dependencies": {
|
|
49
|
-
"@syncular/core": "0.0.6-
|
|
50
|
-
"@syncular/transport-http": "0.0.6-
|
|
49
|
+
"@syncular/core": "0.0.6-139",
|
|
50
|
+
"@syncular/transport-http": "0.0.6-139"
|
|
51
51
|
},
|
|
52
52
|
"peerDependencies": {
|
|
53
53
|
"kysely": "*"
|
package/src/client.test.ts
CHANGED
|
@@ -141,13 +141,13 @@ describe('Client conflict events', () => {
|
|
|
141
141
|
}
|
|
142
142
|
|
|
143
143
|
async function runConflictCheck(
|
|
144
|
-
|
|
144
|
+
engineInstance: SyncEngine<TestDb>
|
|
145
145
|
): Promise<void> {
|
|
146
|
-
const checker = Reflect.get(
|
|
146
|
+
const checker = Reflect.get(engineInstance, 'emitNewConflicts');
|
|
147
147
|
if (typeof checker !== 'function') {
|
|
148
|
-
throw new Error('Expected
|
|
148
|
+
throw new Error('Expected emitNewConflicts to be callable');
|
|
149
149
|
}
|
|
150
|
-
await checker.call(
|
|
150
|
+
await checker.call(engineInstance);
|
|
151
151
|
}
|
|
152
152
|
|
|
153
153
|
beforeEach(async () => {
|
|
@@ -184,6 +184,11 @@ describe('Client conflict events', () => {
|
|
|
184
184
|
subscriptions: [],
|
|
185
185
|
});
|
|
186
186
|
Reflect.set(client, 'engine', engine);
|
|
187
|
+
const wireEngineEvents = Reflect.get(client, 'wireEngineEvents');
|
|
188
|
+
if (typeof wireEngineEvents !== 'function') {
|
|
189
|
+
throw new Error('Expected wireEngineEvents to be callable');
|
|
190
|
+
}
|
|
191
|
+
wireEngineEvents.call(client);
|
|
187
192
|
});
|
|
188
193
|
|
|
189
194
|
afterEach(async () => {
|
|
@@ -220,11 +225,43 @@ describe('Client conflict events', () => {
|
|
|
220
225
|
newEvents.push(conflict.id);
|
|
221
226
|
});
|
|
222
227
|
|
|
223
|
-
await runConflictCheck(
|
|
224
|
-
await runConflictCheck(
|
|
228
|
+
await runConflictCheck(engine);
|
|
229
|
+
await runConflictCheck(engine);
|
|
225
230
|
|
|
226
231
|
expect(newEvents).toEqual(['conflict-1']);
|
|
227
232
|
});
|
|
233
|
+
|
|
234
|
+
it('forwards push:result events from the engine', () => {
|
|
235
|
+
const pushResults: Array<{ clientCommitId: string; status: string }> = [];
|
|
236
|
+
client.on('push:result', (result) => {
|
|
237
|
+
pushResults.push({
|
|
238
|
+
clientCommitId: result.clientCommitId,
|
|
239
|
+
status: result.status,
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
const emit = Reflect.get(engine, 'emit');
|
|
244
|
+
if (typeof emit !== 'function') {
|
|
245
|
+
throw new Error('Expected SyncEngine.emit to be callable');
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
emit.call(engine, 'push:result', {
|
|
249
|
+
outboxCommitId: 'outbox-1',
|
|
250
|
+
clientCommitId: 'commit-1',
|
|
251
|
+
status: 'rejected',
|
|
252
|
+
commitSeq: null,
|
|
253
|
+
results: [],
|
|
254
|
+
errorCode: 'CONFLICT',
|
|
255
|
+
timestamp: Date.now(),
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
expect(pushResults).toEqual([
|
|
259
|
+
{
|
|
260
|
+
clientCommitId: 'commit-1',
|
|
261
|
+
status: 'rejected',
|
|
262
|
+
},
|
|
263
|
+
]);
|
|
264
|
+
});
|
|
228
265
|
});
|
|
229
266
|
|
|
230
267
|
describe('Client blob upload queue recovery', () => {
|
package/src/client.ts
CHANGED
|
@@ -24,6 +24,7 @@ import type {
|
|
|
24
24
|
ConflictInfo,
|
|
25
25
|
OutboxStats,
|
|
26
26
|
PresenceEntry,
|
|
27
|
+
PushResultInfo,
|
|
27
28
|
SubscriptionProgress,
|
|
28
29
|
SyncAwaitBootstrapOptions,
|
|
29
30
|
SyncAwaitPhaseOptions,
|
|
@@ -122,21 +123,22 @@ export interface ClientOptions<DB extends SyncClientDb> {
|
|
|
122
123
|
|
|
123
124
|
/**
|
|
124
125
|
* Optional: Debounce window (ms) for coalescing `data:change` events.
|
|
125
|
-
* -
|
|
126
|
+
* - default: `10`
|
|
127
|
+
* - `0`/`false`: emit immediately (disable debounce)
|
|
126
128
|
* - `>0`: merge scopes and emit once per window
|
|
127
129
|
*/
|
|
128
|
-
dataChangeDebounceMs?: number;
|
|
130
|
+
dataChangeDebounceMs?: number | false;
|
|
129
131
|
/**
|
|
130
132
|
* Optional: Debounce override while sync is actively running.
|
|
131
133
|
* Falls back to `dataChangeDebounceMs` when omitted.
|
|
132
134
|
*/
|
|
133
|
-
dataChangeDebounceMsWhenSyncing?: number;
|
|
135
|
+
dataChangeDebounceMsWhenSyncing?: number | false;
|
|
134
136
|
/**
|
|
135
137
|
* Optional: Debounce override while connection is reconnecting.
|
|
136
138
|
* Falls back to `dataChangeDebounceMsWhenSyncing` (if syncing) and then
|
|
137
139
|
* `dataChangeDebounceMs` when omitted.
|
|
138
140
|
*/
|
|
139
|
-
dataChangeDebounceMsWhenReconnecting?: number;
|
|
141
|
+
dataChangeDebounceMsWhenReconnecting?: number | false;
|
|
140
142
|
|
|
141
143
|
/** Optional: State ID for multi-tenant scenarios */
|
|
142
144
|
stateId?: string;
|
|
@@ -249,6 +251,7 @@ type ClientEventType =
|
|
|
249
251
|
| 'sync:complete'
|
|
250
252
|
| 'sync:live'
|
|
251
253
|
| 'sync:error'
|
|
254
|
+
| 'push:result'
|
|
252
255
|
| 'bootstrap:start'
|
|
253
256
|
| 'bootstrap:progress'
|
|
254
257
|
| 'bootstrap:complete'
|
|
@@ -266,6 +269,7 @@ type ClientEventPayloads = {
|
|
|
266
269
|
'sync:complete': SyncResult;
|
|
267
270
|
'sync:live': { timestamp: number };
|
|
268
271
|
'sync:error': { code: string; message: string };
|
|
272
|
+
'push:result': PushResultInfo;
|
|
269
273
|
'bootstrap:start': {
|
|
270
274
|
timestamp: number;
|
|
271
275
|
stateId: string;
|
|
@@ -332,7 +336,6 @@ export class Client<DB extends SyncClientDb = SyncClientDb> {
|
|
|
332
336
|
private engine: SyncEngine<DB> | null = null;
|
|
333
337
|
private started = false;
|
|
334
338
|
private destroyed = false;
|
|
335
|
-
private emittedConflictIds = new Set<string>();
|
|
336
339
|
private eventListeners = new Map<
|
|
337
340
|
ClientEventType,
|
|
338
341
|
Set<ClientEventHandler<any>>
|
|
@@ -758,7 +761,6 @@ export class Client<DB extends SyncClientDb = SyncClientDb> {
|
|
|
758
761
|
},
|
|
759
762
|
});
|
|
760
763
|
|
|
761
|
-
this.emittedConflictIds.delete(id);
|
|
762
764
|
if (resolvedConflict) {
|
|
763
765
|
this.emit('conflict:resolved', resolvedConflict);
|
|
764
766
|
}
|
|
@@ -975,9 +977,14 @@ export class Client<DB extends SyncClientDb = SyncClientDb> {
|
|
|
975
977
|
|
|
976
978
|
this.engine.on('sync:error', (error) => {
|
|
977
979
|
this.emit('sync:error', { code: error.code, message: error.message });
|
|
980
|
+
});
|
|
981
|
+
|
|
982
|
+
this.engine.on('push:result', (payload) => {
|
|
983
|
+
this.emit('push:result', payload);
|
|
984
|
+
});
|
|
978
985
|
|
|
979
|
-
|
|
980
|
-
this.
|
|
986
|
+
this.engine.on('conflict:new', (payload) => {
|
|
987
|
+
this.emit('conflict:new', this.mapConflictInfo(payload));
|
|
981
988
|
});
|
|
982
989
|
|
|
983
990
|
this.engine.on('bootstrap:start', (payload) => {
|
|
@@ -1020,25 +1027,6 @@ export class Client<DB extends SyncClientDb = SyncClientDb> {
|
|
|
1020
1027
|
});
|
|
1021
1028
|
}
|
|
1022
1029
|
|
|
1023
|
-
private async checkForNewConflicts(): Promise<void> {
|
|
1024
|
-
const conflicts = await this.getConflicts();
|
|
1025
|
-
const activeIds = new Set(conflicts.map((conflict) => conflict.id));
|
|
1026
|
-
|
|
1027
|
-
for (const id of this.emittedConflictIds) {
|
|
1028
|
-
if (!activeIds.has(id)) {
|
|
1029
|
-
this.emittedConflictIds.delete(id);
|
|
1030
|
-
}
|
|
1031
|
-
}
|
|
1032
|
-
|
|
1033
|
-
for (const conflict of conflicts) {
|
|
1034
|
-
if (this.emittedConflictIds.has(conflict.id)) {
|
|
1035
|
-
continue;
|
|
1036
|
-
}
|
|
1037
|
-
this.emittedConflictIds.add(conflict.id);
|
|
1038
|
-
this.emit('conflict:new', conflict);
|
|
1039
|
-
}
|
|
1040
|
-
}
|
|
1041
|
-
|
|
1042
1030
|
private mapConflictInfo(info: ConflictInfo): Conflict {
|
|
1043
1031
|
let serverPayload: Record<string, unknown> | null = null;
|
|
1044
1032
|
if (info.serverRowJson) {
|
package/src/create-client.ts
CHANGED
|
@@ -143,21 +143,22 @@ interface CreateClientOptions<DB extends SyncClientDb> {
|
|
|
143
143
|
pollIntervalMs?: number;
|
|
144
144
|
/**
|
|
145
145
|
* Debounce window (ms) for coalescing `data:change` events.
|
|
146
|
-
* -
|
|
146
|
+
* - default: `10`
|
|
147
|
+
* - `0`/`false`: emit immediately (disable debounce)
|
|
147
148
|
* - `>0`: merge scopes and emit once per window
|
|
148
149
|
*/
|
|
149
|
-
dataChangeDebounceMs?: number;
|
|
150
|
+
dataChangeDebounceMs?: number | false;
|
|
150
151
|
/**
|
|
151
152
|
* Debounce override while sync is actively running.
|
|
152
153
|
* Falls back to `dataChangeDebounceMs` when omitted.
|
|
153
154
|
*/
|
|
154
|
-
dataChangeDebounceMsWhenSyncing?: number;
|
|
155
|
+
dataChangeDebounceMsWhenSyncing?: number | false;
|
|
155
156
|
/**
|
|
156
157
|
* Debounce override while connection is reconnecting.
|
|
157
158
|
* Falls back to `dataChangeDebounceMsWhenSyncing` (if syncing) and then
|
|
158
159
|
* `dataChangeDebounceMs` when omitted.
|
|
159
160
|
*/
|
|
160
|
-
dataChangeDebounceMsWhenReconnecting?: number;
|
|
161
|
+
dataChangeDebounceMsWhenReconnecting?: number | false;
|
|
161
162
|
};
|
|
162
163
|
|
|
163
164
|
/** Optional: Local blob storage adapter */
|
|
@@ -273,6 +273,89 @@ describe('SyncEngine WS inline apply', () => {
|
|
|
273
273
|
expect(onDataChangeCalls).toEqual([['tasks']]);
|
|
274
274
|
});
|
|
275
275
|
|
|
276
|
+
it('uses 10ms debounce by default and supports 0/false opt-out', async () => {
|
|
277
|
+
const handlers: ClientHandlerCollection<TestDb> = [
|
|
278
|
+
{
|
|
279
|
+
table: 'tasks',
|
|
280
|
+
async applySnapshot() {},
|
|
281
|
+
async clearAll() {},
|
|
282
|
+
async applyChange() {},
|
|
283
|
+
},
|
|
284
|
+
];
|
|
285
|
+
|
|
286
|
+
const defaultEngine = new SyncEngine<TestDb>({
|
|
287
|
+
db,
|
|
288
|
+
transport: noopTransport,
|
|
289
|
+
handlers,
|
|
290
|
+
actorId: 'u1',
|
|
291
|
+
clientId: 'client-default-debounce',
|
|
292
|
+
subscriptions: [],
|
|
293
|
+
stateId: 'default',
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
const defaultEvents: string[][] = [];
|
|
297
|
+
defaultEngine.on('data:change', (payload) => {
|
|
298
|
+
defaultEvents.push(payload.scopes);
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
defaultEngine.recordLocalMutations([
|
|
302
|
+
{ table: 'tasks', rowId: 'd1', op: 'upsert' },
|
|
303
|
+
]);
|
|
304
|
+
defaultEngine.recordLocalMutations([
|
|
305
|
+
{ table: 'tasks', rowId: 'd2', op: 'upsert' },
|
|
306
|
+
]);
|
|
307
|
+
expect(defaultEvents).toEqual([]);
|
|
308
|
+
|
|
309
|
+
await new Promise<void>((resolve) => setTimeout(resolve, 20));
|
|
310
|
+
expect(defaultEvents).toEqual([['tasks']]);
|
|
311
|
+
|
|
312
|
+
const noDebounceEngine = new SyncEngine<TestDb>({
|
|
313
|
+
db,
|
|
314
|
+
transport: noopTransport,
|
|
315
|
+
handlers,
|
|
316
|
+
actorId: 'u1',
|
|
317
|
+
clientId: 'client-no-debounce',
|
|
318
|
+
subscriptions: [],
|
|
319
|
+
stateId: 'default',
|
|
320
|
+
dataChangeDebounceMs: false,
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
const immediateEvents: string[][] = [];
|
|
324
|
+
noDebounceEngine.on('data:change', (payload) => {
|
|
325
|
+
immediateEvents.push(payload.scopes);
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
noDebounceEngine.recordLocalMutations([
|
|
329
|
+
{ table: 'tasks', rowId: 'n1', op: 'upsert' },
|
|
330
|
+
]);
|
|
331
|
+
expect(immediateEvents).toEqual([['tasks']]);
|
|
332
|
+
|
|
333
|
+
const zeroDebounceEngine = new SyncEngine<TestDb>({
|
|
334
|
+
db,
|
|
335
|
+
transport: noopTransport,
|
|
336
|
+
handlers,
|
|
337
|
+
actorId: 'u1',
|
|
338
|
+
clientId: 'client-zero-debounce',
|
|
339
|
+
subscriptions: [],
|
|
340
|
+
stateId: 'default',
|
|
341
|
+
dataChangeDebounceMs: 0,
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
const zeroEvents: string[][] = [];
|
|
345
|
+
zeroDebounceEngine.on('data:change', (payload) => {
|
|
346
|
+
zeroEvents.push(payload.scopes);
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
zeroDebounceEngine.recordLocalMutations([
|
|
350
|
+
{ table: 'tasks', rowId: 'z1', op: 'upsert' },
|
|
351
|
+
]);
|
|
352
|
+
expect(zeroEvents).toEqual([['tasks']]);
|
|
353
|
+
|
|
354
|
+
defaultEngine.destroy();
|
|
355
|
+
noDebounceEngine.destroy();
|
|
356
|
+
zeroDebounceEngine.destroy();
|
|
357
|
+
});
|
|
358
|
+
|
|
276
359
|
it('supports adaptive debounce overrides while syncing and reconnecting', async () => {
|
|
277
360
|
const handlers: ClientHandlerCollection<TestDb> = [
|
|
278
361
|
{
|
|
@@ -301,6 +384,11 @@ describe('SyncEngine WS inline apply', () => {
|
|
|
301
384
|
eventScopes.push(payload.scopes);
|
|
302
385
|
});
|
|
303
386
|
|
|
387
|
+
const setConnectionState = Reflect.get(engine, 'setConnectionState');
|
|
388
|
+
if (typeof setConnectionState !== 'function') {
|
|
389
|
+
throw new Error('Expected setConnectionState to be callable');
|
|
390
|
+
}
|
|
391
|
+
|
|
304
392
|
const updateState = Reflect.get(engine, 'updateState');
|
|
305
393
|
if (typeof updateState !== 'function') {
|
|
306
394
|
throw new Error('Expected updateState to be callable');
|
|
@@ -320,17 +408,28 @@ describe('SyncEngine WS inline apply', () => {
|
|
|
320
408
|
expect(eventScopes).toEqual([['tasks']]);
|
|
321
409
|
|
|
322
410
|
updateState.call(engine, {
|
|
323
|
-
isSyncing:
|
|
324
|
-
connectionState: '
|
|
411
|
+
isSyncing: false,
|
|
412
|
+
connectionState: 'connected',
|
|
325
413
|
});
|
|
414
|
+
setConnectionState.call(engine, 'reconnecting');
|
|
326
415
|
|
|
327
416
|
engine.recordLocalMutations([
|
|
328
417
|
{ table: 'tasks', rowId: 'reconnecting-1', op: 'upsert' },
|
|
329
418
|
]);
|
|
330
|
-
await new Promise<void>((resolve) => setTimeout(resolve,
|
|
419
|
+
await new Promise<void>((resolve) => setTimeout(resolve, 50));
|
|
331
420
|
expect(eventScopes).toEqual([['tasks']]);
|
|
332
421
|
|
|
333
|
-
|
|
422
|
+
setConnectionState.call(engine, 'connected');
|
|
423
|
+
const flushReconnectBatch = Reflect.get(
|
|
424
|
+
engine,
|
|
425
|
+
'flushReconnectBatchedDataChangesIfReady'
|
|
426
|
+
);
|
|
427
|
+
if (typeof flushReconnectBatch !== 'function') {
|
|
428
|
+
throw new Error(
|
|
429
|
+
'Expected flushReconnectBatchedDataChangesIfReady to be callable'
|
|
430
|
+
);
|
|
431
|
+
}
|
|
432
|
+
flushReconnectBatch.call(engine);
|
|
334
433
|
expect(eventScopes).toEqual([['tasks'], ['tasks']]);
|
|
335
434
|
});
|
|
336
435
|
|