@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.
@@ -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({ trx }, change);
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(changes, cursor);
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, {
@@ -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,
@@ -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
  /**
@@ -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
  });
@@ -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({ trx }, change);
896
+ await handler.applyChange(
897
+ {
898
+ trx,
899
+ commitSeq,
900
+ actorId,
901
+ createdAt,
902
+ },
903
+ change
904
+ );
894
905
  }
895
906
  }
896
907
  }
@@ -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 { pushed: true, response: responseToUse };
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 { pushed: true, response: responseToUse };
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 { pushed: true, response: responseToUse };
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>(