@syncular/client 0.0.6-136 → 0.0.6-138
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 +3 -4
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +6 -20
- package/dist/client.js.map +1 -1
- package/dist/engine/SyncEngine.d.ts +4 -0
- package/dist/engine/SyncEngine.d.ts.map +1 -1
- package/dist/engine/SyncEngine.js +74 -5
- package/dist/engine/SyncEngine.js.map +1 -1
- package/dist/engine/types.d.ts +18 -1
- 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 +10 -23
- package/src/engine/SyncEngine.ts +104 -5
- package/src/engine/types.ts +21 -0
- 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/src/engine/SyncEngine.ts
CHANGED
|
@@ -37,6 +37,7 @@ import type {
|
|
|
37
37
|
ConflictInfo,
|
|
38
38
|
OutboxStats,
|
|
39
39
|
PresenceEntry,
|
|
40
|
+
PushResultInfo,
|
|
40
41
|
RealtimeTransportLike,
|
|
41
42
|
SubscriptionProgress,
|
|
42
43
|
SyncAwaitBootstrapOptions,
|
|
@@ -313,6 +314,7 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
|
|
|
313
314
|
};
|
|
314
315
|
private activeBootstrapSubscriptions = new Set<string>();
|
|
315
316
|
private bootstrapStartedAt = new Map<string, number>();
|
|
317
|
+
private emittedConflictIds = new Set<string>();
|
|
316
318
|
private inspectorEvents: SyncInspectorEvent[] = [];
|
|
317
319
|
private nextInspectorEventId = 1;
|
|
318
320
|
|
|
@@ -1133,6 +1135,49 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
|
|
|
1133
1135
|
}
|
|
1134
1136
|
}
|
|
1135
1137
|
|
|
1138
|
+
private emitPushResult(result: PushResultInfo): void {
|
|
1139
|
+
this.emit('push:result', result);
|
|
1140
|
+
this.config.onPushResult?.(result);
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
private async emitNewConflicts(): Promise<void> {
|
|
1144
|
+
const conflicts = await this.getConflicts();
|
|
1145
|
+
const activeIds = new Set(conflicts.map((conflict) => conflict.id));
|
|
1146
|
+
|
|
1147
|
+
for (const id of this.emittedConflictIds) {
|
|
1148
|
+
if (!activeIds.has(id)) {
|
|
1149
|
+
this.emittedConflictIds.delete(id);
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
const sorted = [...conflicts].sort((left, right) => {
|
|
1154
|
+
if (left.createdAt !== right.createdAt) {
|
|
1155
|
+
return left.createdAt - right.createdAt;
|
|
1156
|
+
}
|
|
1157
|
+
return left.opIndex - right.opIndex;
|
|
1158
|
+
});
|
|
1159
|
+
|
|
1160
|
+
for (const conflict of sorted) {
|
|
1161
|
+
if (this.emittedConflictIds.has(conflict.id)) {
|
|
1162
|
+
continue;
|
|
1163
|
+
}
|
|
1164
|
+
this.emittedConflictIds.add(conflict.id);
|
|
1165
|
+
this.emit('conflict:new', conflict);
|
|
1166
|
+
this.config.onConflict?.(conflict);
|
|
1167
|
+
}
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
private async emitNewConflictsSafe(context: string): Promise<void> {
|
|
1171
|
+
try {
|
|
1172
|
+
await this.emitNewConflicts();
|
|
1173
|
+
} catch (error) {
|
|
1174
|
+
console.warn(
|
|
1175
|
+
`[SyncEngine] Failed to emit conflict:new during ${context}`,
|
|
1176
|
+
error
|
|
1177
|
+
);
|
|
1178
|
+
}
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1136
1181
|
private async resolveResetTargets(
|
|
1137
1182
|
options: SyncResetOptions
|
|
1138
1183
|
): Promise<SubscriptionState[]> {
|
|
@@ -1279,6 +1324,9 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
|
|
|
1279
1324
|
|
|
1280
1325
|
this.resetLocalState();
|
|
1281
1326
|
await this.refreshOutboxStats();
|
|
1327
|
+
if (result.deletedConflicts > 0) {
|
|
1328
|
+
this.emittedConflictIds.clear();
|
|
1329
|
+
}
|
|
1282
1330
|
this.updateState({ error: null });
|
|
1283
1331
|
|
|
1284
1332
|
return result;
|
|
@@ -1446,6 +1494,9 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
|
|
|
1446
1494
|
}
|
|
1447
1495
|
);
|
|
1448
1496
|
pushed = result.pushed;
|
|
1497
|
+
if (result.pushResult) {
|
|
1498
|
+
this.emitPushResult(result.pushResult);
|
|
1499
|
+
}
|
|
1449
1500
|
}
|
|
1450
1501
|
}
|
|
1451
1502
|
} catch {
|
|
@@ -1636,6 +1687,10 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
|
|
|
1636
1687
|
)
|
|
1637
1688
|
);
|
|
1638
1689
|
|
|
1690
|
+
for (const pushResult of result.pushResults) {
|
|
1691
|
+
this.emitPushResult(pushResult);
|
|
1692
|
+
}
|
|
1693
|
+
|
|
1639
1694
|
const syncResult: SyncResult = {
|
|
1640
1695
|
success: true,
|
|
1641
1696
|
pushedCommits: result.pushedCommits,
|
|
@@ -1674,6 +1729,7 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
|
|
|
1674
1729
|
this.emitDataChange(changedTables);
|
|
1675
1730
|
}
|
|
1676
1731
|
this.handleBootstrapLifecycle(result.pullResponse);
|
|
1732
|
+
await this.emitNewConflictsSafe('sync success');
|
|
1677
1733
|
|
|
1678
1734
|
// Refresh outbox stats (fire-and-forget — don't block sync:complete)
|
|
1679
1735
|
this.refreshOutboxStats().catch((error) => {
|
|
@@ -1732,6 +1788,7 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
|
|
|
1732
1788
|
});
|
|
1733
1789
|
|
|
1734
1790
|
this.handleError(error);
|
|
1791
|
+
await this.emitNewConflictsSafe('sync error');
|
|
1735
1792
|
|
|
1736
1793
|
const durationMs = Math.max(0, Date.now() - startedAtMs);
|
|
1737
1794
|
countSyncMetric('sync.client.sync.results', 1, {
|
|
@@ -1797,9 +1854,17 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
|
|
|
1797
1854
|
*/
|
|
1798
1855
|
private async applyWsDeliveredChanges(
|
|
1799
1856
|
changes: SyncChange[],
|
|
1800
|
-
cursor: number
|
|
1857
|
+
cursor: number,
|
|
1858
|
+
metadata?: {
|
|
1859
|
+
commitSeq?: number;
|
|
1860
|
+
actorId?: string | null;
|
|
1861
|
+
createdAt?: string | null;
|
|
1862
|
+
}
|
|
1801
1863
|
): Promise<boolean> {
|
|
1802
1864
|
try {
|
|
1865
|
+
const commitSeq = metadata?.commitSeq ?? cursor;
|
|
1866
|
+
const actorId = metadata?.actorId ?? null;
|
|
1867
|
+
const createdAt = metadata?.createdAt ?? null;
|
|
1803
1868
|
await this.config.db.transaction().execute(async (trx) => {
|
|
1804
1869
|
for (const change of changes) {
|
|
1805
1870
|
const handler = getClientHandler(this.config.handlers, change.table);
|
|
@@ -1808,7 +1873,15 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
|
|
|
1808
1873
|
`Missing client table handler for WS change table "${change.table}"`
|
|
1809
1874
|
);
|
|
1810
1875
|
}
|
|
1811
|
-
await handler.applyChange(
|
|
1876
|
+
await handler.applyChange(
|
|
1877
|
+
{
|
|
1878
|
+
trx,
|
|
1879
|
+
commitSeq,
|
|
1880
|
+
actorId,
|
|
1881
|
+
createdAt,
|
|
1882
|
+
},
|
|
1883
|
+
change
|
|
1884
|
+
);
|
|
1812
1885
|
}
|
|
1813
1886
|
|
|
1814
1887
|
// Update subscription cursors
|
|
@@ -1851,7 +1924,12 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
|
|
|
1851
1924
|
*/
|
|
1852
1925
|
private async handleWsDelivery(
|
|
1853
1926
|
changes: SyncChange[],
|
|
1854
|
-
cursor: number
|
|
1927
|
+
cursor: number,
|
|
1928
|
+
metadata?: {
|
|
1929
|
+
commitSeq?: number;
|
|
1930
|
+
actorId?: string | null;
|
|
1931
|
+
createdAt?: string | null;
|
|
1932
|
+
}
|
|
1855
1933
|
): Promise<void> {
|
|
1856
1934
|
// If a sync is already in-flight, let it handle everything
|
|
1857
1935
|
if (this.syncPromise) {
|
|
@@ -1895,7 +1973,11 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
|
|
|
1895
1973
|
|
|
1896
1974
|
// Apply changes + update cursor
|
|
1897
1975
|
const inlineApplyStartedAtMs = Date.now();
|
|
1898
|
-
const applied = await this.applyWsDeliveredChanges(
|
|
1976
|
+
const applied = await this.applyWsDeliveredChanges(
|
|
1977
|
+
changes,
|
|
1978
|
+
cursor,
|
|
1979
|
+
metadata
|
|
1980
|
+
);
|
|
1899
1981
|
const inlineApplyDurationMs = Math.max(
|
|
1900
1982
|
0,
|
|
1901
1983
|
Date.now() - inlineApplyStartedAtMs
|
|
@@ -2161,10 +2243,27 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
|
|
|
2161
2243
|
const hasInlineChanges =
|
|
2162
2244
|
Array.isArray(event.data.changes) && event.data.changes.length > 0;
|
|
2163
2245
|
const cursor = event.data.cursor;
|
|
2246
|
+
const commitSeqRaw = event.data.commitSeq;
|
|
2247
|
+
const commitSeq =
|
|
2248
|
+
typeof commitSeqRaw === 'number'
|
|
2249
|
+
? commitSeqRaw
|
|
2250
|
+
: typeof cursor === 'number'
|
|
2251
|
+
? cursor
|
|
2252
|
+
: undefined;
|
|
2253
|
+
const actorId =
|
|
2254
|
+
typeof event.data.actorId === 'string' ? event.data.actorId : null;
|
|
2255
|
+
const createdAt =
|
|
2256
|
+
typeof event.data.createdAt === 'string'
|
|
2257
|
+
? event.data.createdAt
|
|
2258
|
+
: null;
|
|
2164
2259
|
|
|
2165
2260
|
if (hasInlineChanges && typeof cursor === 'number') {
|
|
2166
2261
|
// WS delivered changes + cursor — may skip HTTP pull
|
|
2167
|
-
this.handleWsDelivery(event.data.changes as SyncChange[], cursor
|
|
2262
|
+
this.handleWsDelivery(event.data.changes as SyncChange[], cursor, {
|
|
2263
|
+
commitSeq,
|
|
2264
|
+
actorId,
|
|
2265
|
+
createdAt,
|
|
2266
|
+
});
|
|
2168
2267
|
} else {
|
|
2169
2268
|
// Cursor-only wake-up or no cursor — must HTTP sync
|
|
2170
2269
|
countSyncMetric('sync.client.ws.delivery.events', 1, {
|
package/src/engine/types.ts
CHANGED
|
@@ -140,14 +140,28 @@ export type SyncEventType =
|
|
|
140
140
|
| 'sync:complete'
|
|
141
141
|
| 'sync:live'
|
|
142
142
|
| 'sync:error'
|
|
143
|
+
| 'push:result'
|
|
143
144
|
| 'bootstrap:start'
|
|
144
145
|
| 'bootstrap:progress'
|
|
145
146
|
| 'bootstrap:complete'
|
|
146
147
|
| 'connection:change'
|
|
147
148
|
| 'outbox:change'
|
|
148
149
|
| 'data:change'
|
|
150
|
+
| 'conflict:new'
|
|
149
151
|
| 'presence:change';
|
|
150
152
|
|
|
153
|
+
export type PushResultStatus = 'applied' | 'cached' | 'rejected' | 'retriable';
|
|
154
|
+
|
|
155
|
+
export interface PushResultInfo {
|
|
156
|
+
outboxCommitId: string;
|
|
157
|
+
clientCommitId: string;
|
|
158
|
+
status: PushResultStatus;
|
|
159
|
+
commitSeq: number | null;
|
|
160
|
+
results: SyncPushResponse['results'];
|
|
161
|
+
errorCode: string | null;
|
|
162
|
+
timestamp: number;
|
|
163
|
+
}
|
|
164
|
+
|
|
151
165
|
/**
|
|
152
166
|
* Presence entry for a client connected to a scope
|
|
153
167
|
*/
|
|
@@ -172,6 +186,7 @@ export interface SyncEventPayloads {
|
|
|
172
186
|
};
|
|
173
187
|
'sync:live': { timestamp: number };
|
|
174
188
|
'sync:error': SyncError;
|
|
189
|
+
'push:result': PushResultInfo;
|
|
175
190
|
'bootstrap:start': {
|
|
176
191
|
timestamp: number;
|
|
177
192
|
stateId: string;
|
|
@@ -203,6 +218,7 @@ export interface SyncEventPayloads {
|
|
|
203
218
|
scopes: string[];
|
|
204
219
|
timestamp: number;
|
|
205
220
|
};
|
|
221
|
+
'conflict:new': ConflictInfo;
|
|
206
222
|
'presence:change': {
|
|
207
223
|
scopeKey: string;
|
|
208
224
|
presence: PresenceEntry[];
|
|
@@ -260,6 +276,8 @@ export interface SyncEngineConfig<DB extends SyncClientDb = SyncClientDb> {
|
|
|
260
276
|
onError?: (error: SyncError) => void;
|
|
261
277
|
/** Conflict callback */
|
|
262
278
|
onConflict?: (conflict: ConflictInfo) => void;
|
|
279
|
+
/** Per-commit push outcome callback */
|
|
280
|
+
onPushResult?: (result: PushResultInfo) => void;
|
|
263
281
|
/** Data change callback */
|
|
264
282
|
onDataChange?: (scopes: string[]) => void;
|
|
265
283
|
/**
|
|
@@ -317,8 +335,11 @@ export interface RealtimeTransportLike extends SyncTransport {
|
|
|
317
335
|
event: string;
|
|
318
336
|
data: {
|
|
319
337
|
cursor?: number;
|
|
338
|
+
commitSeq?: number;
|
|
320
339
|
changes?: unknown[];
|
|
321
340
|
error?: string;
|
|
341
|
+
actorId?: string;
|
|
342
|
+
createdAt?: string;
|
|
322
343
|
timestamp: number;
|
|
323
344
|
};
|
|
324
345
|
}) => void,
|
package/src/handlers/types.ts
CHANGED
|
@@ -16,6 +16,15 @@ import type { Transaction } from 'kysely';
|
|
|
16
16
|
export interface ClientHandlerContext<DB> {
|
|
17
17
|
/** Database transaction */
|
|
18
18
|
trx: Transaction<DB>;
|
|
19
|
+
/**
|
|
20
|
+
* Commit metadata for server-delivered changes.
|
|
21
|
+
* Undefined for local optimistic changes.
|
|
22
|
+
*/
|
|
23
|
+
commitSeq?: number | null;
|
|
24
|
+
/** Actor that authored the server commit, when available. */
|
|
25
|
+
actorId?: string | null;
|
|
26
|
+
/** Commit creation timestamp (ISO string), when available. */
|
|
27
|
+
createdAt?: string | null;
|
|
19
28
|
}
|
|
20
29
|
|
|
21
30
|
/**
|
package/src/pull-engine.test.ts
CHANGED
|
@@ -818,4 +818,98 @@ describe('applyPullResponse chunk streaming', () => {
|
|
|
818
818
|
.executeTakeFirst();
|
|
819
819
|
expect(Number(state?.cursor ?? -1)).toBe(2);
|
|
820
820
|
});
|
|
821
|
+
|
|
822
|
+
it('passes commit metadata to applyChange handler context', async () => {
|
|
823
|
+
const transport: SyncTransport = {
|
|
824
|
+
async sync() {
|
|
825
|
+
return {};
|
|
826
|
+
},
|
|
827
|
+
async fetchSnapshotChunk() {
|
|
828
|
+
return new Uint8Array();
|
|
829
|
+
},
|
|
830
|
+
};
|
|
831
|
+
|
|
832
|
+
const appliedContexts: Array<{
|
|
833
|
+
commitSeq: number | null | undefined;
|
|
834
|
+
actorId: string | null | undefined;
|
|
835
|
+
createdAt: string | null | undefined;
|
|
836
|
+
}> = [];
|
|
837
|
+
|
|
838
|
+
const handlers: ClientHandlerCollection<TestDb> = [
|
|
839
|
+
{
|
|
840
|
+
table: 'items',
|
|
841
|
+
async applySnapshot() {},
|
|
842
|
+
async clearAll() {},
|
|
843
|
+
async applyChange(ctx) {
|
|
844
|
+
appliedContexts.push({
|
|
845
|
+
commitSeq: ctx.commitSeq,
|
|
846
|
+
actorId: ctx.actorId,
|
|
847
|
+
createdAt: ctx.createdAt,
|
|
848
|
+
});
|
|
849
|
+
},
|
|
850
|
+
},
|
|
851
|
+
];
|
|
852
|
+
|
|
853
|
+
const options = {
|
|
854
|
+
clientId: 'client-1',
|
|
855
|
+
subscriptions: [
|
|
856
|
+
{
|
|
857
|
+
id: 'items-sub',
|
|
858
|
+
table: 'items',
|
|
859
|
+
scopes: {},
|
|
860
|
+
},
|
|
861
|
+
],
|
|
862
|
+
stateId: 'default',
|
|
863
|
+
};
|
|
864
|
+
|
|
865
|
+
const pullState = await buildPullRequest(db, options);
|
|
866
|
+
const response: SyncPullResponse = {
|
|
867
|
+
ok: true,
|
|
868
|
+
subscriptions: [
|
|
869
|
+
{
|
|
870
|
+
id: 'items-sub',
|
|
871
|
+
status: 'active',
|
|
872
|
+
scopes: {},
|
|
873
|
+
bootstrap: false,
|
|
874
|
+
bootstrapState: null,
|
|
875
|
+
nextCursor: 7,
|
|
876
|
+
commits: [
|
|
877
|
+
{
|
|
878
|
+
commitSeq: 7,
|
|
879
|
+
actorId: 'remote-user',
|
|
880
|
+
createdAt: '2026-02-28T12:00:00.000Z',
|
|
881
|
+
changes: [
|
|
882
|
+
{
|
|
883
|
+
table: 'items',
|
|
884
|
+
row_id: 'item-ctx',
|
|
885
|
+
op: 'upsert',
|
|
886
|
+
row_version: 1,
|
|
887
|
+
row_json: { id: 'item-ctx', name: 'ctx-test' },
|
|
888
|
+
scopes: {},
|
|
889
|
+
},
|
|
890
|
+
],
|
|
891
|
+
},
|
|
892
|
+
],
|
|
893
|
+
snapshots: [],
|
|
894
|
+
},
|
|
895
|
+
],
|
|
896
|
+
};
|
|
897
|
+
|
|
898
|
+
await applyPullResponse(
|
|
899
|
+
db,
|
|
900
|
+
transport,
|
|
901
|
+
handlers,
|
|
902
|
+
options,
|
|
903
|
+
pullState,
|
|
904
|
+
response
|
|
905
|
+
);
|
|
906
|
+
|
|
907
|
+
expect(appliedContexts).toEqual([
|
|
908
|
+
{
|
|
909
|
+
commitSeq: 7,
|
|
910
|
+
actorId: 'remote-user',
|
|
911
|
+
createdAt: '2026-02-28T12:00:00.000Z',
|
|
912
|
+
},
|
|
913
|
+
]);
|
|
914
|
+
});
|
|
821
915
|
});
|
package/src/pull-engine.ts
CHANGED
|
@@ -888,9 +888,20 @@ export async function applyPullResponse<DB extends SyncClientDb>(
|
|
|
888
888
|
} else {
|
|
889
889
|
// Apply incremental changes
|
|
890
890
|
for (const commit of sub.commits) {
|
|
891
|
+
const commitSeq = commit.commitSeq ?? null;
|
|
892
|
+
const actorId = commit.actorId ?? null;
|
|
893
|
+
const createdAt = commit.createdAt ?? null;
|
|
891
894
|
for (const change of commit.changes) {
|
|
892
895
|
const handler = getClientHandlerOrThrow(handlers, change.table);
|
|
893
|
-
await handler.applyChange(
|
|
896
|
+
await handler.applyChange(
|
|
897
|
+
{
|
|
898
|
+
trx,
|
|
899
|
+
commitSeq,
|
|
900
|
+
actorId,
|
|
901
|
+
createdAt,
|
|
902
|
+
},
|
|
903
|
+
change
|
|
904
|
+
);
|
|
894
905
|
}
|
|
895
906
|
}
|
|
896
907
|
}
|
package/src/push-engine.ts
CHANGED
|
@@ -10,6 +10,7 @@ import type {
|
|
|
10
10
|
import { countSyncMetric } from '@syncular/core';
|
|
11
11
|
import type { Kysely } from 'kysely';
|
|
12
12
|
import { upsertConflictsForRejectedCommit } from './conflicts';
|
|
13
|
+
import type { PushResultInfo } from './engine/types';
|
|
13
14
|
import {
|
|
14
15
|
getNextSendableOutboxCommit,
|
|
15
16
|
markOutboxCommitAcked,
|
|
@@ -31,6 +32,7 @@ export interface SyncPushOnceOptions {
|
|
|
31
32
|
export interface SyncPushOnceResult {
|
|
32
33
|
pushed: boolean;
|
|
33
34
|
response?: SyncPushResponse;
|
|
35
|
+
pushResult?: PushResultInfo;
|
|
34
36
|
}
|
|
35
37
|
|
|
36
38
|
interface TransportWithWsPush extends SyncTransport {
|
|
@@ -48,6 +50,41 @@ function clonePushRequest(request: SyncPushRequest): SyncPushRequest {
|
|
|
48
50
|
return JSON.parse(JSON.stringify(request)) as SyncPushRequest;
|
|
49
51
|
}
|
|
50
52
|
|
|
53
|
+
function firstPushErrorCode(response: SyncPushResponse): string | null {
|
|
54
|
+
const firstError = response.results.find(
|
|
55
|
+
(result) => result.status === 'error'
|
|
56
|
+
);
|
|
57
|
+
if (
|
|
58
|
+
firstError &&
|
|
59
|
+
'code' in firstError &&
|
|
60
|
+
typeof firstError.code === 'string' &&
|
|
61
|
+
firstError.code
|
|
62
|
+
) {
|
|
63
|
+
return firstError.code;
|
|
64
|
+
}
|
|
65
|
+
const hasConflict = response.results.some(
|
|
66
|
+
(result) => result.status === 'conflict'
|
|
67
|
+
);
|
|
68
|
+
return hasConflict ? 'CONFLICT' : null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function buildPushResult(args: {
|
|
72
|
+
outboxCommitId: string;
|
|
73
|
+
clientCommitId: string;
|
|
74
|
+
status: PushResultInfo['status'];
|
|
75
|
+
response: SyncPushResponse;
|
|
76
|
+
}): PushResultInfo {
|
|
77
|
+
return {
|
|
78
|
+
outboxCommitId: args.outboxCommitId,
|
|
79
|
+
clientCommitId: args.clientCommitId,
|
|
80
|
+
status: args.status,
|
|
81
|
+
commitSeq: args.response.commitSeq ?? null,
|
|
82
|
+
results: args.response.results,
|
|
83
|
+
errorCode: firstPushErrorCode(args.response),
|
|
84
|
+
timestamp: Date.now(),
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
51
88
|
export async function syncPushOnce<DB extends SyncClientDb>(
|
|
52
89
|
db: Kysely<DB>,
|
|
53
90
|
transport: SyncTransport,
|
|
@@ -178,7 +215,16 @@ export async function syncPushOnce<DB extends SyncClientDb>(
|
|
|
178
215
|
commitSeq: responseToUse.commitSeq ?? null,
|
|
179
216
|
responseJson,
|
|
180
217
|
});
|
|
181
|
-
return {
|
|
218
|
+
return {
|
|
219
|
+
pushed: true,
|
|
220
|
+
response: responseToUse,
|
|
221
|
+
pushResult: buildPushResult({
|
|
222
|
+
outboxCommitId: next.id,
|
|
223
|
+
clientCommitId: next.client_commit_id,
|
|
224
|
+
status: responseToUse.status,
|
|
225
|
+
response: responseToUse,
|
|
226
|
+
}),
|
|
227
|
+
};
|
|
182
228
|
}
|
|
183
229
|
|
|
184
230
|
// Check if all errors are retriable - if so, keep pending for retry
|
|
@@ -198,7 +244,16 @@ export async function syncPushOnce<DB extends SyncClientDb>(
|
|
|
198
244
|
error: `Retriable: ${errorMessages}`,
|
|
199
245
|
responseJson,
|
|
200
246
|
});
|
|
201
|
-
return {
|
|
247
|
+
return {
|
|
248
|
+
pushed: true,
|
|
249
|
+
response: responseToUse,
|
|
250
|
+
pushResult: buildPushResult({
|
|
251
|
+
outboxCommitId: next.id,
|
|
252
|
+
clientCommitId: next.client_commit_id,
|
|
253
|
+
status: 'retriable',
|
|
254
|
+
response: responseToUse,
|
|
255
|
+
}),
|
|
256
|
+
};
|
|
202
257
|
}
|
|
203
258
|
|
|
204
259
|
// Terminal rejection - mark as failed and record conflicts
|
|
@@ -212,5 +267,14 @@ export async function syncPushOnce<DB extends SyncClientDb>(
|
|
|
212
267
|
error: 'REJECTED',
|
|
213
268
|
responseJson,
|
|
214
269
|
});
|
|
215
|
-
return {
|
|
270
|
+
return {
|
|
271
|
+
pushed: true,
|
|
272
|
+
response: responseToUse,
|
|
273
|
+
pushResult: buildPushResult({
|
|
274
|
+
outboxCommitId: next.id,
|
|
275
|
+
clientCommitId: next.client_commit_id,
|
|
276
|
+
status: 'rejected',
|
|
277
|
+
response: responseToUse,
|
|
278
|
+
}),
|
|
279
|
+
};
|
|
216
280
|
}
|
package/src/sync-loop.ts
CHANGED
|
@@ -15,6 +15,7 @@ import type {
|
|
|
15
15
|
} from '@syncular/core';
|
|
16
16
|
import type { Kysely } from 'kysely';
|
|
17
17
|
import { upsertConflictsForRejectedCommit } from './conflicts';
|
|
18
|
+
import type { PushResultInfo } from './engine/types';
|
|
18
19
|
import type { ClientHandlerCollection } from './handlers/collection';
|
|
19
20
|
import {
|
|
20
21
|
getNextSendableOutboxCommit,
|
|
@@ -41,6 +42,42 @@ interface SyncPushUntilSettledOptions extends SyncPushOnceOptions {
|
|
|
41
42
|
|
|
42
43
|
interface SyncPushUntilSettledResult {
|
|
43
44
|
pushedCount: number;
|
|
45
|
+
pushResults: PushResultInfo[];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function firstPushErrorCode(response: SyncPushResponse): string | null {
|
|
49
|
+
const firstError = response.results.find(
|
|
50
|
+
(result) => result.status === 'error'
|
|
51
|
+
);
|
|
52
|
+
if (
|
|
53
|
+
firstError &&
|
|
54
|
+
'code' in firstError &&
|
|
55
|
+
typeof firstError.code === 'string' &&
|
|
56
|
+
firstError.code
|
|
57
|
+
) {
|
|
58
|
+
return firstError.code;
|
|
59
|
+
}
|
|
60
|
+
const hasConflict = response.results.some(
|
|
61
|
+
(result) => result.status === 'conflict'
|
|
62
|
+
);
|
|
63
|
+
return hasConflict ? 'CONFLICT' : null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function buildPushResult(args: {
|
|
67
|
+
outboxCommitId: string;
|
|
68
|
+
clientCommitId: string;
|
|
69
|
+
status: PushResultInfo['status'];
|
|
70
|
+
response: SyncPushResponse;
|
|
71
|
+
}): PushResultInfo {
|
|
72
|
+
return {
|
|
73
|
+
outboxCommitId: args.outboxCommitId,
|
|
74
|
+
clientCommitId: args.clientCommitId,
|
|
75
|
+
status: args.status,
|
|
76
|
+
commitSeq: args.response.commitSeq ?? null,
|
|
77
|
+
results: args.response.results,
|
|
78
|
+
errorCode: firstPushErrorCode(args.response),
|
|
79
|
+
timestamp: Date.now(),
|
|
80
|
+
};
|
|
44
81
|
}
|
|
45
82
|
|
|
46
83
|
async function syncPushUntilSettled<DB extends SyncClientDb>(
|
|
@@ -51,6 +88,7 @@ async function syncPushUntilSettled<DB extends SyncClientDb>(
|
|
|
51
88
|
const maxCommits = Math.max(1, Math.min(1000, options.maxCommits ?? 20));
|
|
52
89
|
|
|
53
90
|
let pushedCount = 0;
|
|
91
|
+
const pushResults: PushResultInfo[] = [];
|
|
54
92
|
for (let i = 0; i < maxCommits; i++) {
|
|
55
93
|
const res = await syncPushOnce(db, transport, {
|
|
56
94
|
clientId: options.clientId,
|
|
@@ -59,9 +97,12 @@ async function syncPushUntilSettled<DB extends SyncClientDb>(
|
|
|
59
97
|
});
|
|
60
98
|
if (!res.pushed) break;
|
|
61
99
|
pushedCount += 1;
|
|
100
|
+
if (res.pushResult) {
|
|
101
|
+
pushResults.push(res.pushResult);
|
|
102
|
+
}
|
|
62
103
|
}
|
|
63
104
|
|
|
64
|
-
return { pushedCount };
|
|
105
|
+
return { pushedCount, pushResults };
|
|
65
106
|
}
|
|
66
107
|
|
|
67
108
|
interface SyncPullUntilSettledOptions extends SyncPullOnceOptions {
|
|
@@ -176,6 +217,7 @@ export interface SyncOnceResult {
|
|
|
176
217
|
pushedCommits: number;
|
|
177
218
|
pullRounds: number;
|
|
178
219
|
pullResponse: SyncPullResponse;
|
|
220
|
+
pushResults: PushResultInfo[];
|
|
179
221
|
}
|
|
180
222
|
|
|
181
223
|
/**
|
|
@@ -276,6 +318,7 @@ async function syncOnceCombined<DB extends SyncClientDb>(
|
|
|
276
318
|
|
|
277
319
|
// Process push response
|
|
278
320
|
let pushedCommits = 0;
|
|
321
|
+
const pushResults: PushResultInfo[] = [];
|
|
279
322
|
if (outbox && pushRequest) {
|
|
280
323
|
let pushRes = wsPushResponse ?? combined.push;
|
|
281
324
|
if (!pushRes) {
|
|
@@ -303,6 +346,14 @@ async function syncOnceCombined<DB extends SyncClientDb>(
|
|
|
303
346
|
commitSeq: pushRes.commitSeq ?? null,
|
|
304
347
|
responseJson,
|
|
305
348
|
});
|
|
349
|
+
pushResults.push(
|
|
350
|
+
buildPushResult({
|
|
351
|
+
outboxCommitId: outbox.id,
|
|
352
|
+
clientCommitId: outbox.client_commit_id,
|
|
353
|
+
status: pushRes.status,
|
|
354
|
+
response: pushRes,
|
|
355
|
+
})
|
|
356
|
+
);
|
|
306
357
|
pushedCommits = 1;
|
|
307
358
|
} else {
|
|
308
359
|
// Check if all errors are retriable
|
|
@@ -317,6 +368,14 @@ async function syncOnceCombined<DB extends SyncClientDb>(
|
|
|
317
368
|
error: 'Retriable',
|
|
318
369
|
responseJson,
|
|
319
370
|
});
|
|
371
|
+
pushResults.push(
|
|
372
|
+
buildPushResult({
|
|
373
|
+
outboxCommitId: outbox.id,
|
|
374
|
+
clientCommitId: outbox.client_commit_id,
|
|
375
|
+
status: 'retriable',
|
|
376
|
+
response: pushRes,
|
|
377
|
+
})
|
|
378
|
+
);
|
|
320
379
|
pushedCommits = 1;
|
|
321
380
|
} else {
|
|
322
381
|
await upsertConflictsForRejectedCommit(db, {
|
|
@@ -329,6 +388,14 @@ async function syncOnceCombined<DB extends SyncClientDb>(
|
|
|
329
388
|
error: 'REJECTED',
|
|
330
389
|
responseJson,
|
|
331
390
|
});
|
|
391
|
+
pushResults.push(
|
|
392
|
+
buildPushResult({
|
|
393
|
+
outboxCommitId: outbox.id,
|
|
394
|
+
clientCommitId: outbox.client_commit_id,
|
|
395
|
+
status: 'rejected',
|
|
396
|
+
response: pushRes,
|
|
397
|
+
})
|
|
398
|
+
);
|
|
332
399
|
pushedCommits = 1;
|
|
333
400
|
}
|
|
334
401
|
}
|
|
@@ -341,6 +408,7 @@ async function syncOnceCombined<DB extends SyncClientDb>(
|
|
|
341
408
|
maxCommits: (options.maxPushCommits ?? 20) - 1,
|
|
342
409
|
});
|
|
343
410
|
pushedCommits += remaining.pushedCount;
|
|
411
|
+
pushResults.push(...remaining.pushResults);
|
|
344
412
|
}
|
|
345
413
|
|
|
346
414
|
// Process pull response
|
|
@@ -376,7 +444,7 @@ async function syncOnceCombined<DB extends SyncClientDb>(
|
|
|
376
444
|
}
|
|
377
445
|
}
|
|
378
446
|
|
|
379
|
-
return { pushedCommits, pullRounds, pullResponse };
|
|
447
|
+
return { pushedCommits, pullRounds, pullResponse, pushResults };
|
|
380
448
|
}
|
|
381
449
|
|
|
382
450
|
export async function syncOnce<DB extends SyncClientDb>(
|