@syncular/client 0.0.6-184 → 0.0.6-188

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.
Files changed (81) hide show
  1. package/dist/client.d.ts +14 -71
  2. package/dist/client.d.ts.map +1 -1
  3. package/dist/client.js +81 -406
  4. package/dist/client.js.map +1 -1
  5. package/dist/create-client.d.ts +0 -2
  6. package/dist/create-client.d.ts.map +1 -1
  7. package/dist/create-client.js +2 -3
  8. package/dist/create-client.js.map +1 -1
  9. package/dist/engine/SyncEngine.d.ts +1 -0
  10. package/dist/engine/SyncEngine.d.ts.map +1 -1
  11. package/dist/engine/SyncEngine.js +21 -6
  12. package/dist/engine/SyncEngine.js.map +1 -1
  13. package/dist/handlers/create-handler.d.ts +5 -0
  14. package/dist/handlers/create-handler.d.ts.map +1 -1
  15. package/dist/handlers/create-handler.js +123 -4
  16. package/dist/handlers/create-handler.js.map +1 -1
  17. package/dist/handlers/types.d.ts +7 -0
  18. package/dist/handlers/types.d.ts.map +1 -1
  19. package/dist/index.d.ts +1 -1
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/index.js +0 -1
  22. package/dist/index.js.map +1 -1
  23. package/dist/internal/blob-schema.d.ts +32 -0
  24. package/dist/internal/blob-schema.d.ts.map +1 -0
  25. package/dist/internal/blob-schema.js +2 -0
  26. package/dist/internal/blob-schema.js.map +1 -0
  27. package/dist/mutations.d.ts.map +1 -1
  28. package/dist/mutations.js +15 -6
  29. package/dist/mutations.js.map +1 -1
  30. package/dist/plugins/incrementing-version.d.ts.map +1 -1
  31. package/dist/plugins/incrementing-version.js +20 -8
  32. package/dist/plugins/incrementing-version.js.map +1 -1
  33. package/dist/plugins/types.d.ts +26 -1
  34. package/dist/plugins/types.d.ts.map +1 -1
  35. package/dist/plugins/types.js.map +1 -1
  36. package/dist/pull-engine.d.ts +8 -2
  37. package/dist/pull-engine.d.ts.map +1 -1
  38. package/dist/pull-engine.js +150 -26
  39. package/dist/pull-engine.js.map +1 -1
  40. package/dist/push-engine.d.ts.map +1 -1
  41. package/dist/push-engine.js +21 -5
  42. package/dist/push-engine.js.map +1 -1
  43. package/dist/schema.d.ts +2 -2
  44. package/dist/schema.d.ts.map +1 -1
  45. package/dist/sync-loop.d.ts +3 -1
  46. package/dist/sync-loop.d.ts.map +1 -1
  47. package/dist/sync-loop.js +382 -139
  48. package/dist/sync-loop.js.map +1 -1
  49. package/package.json +76 -3
  50. package/src/client.test.ts +72 -155
  51. package/src/client.ts +113 -572
  52. package/src/create-client.ts +1 -6
  53. package/src/engine/SyncEngine.test.ts +90 -0
  54. package/src/engine/SyncEngine.ts +29 -9
  55. package/src/handlers/create-handler.ts +197 -4
  56. package/src/handlers/types.ts +11 -0
  57. package/src/index.ts +1 -2
  58. package/src/internal/blob-schema.ts +40 -0
  59. package/src/mutations.ts +17 -6
  60. package/src/plugins/incrementing-version.ts +36 -7
  61. package/src/plugins/types.ts +42 -0
  62. package/src/pull-engine.test.ts +494 -0
  63. package/src/pull-engine.ts +193 -29
  64. package/src/push-engine.ts +31 -5
  65. package/src/schema.ts +2 -2
  66. package/src/sync-loop.ts +538 -145
  67. package/dist/blobs/index.d.ts +0 -6
  68. package/dist/blobs/index.d.ts.map +0 -1
  69. package/dist/blobs/index.js +0 -6
  70. package/dist/blobs/index.js.map +0 -1
  71. package/dist/blobs/migrate.d.ts +0 -14
  72. package/dist/blobs/migrate.d.ts.map +0 -1
  73. package/dist/blobs/migrate.js +0 -59
  74. package/dist/blobs/migrate.js.map +0 -1
  75. package/dist/blobs/types.d.ts +0 -62
  76. package/dist/blobs/types.d.ts.map +0 -1
  77. package/dist/blobs/types.js +0 -5
  78. package/dist/blobs/types.js.map +0 -1
  79. package/src/blobs/index.ts +0 -6
  80. package/src/blobs/migrate.ts +0 -67
  81. package/src/blobs/types.ts +0 -84
package/dist/sync-loop.js CHANGED
@@ -5,8 +5,8 @@
5
5
  */
6
6
  import { upsertConflictsForRejectedCommit } from './conflicts.js';
7
7
  import { getNextSendableOutboxCommit, markOutboxCommitAcked, markOutboxCommitFailed, markOutboxCommitPending, } from './outbox.js';
8
+ import { INCREMENTING_VERSION_PLUGIN_KIND } from './plugins/incrementing-version.js';
8
9
  import { applyPullResponse, buildPullRequest, createFollowupPullState, syncPullOnce, } from './pull-engine.js';
9
- import { syncPushOnce } from './push-engine.js';
10
10
  function firstPushErrorCode(response) {
11
11
  const firstError = response.results.find((result) => result.status === 'error');
12
12
  if (firstError &&
@@ -29,21 +29,239 @@ function buildPushResult(args) {
29
29
  timestamp: Date.now(),
30
30
  };
31
31
  }
32
+ function normalizeError(error) {
33
+ return error instanceof Error ? error : new Error(String(error));
34
+ }
35
+ function createPushRequest(clientId, outboxCommit) {
36
+ return {
37
+ clientId,
38
+ clientCommitId: outboxCommit.client_commit_id,
39
+ operations: outboxCommit.operations,
40
+ schemaVersion: outboxCommit.schema_version,
41
+ };
42
+ }
43
+ function toSyncPushResponse(response) {
44
+ return {
45
+ ok: true,
46
+ status: response.status,
47
+ commitSeq: response.commitSeq,
48
+ results: response.results,
49
+ };
50
+ }
51
+ function isRetriablePushResponse(response) {
52
+ const errorResults = response.results.filter((result) => result.status === 'error');
53
+ return (errorResults.length > 0 &&
54
+ errorResults.every((result) => result.retriable === true));
55
+ }
56
+ async function claimSendableOutboxCommits(db, maxCommits) {
57
+ const claimed = [];
58
+ for (let i = 0; i < maxCommits; i++) {
59
+ const next = await getNextSendableOutboxCommit(db);
60
+ if (!next)
61
+ break;
62
+ claimed.push(next);
63
+ }
64
+ return claimed;
65
+ }
66
+ async function markClaimedOutboxCommitsPending(db, outboxCommits, error) {
67
+ for (const outboxCommit of outboxCommits) {
68
+ await markOutboxCommitPending(db, {
69
+ id: outboxCommit.id,
70
+ error,
71
+ });
72
+ }
73
+ }
74
+ function hasIncrementingVersionPlugin(plugins) {
75
+ return plugins.some((plugin) => plugin.kind === INCREMENTING_VERSION_PLUGIN_KIND ||
76
+ plugin.name === INCREMENTING_VERSION_PLUGIN_KIND);
77
+ }
78
+ function makeSequentialRowKey(operation) {
79
+ return `${operation.table}\u001f${operation.row_id}`;
80
+ }
81
+ function advanceSequentialBaseVersionsInBatch(preparedCommits) {
82
+ const nextExpectedBaseVersionByRow = new Map();
83
+ return preparedCommits.map((preparedCommit) => {
84
+ let requestChanged = false;
85
+ const operations = preparedCommit.request.operations.map((operation) => {
86
+ const key = makeSequentialRowKey(operation);
87
+ const nextExpected = nextExpectedBaseVersionByRow.get(key);
88
+ const baseVersion = typeof operation.base_version === 'number' &&
89
+ typeof nextExpected === 'number' &&
90
+ nextExpected > operation.base_version
91
+ ? nextExpected
92
+ : operation.base_version;
93
+ const rewrittenOperation = baseVersion === operation.base_version
94
+ ? operation
95
+ : { ...operation, base_version: baseVersion };
96
+ if (rewrittenOperation !== operation) {
97
+ requestChanged = true;
98
+ }
99
+ if (operation.op === 'delete') {
100
+ nextExpectedBaseVersionByRow.delete(key);
101
+ return rewrittenOperation;
102
+ }
103
+ if (typeof baseVersion === 'number') {
104
+ nextExpectedBaseVersionByRow.set(key, baseVersion + 1);
105
+ return rewrittenOperation;
106
+ }
107
+ nextExpectedBaseVersionByRow.set(key, 1);
108
+ return rewrittenOperation;
109
+ });
110
+ if (!requestChanged)
111
+ return preparedCommit;
112
+ return {
113
+ ...preparedCommit,
114
+ request: {
115
+ ...preparedCommit.request,
116
+ operations,
117
+ },
118
+ };
119
+ });
120
+ }
121
+ async function preparePushCommits(outboxCommits, options) {
122
+ const plugins = options.plugins ?? [];
123
+ const ctx = {
124
+ actorId: options.actorId ?? 'unknown',
125
+ clientId: options.clientId,
126
+ };
127
+ const prepared = [];
128
+ for (const outboxCommit of outboxCommits) {
129
+ let request = createPushRequest(options.clientId, outboxCommit);
130
+ for (const plugin of plugins) {
131
+ if (!plugin.beforePush)
132
+ continue;
133
+ request = await plugin.beforePush(ctx, request);
134
+ }
135
+ prepared.push({ outboxCommit, request });
136
+ }
137
+ if (!hasIncrementingVersionPlugin(plugins)) {
138
+ return prepared;
139
+ }
140
+ return advanceSequentialBaseVersionsInBatch(prepared);
141
+ }
142
+ async function finalizePushCommit(db, options, preparedCommit, rawResponse) {
143
+ const plugins = options.plugins ?? [];
144
+ const ctx = {
145
+ actorId: options.actorId ?? 'unknown',
146
+ clientId: options.clientId,
147
+ };
148
+ let response = rawResponse;
149
+ let afterPushError = null;
150
+ try {
151
+ for (const plugin of plugins) {
152
+ if (!plugin.afterPush)
153
+ continue;
154
+ response = await plugin.afterPush(ctx, {
155
+ request: preparedCommit.request,
156
+ response,
157
+ });
158
+ }
159
+ }
160
+ catch (error) {
161
+ afterPushError = normalizeError(error);
162
+ response = rawResponse;
163
+ }
164
+ const responseJson = JSON.stringify(response);
165
+ let status;
166
+ if (response.status === 'applied' || response.status === 'cached') {
167
+ await markOutboxCommitAcked(db, {
168
+ id: preparedCommit.outboxCommit.id,
169
+ commitSeq: response.commitSeq ?? null,
170
+ responseJson,
171
+ });
172
+ status = response.status;
173
+ }
174
+ else if (isRetriablePushResponse(response)) {
175
+ const errorMessages = response.results
176
+ .filter((result) => result.status === 'error')
177
+ .map((result) => result.error ?? 'Unknown error')
178
+ .join('; ');
179
+ await markOutboxCommitPending(db, {
180
+ id: preparedCommit.outboxCommit.id,
181
+ error: `Retriable: ${errorMessages}`,
182
+ responseJson,
183
+ });
184
+ status = 'retriable';
185
+ }
186
+ else {
187
+ await upsertConflictsForRejectedCommit(db, {
188
+ outboxCommitId: preparedCommit.outboxCommit.id,
189
+ clientCommitId: preparedCommit.outboxCommit.client_commit_id,
190
+ response,
191
+ });
192
+ await markOutboxCommitFailed(db, {
193
+ id: preparedCommit.outboxCommit.id,
194
+ error: 'REJECTED',
195
+ responseJson,
196
+ });
197
+ status = 'rejected';
198
+ }
199
+ return {
200
+ pushResult: buildPushResult({
201
+ outboxCommitId: preparedCommit.outboxCommit.id,
202
+ clientCommitId: preparedCommit.outboxCommit.client_commit_id,
203
+ status,
204
+ response,
205
+ }),
206
+ afterPushError,
207
+ };
208
+ }
32
209
  async function syncPushUntilSettled(db, transport, options) {
33
210
  const maxCommits = Math.max(1, Math.min(1000, options.maxCommits ?? 20));
34
211
  let pushedCount = 0;
35
212
  const pushResults = [];
36
- for (let i = 0; i < maxCommits; i++) {
37
- const res = await syncPushOnce(db, transport, {
38
- clientId: options.clientId,
39
- actorId: options.actorId,
40
- plugins: options.plugins,
41
- });
42
- if (!res.pushed)
213
+ while (pushedCount < maxCommits) {
214
+ const claimedCommits = await claimSendableOutboxCommits(db, maxCommits - pushedCount);
215
+ if (claimedCommits.length === 0)
43
216
  break;
44
- pushedCount += 1;
45
- if (res.pushResult) {
46
- pushResults.push(res.pushResult);
217
+ let preparedCommits;
218
+ try {
219
+ preparedCommits = await preparePushCommits(claimedCommits, options);
220
+ }
221
+ catch (error) {
222
+ const normalizedError = normalizeError(error);
223
+ await markClaimedOutboxCommitsPending(db, claimedCommits, normalizedError.message);
224
+ throw normalizedError;
225
+ }
226
+ let combined;
227
+ try {
228
+ combined = await transport.sync({
229
+ clientId: options.clientId,
230
+ push: {
231
+ commits: preparedCommits.map(({ request }) => ({
232
+ clientCommitId: request.clientCommitId,
233
+ operations: request.operations,
234
+ schemaVersion: request.schemaVersion,
235
+ })),
236
+ },
237
+ });
238
+ }
239
+ catch (error) {
240
+ const normalizedError = normalizeError(error);
241
+ await markClaimedOutboxCommitsPending(db, claimedCommits, normalizedError.message);
242
+ throw normalizedError;
243
+ }
244
+ const batchResponses = combined.push?.commits ?? [];
245
+ const responsesByClientCommitId = new Map(batchResponses.map((response) => [response.clientCommitId, response]));
246
+ if (!combined.push ||
247
+ preparedCommits.some(({ request }) => !responsesByClientCommitId.has(request.clientCommitId))) {
248
+ await markClaimedOutboxCommitsPending(db, claimedCommits, 'MISSING_PUSH_RESPONSE');
249
+ throw new Error('Server returned incomplete push response');
250
+ }
251
+ let deferredError = null;
252
+ for (const preparedCommit of preparedCommits) {
253
+ const batchResponse = responsesByClientCommitId.get(preparedCommit.request.clientCommitId);
254
+ if (!batchResponse)
255
+ continue;
256
+ const finalized = await finalizePushCommit(db, options, preparedCommit, toSyncPushResponse(batchResponse));
257
+ pushResults.push(finalized.pushResult);
258
+ pushedCount += 1;
259
+ if (!deferredError && finalized.afterPushError) {
260
+ deferredError = finalized.afterPushError;
261
+ }
262
+ }
263
+ if (deferredError) {
264
+ throw deferredError;
47
265
  }
48
266
  }
49
267
  return { pushedCount, pushResults };
@@ -51,16 +269,16 @@ async function syncPushUntilSettled(db, transport, options) {
51
269
  function hasPushViaWs(transport) {
52
270
  return 'pushViaWs' in transport && typeof transport.pushViaWs === 'function';
53
271
  }
54
- function needsAnotherPull(res) {
272
+ function needsAnotherPull(res, limitCommits) {
273
+ let totalCommits = 0;
55
274
  for (const sub of res.subscriptions ?? []) {
56
275
  if (sub.status !== 'active')
57
276
  continue;
58
277
  if (sub.bootstrap)
59
278
  return true;
60
- if ((sub.commits?.length ?? 0) > 0)
61
- return true;
279
+ totalCommits += sub.commits?.length ?? 0;
62
280
  }
63
- return false;
281
+ return totalCommits >= limitCommits;
64
282
  }
65
283
  function mergePullResponse(targetBySubId, res) {
66
284
  for (const sub of res.subscriptions ?? []) {
@@ -83,6 +301,30 @@ function mergePullResponse(targetBySubId, res) {
83
301
  targetBySubId.set(sub.id, merged);
84
302
  }
85
303
  }
304
+ function canSkipPullAfterLocalWsPush(pullState, pushResponse, options) {
305
+ if (!options.allowSkipPullOnLocalWsPush)
306
+ return false;
307
+ if (options.trigger !== 'local')
308
+ return false;
309
+ if (!pushResponse)
310
+ return false;
311
+ if (pushResponse.status !== 'applied' && pushResponse.status !== 'cached') {
312
+ return false;
313
+ }
314
+ if ((options.plugins ?? []).some((plugin) => typeof plugin.afterPull === 'function')) {
315
+ return false;
316
+ }
317
+ for (const subscription of pullState.request.subscriptions ?? []) {
318
+ if (subscription.bootstrapState != null) {
319
+ return false;
320
+ }
321
+ const cursor = subscription.cursor ?? -1;
322
+ if (cursor < 0) {
323
+ return false;
324
+ }
325
+ }
326
+ return true;
327
+ }
86
328
  async function syncPullUntilSettled(db, transport, handlers, options) {
87
329
  const maxRounds = Math.max(1, Math.min(1000, options.maxRounds ?? 20));
88
330
  const aggregatedBySubId = new Map();
@@ -92,7 +334,7 @@ async function syncPullUntilSettled(db, transport, handlers, options) {
92
334
  rounds += 1;
93
335
  const res = await syncPullOnce(db, transport, handlers, options, pullState);
94
336
  mergePullResponse(aggregatedBySubId, res);
95
- if (!needsAnotherPull(res))
337
+ if (!needsAnotherPull(res, pullState.request.limitCommits))
96
338
  break;
97
339
  pullState = createFollowupPullState(pullState, res);
98
340
  }
@@ -131,161 +373,162 @@ async function syncOnceCombined(db, transport, handlers, options) {
131
373
  // Build pull request (reads subscription state)
132
374
  const pullState = await buildPullRequest(db, pullOpts);
133
375
  const { clientId } = pullState.request;
134
- // Grab at most one outbox commit
135
- const outbox = await getNextSendableOutboxCommit(db);
136
376
  const plugins = options.plugins ?? [];
137
- const ctx = {
138
- actorId: options.actorId ?? 'unknown',
139
- clientId,
140
- };
141
- // Build push request, running beforePush plugins
142
- let pushRequest;
377
+ const outbox = await getNextSendableOutboxCommit(db);
378
+ let preparedFirstCommit = null;
143
379
  if (outbox) {
144
- pushRequest = {
145
- clientId,
146
- clientCommitId: outbox.client_commit_id,
147
- operations: outbox.operations,
148
- schemaVersion: outbox.schema_version,
149
- };
150
- for (const plugin of plugins) {
151
- if (!plugin.beforePush)
152
- continue;
153
- pushRequest = await plugin.beforePush(ctx, pushRequest);
380
+ try {
381
+ const preparedCommits = await preparePushCommits([outbox], {
382
+ clientId: options.clientId,
383
+ actorId: options.actorId,
384
+ plugins,
385
+ });
386
+ preparedFirstCommit = preparedCommits[0] ?? null;
387
+ }
388
+ catch (error) {
389
+ const normalizedError = normalizeError(error);
390
+ await markClaimedOutboxCommitsPending(db, [outbox], normalizedError.message);
391
+ throw normalizedError;
154
392
  }
155
393
  }
156
394
  // Try WS push first for the first outbox commit (if realtime transport supports it).
157
395
  // Fall back to HTTP push in the combined request when WS is unavailable or fails.
158
396
  let wsPushResponse = null;
159
- if (pushRequest && hasPushViaWs(transport)) {
397
+ if (preparedFirstCommit && hasPushViaWs(transport)) {
160
398
  try {
161
- wsPushResponse = await transport.pushViaWs(pushRequest);
399
+ wsPushResponse = await transport.pushViaWs(preparedFirstCommit.request);
162
400
  }
163
401
  catch {
164
402
  wsPushResponse = null;
165
403
  }
166
404
  }
167
- let combined;
168
- try {
169
- combined = await transport.sync({
170
- clientId,
171
- ...(pushRequest && !wsPushResponse
172
- ? {
173
- push: {
174
- clientCommitId: pushRequest.clientCommitId,
175
- operations: pushRequest.operations,
176
- schemaVersion: pushRequest.schemaVersion,
177
- },
178
- }
179
- : {}),
180
- pull: {
181
- limitCommits: pullState.request.limitCommits,
182
- limitSnapshotRows: pullState.request.limitSnapshotRows,
183
- maxSnapshotPages: pullState.request.maxSnapshotPages,
184
- dedupeRows: pullState.request.dedupeRows,
185
- subscriptions: pullState.request.subscriptions,
186
- },
187
- });
188
- }
189
- catch (err) {
190
- if (outbox) {
191
- const message = err instanceof Error ? err.message : 'Unknown error';
192
- await markOutboxCommitPending(db, { id: outbox.id, error: message });
405
+ const skipPullAfterWsPush = canSkipPullAfterLocalWsPush(pullState, wsPushResponse, options);
406
+ let combined = null;
407
+ let combinedPushCommits = [];
408
+ if (!skipPullAfterWsPush) {
409
+ combinedPushCommits =
410
+ preparedFirstCommit && !wsPushResponse
411
+ ? [
412
+ preparedFirstCommit,
413
+ ...(await (async () => {
414
+ const additionalOutboxCommits = await claimSendableOutboxCommits(db, Math.max(0, (options.maxPushCommits ?? 20) - 1));
415
+ if (additionalOutboxCommits.length === 0)
416
+ return [];
417
+ try {
418
+ return await preparePushCommits(additionalOutboxCommits, {
419
+ clientId: options.clientId,
420
+ actorId: options.actorId,
421
+ plugins,
422
+ });
423
+ }
424
+ catch (error) {
425
+ const normalizedError = normalizeError(error);
426
+ await markClaimedOutboxCommitsPending(db, additionalOutboxCommits, normalizedError.message);
427
+ throw normalizedError;
428
+ }
429
+ })()),
430
+ ]
431
+ : [];
432
+ if (hasIncrementingVersionPlugin(plugins)) {
433
+ combinedPushCommits =
434
+ advanceSequentialBaseVersionsInBatch(combinedPushCommits);
435
+ }
436
+ try {
437
+ combined = await transport.sync({
438
+ clientId,
439
+ ...(combinedPushCommits.length > 0
440
+ ? {
441
+ push: {
442
+ commits: combinedPushCommits.map(({ request }) => ({
443
+ clientCommitId: request.clientCommitId,
444
+ operations: request.operations,
445
+ schemaVersion: request.schemaVersion,
446
+ })),
447
+ },
448
+ }
449
+ : {}),
450
+ pull: {
451
+ limitCommits: pullState.request.limitCommits,
452
+ limitSnapshotRows: pullState.request.limitSnapshotRows,
453
+ maxSnapshotPages: pullState.request.maxSnapshotPages,
454
+ dedupeRows: pullState.request.dedupeRows,
455
+ subscriptions: pullState.request.subscriptions,
456
+ },
457
+ });
458
+ }
459
+ catch (err) {
460
+ if (combinedPushCommits.length > 0) {
461
+ const message = err instanceof Error ? err.message : 'Unknown error';
462
+ await markClaimedOutboxCommitsPending(db, combinedPushCommits.map(({ outboxCommit }) => outboxCommit), message);
463
+ }
464
+ throw err;
193
465
  }
194
- throw err;
195
466
  }
196
467
  // Process push response
197
468
  let pushedCommits = 0;
198
469
  const pushResults = [];
199
- if (outbox && pushRequest) {
200
- let pushRes = wsPushResponse ?? combined.push;
201
- if (!pushRes) {
202
- await markOutboxCommitPending(db, {
203
- id: outbox.id,
204
- error: 'MISSING_PUSH_RESPONSE',
205
- });
206
- throw new Error('Server returned no push response');
207
- }
208
- // Run afterPush plugins
209
- for (const plugin of plugins) {
210
- if (!plugin.afterPush)
211
- continue;
212
- pushRes = await plugin.afterPush(ctx, {
213
- request: pushRequest,
214
- response: pushRes,
215
- });
216
- }
217
- const responseJson = JSON.stringify(pushRes);
218
- if (pushRes.status === 'applied' || pushRes.status === 'cached') {
219
- await markOutboxCommitAcked(db, {
220
- id: outbox.id,
221
- commitSeq: pushRes.commitSeq ?? null,
222
- responseJson,
223
- });
224
- pushResults.push(buildPushResult({
225
- outboxCommitId: outbox.id,
226
- clientCommitId: outbox.client_commit_id,
227
- status: pushRes.status,
228
- response: pushRes,
229
- }));
470
+ if (preparedFirstCommit) {
471
+ if (wsPushResponse) {
472
+ const finalizedFirstCommit = await finalizePushCommit(db, {
473
+ clientId: options.clientId,
474
+ actorId: options.actorId,
475
+ plugins,
476
+ }, preparedFirstCommit, wsPushResponse);
477
+ pushResults.push(finalizedFirstCommit.pushResult);
230
478
  pushedCommits = 1;
479
+ if (finalizedFirstCommit.afterPushError) {
480
+ throw finalizedFirstCommit.afterPushError;
481
+ }
231
482
  }
232
483
  else {
233
- // Check if all errors are retriable
234
- const errorResults = pushRes.results.filter((r) => r.status === 'error');
235
- const allRetriable = errorResults.length > 0 &&
236
- errorResults.every((r) => r.retriable === true);
237
- if (allRetriable) {
238
- await markOutboxCommitPending(db, {
239
- id: outbox.id,
240
- error: 'Retriable',
241
- responseJson,
242
- });
243
- pushResults.push(buildPushResult({
244
- outboxCommitId: outbox.id,
245
- clientCommitId: outbox.client_commit_id,
246
- status: 'retriable',
247
- response: pushRes,
248
- }));
249
- pushedCommits = 1;
484
+ const batchResponses = combined?.push?.commits ?? [];
485
+ const responsesByClientCommitId = new Map(batchResponses.map((response) => [response.clientCommitId, response]));
486
+ if (!combined?.push ||
487
+ combinedPushCommits.some(({ request }) => !responsesByClientCommitId.has(request.clientCommitId))) {
488
+ await markClaimedOutboxCommitsPending(db, combinedPushCommits.map(({ outboxCommit }) => outboxCommit), 'MISSING_PUSH_RESPONSE');
489
+ throw new Error('Server returned incomplete push response');
490
+ }
491
+ let deferredError = null;
492
+ for (const preparedCommit of combinedPushCommits) {
493
+ const batchResponse = responsesByClientCommitId.get(preparedCommit.request.clientCommitId);
494
+ if (!batchResponse)
495
+ continue;
496
+ const finalizedCommit = await finalizePushCommit(db, {
497
+ clientId: options.clientId,
498
+ actorId: options.actorId,
499
+ plugins,
500
+ }, preparedCommit, toSyncPushResponse(batchResponse));
501
+ pushResults.push(finalizedCommit.pushResult);
502
+ pushedCommits += 1;
503
+ if (!deferredError && finalizedCommit.afterPushError) {
504
+ deferredError = finalizedCommit.afterPushError;
505
+ }
250
506
  }
251
- else {
252
- await upsertConflictsForRejectedCommit(db, {
253
- outboxCommitId: outbox.id,
254
- clientCommitId: outbox.client_commit_id,
255
- response: pushRes,
256
- });
257
- await markOutboxCommitFailed(db, {
258
- id: outbox.id,
259
- error: 'REJECTED',
260
- responseJson,
261
- });
262
- pushResults.push(buildPushResult({
263
- outboxCommitId: outbox.id,
264
- clientCommitId: outbox.client_commit_id,
265
- status: 'rejected',
266
- response: pushRes,
267
- }));
268
- pushedCommits = 1;
507
+ if (deferredError) {
508
+ throw deferredError;
269
509
  }
270
510
  }
271
511
  // Settle remaining outbox commits
272
- const remaining = await syncPushUntilSettled(db, transport, {
273
- clientId: options.clientId,
274
- actorId: options.actorId,
275
- plugins: options.plugins,
276
- maxCommits: (options.maxPushCommits ?? 20) - 1,
277
- });
278
- pushedCommits += remaining.pushedCount;
279
- pushResults.push(...remaining.pushResults);
512
+ const remainingMaxCommits = Math.max(0, (options.maxPushCommits ?? 20) - pushedCommits);
513
+ if (remainingMaxCommits > 0) {
514
+ const remaining = await syncPushUntilSettled(db, transport, {
515
+ clientId: options.clientId,
516
+ actorId: options.actorId,
517
+ plugins: options.plugins,
518
+ maxCommits: remainingMaxCommits,
519
+ });
520
+ pushedCommits += remaining.pushedCount;
521
+ pushResults.push(...remaining.pushResults);
522
+ }
280
523
  }
281
524
  // Process pull response
282
525
  let pullResponse = { ok: true, subscriptions: [] };
283
526
  let pullRounds = 0;
284
- if (combined.pull) {
527
+ if (combined?.pull) {
285
528
  pullResponse = await applyPullResponse(db, transport, handlers, pullOpts, pullState, combined.pull);
286
529
  pullRounds = 1;
287
530
  // Continue pulling if more data
288
- if (needsAnotherPull(pullResponse)) {
531
+ if (needsAnotherPull(pullResponse, pullState.request.limitCommits)) {
289
532
  const aggregatedBySubId = new Map();
290
533
  mergePullResponse(aggregatedBySubId, pullResponse);
291
534
  const more = await syncPullUntilSettled(db, transport, handlers, {