@syncular/server 0.42.2 → 0.44.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/src/push.ts CHANGED
@@ -48,6 +48,7 @@ import { authorizeWrite, renderScopeValue, storedScopesForRow } from './scopes';
48
48
  import { decodeColumnVersions, encodeColumnVersions } from './relational-rows';
49
49
  import type {
50
50
  NewChange,
51
+ PreparedPushApply,
51
52
  StorageTransaction,
52
53
  StoredCommit,
53
54
  StoredPushResult,
@@ -108,12 +109,28 @@ type TerminatingOutcome = {
108
109
  type OperationOutcome =
109
110
  | {
110
111
  readonly kind: 'applied';
111
- readonly change: NewChange | undefined;
112
- readonly operation: ValidateCommitOperation;
112
+ readonly changes: readonly NewChange[];
113
+ readonly operations: readonly ValidateCommitOperation[];
113
114
  readonly staged: StagedWrite;
114
115
  }
115
116
  | TerminatingOutcome;
116
117
 
118
+ /**
119
+ * A server-authoritative scope move (§2.2). Commands emit it through
120
+ * `CommandMutation`; it is not an SSP2 wire operation, so a client cannot
121
+ * request one. Apply rewrites the stored row in place under the new scopes
122
+ * and emits the delete/upsert change pair in one commit.
123
+ */
124
+ export interface RescopeOperation {
125
+ readonly table: string;
126
+ readonly rowId: string;
127
+ readonly op: 'rescope';
128
+ /** Full-row payload (the row's values under the new scopes). */
129
+ readonly payload: Uint8Array;
130
+ }
131
+
132
+ export type ApplyOperation = PushOperation | RescopeOperation;
133
+
117
134
  function errorRecord(
118
135
  opIndex: number,
119
136
  code: string,
@@ -296,7 +313,7 @@ async function applyOperation(
296
313
  schema: CompiledSchema,
297
314
  wireSchema: CompiledSchema,
298
315
  resolved: ResolvedScopes,
299
- op: PushOperation,
316
+ op: ApplyOperation,
300
317
  opIndex: number,
301
318
  blobCtx: BlobApplyContext,
302
319
  crdtMergers: CrdtMergerRegistry | undefined,
@@ -322,20 +339,224 @@ async function applyOperation(
322
339
  }
323
340
  const stored = await tx.getRow(op.table, op.rowId);
324
341
 
325
- if (op.op === 'delete') {
342
+ if (op.op === 'rescope') {
343
+ // §2.2 explicit scope move. One stored row changes its scope values;
344
+ // apply rewrites it in place, keeps `server_version`, and emits the
345
+ // delete under the old scopes plus the upsert under the new ones.
326
346
  if (stored === undefined) {
327
- // Deleting an absent row is applied (idempotent, §6.2); no change.
328
- return {
329
- kind: 'applied',
330
- change: undefined,
331
- operation: {
347
+ return errorRecord(
348
+ opIndex,
349
+ 'sync.row_missing',
350
+ 'rescope targets an absent row (§2.2)',
351
+ );
352
+ }
353
+ if (!authorizeWrite(table, stored.scopes, resolved)) {
354
+ return errorRecord(
355
+ opIndex,
356
+ 'sync.forbidden',
357
+ 'rescope denied by scope authorization (§3.4)',
358
+ );
359
+ }
360
+ const missing = missingScopeVariable(table, stored.scopes);
361
+ if (missing !== undefined) {
362
+ return errorRecord(
363
+ opIndex,
364
+ 'sync.missing_scopes',
365
+ `stored row lacks scope variable ${JSON.stringify(missing)} (§3.1)`,
366
+ );
367
+ }
368
+ let sparse: SparseRowValue[];
369
+ try {
370
+ sparse = decodeSparseRow(
371
+ wireTable.columns,
372
+ wireTable.primaryKeyIndex,
373
+ op.payload,
374
+ );
375
+ } catch (error) {
376
+ return errorRecord(
377
+ opIndex,
378
+ 'sync.invalid_request',
379
+ `rescope payload failed row-codec decode (§1.7): ${error instanceof Error ? error.message : String(error)}`,
380
+ );
381
+ }
382
+ if (
383
+ sparse.length < table.columns.length ||
384
+ sparse.some((value) => value === undefined)
385
+ ) {
386
+ return errorRecord(
387
+ opIndex,
388
+ 'sync.invalid_request',
389
+ 'rescope requires a full-row payload (§2.2)',
390
+ );
391
+ }
392
+ const values: RowValue[] = sparse.map((value) => value ?? null);
393
+ const pkValue = renderScopeValue(values[table.primaryKeyIndex] ?? null);
394
+ if (pkValue !== op.rowId) {
395
+ return errorRecord(
396
+ opIndex,
397
+ 'sync.invalid_request',
398
+ 'payload primary key does not match rowId',
399
+ );
400
+ }
401
+ const storedValues = decodeRow(table.columns, stored.payload);
402
+ const scopeIndexes = new Set(
403
+ table.scopePatterns.map((pattern) => pattern.columnIndex),
404
+ );
405
+ const sameValue = (left: RowValue, right: RowValue): boolean => {
406
+ if (left instanceof Uint8Array || right instanceof Uint8Array) {
407
+ return (
408
+ left instanceof Uint8Array &&
409
+ right instanceof Uint8Array &&
410
+ left.length === right.length &&
411
+ left.every((byte, index) => byte === right[index])
412
+ );
413
+ }
414
+ return left === right;
415
+ };
416
+ for (let index = 0; index < table.columns.length; index++) {
417
+ if (index === table.primaryKeyIndex || scopeIndexes.has(index)) continue;
418
+ if (!sameValue(values[index] ?? null, storedValues[index] ?? null)) {
419
+ return errorRecord(
420
+ opIndex,
421
+ 'sync.invalid_request',
422
+ 'rescope may change only scope columns (§2.2)',
423
+ );
424
+ }
425
+ }
426
+ const extracted = storedScopesForRow(table, values);
427
+ if ('missing' in extracted) {
428
+ return errorRecord(
429
+ opIndex,
430
+ 'sync.forbidden',
431
+ `rescope missing scope column value for ${JSON.stringify(extracted.missing)} (§3.4)`,
432
+ );
433
+ }
434
+ if (!authorizeWrite(table, extracted.scopes, resolved)) {
435
+ return errorRecord(
436
+ opIndex,
437
+ 'sync.forbidden',
438
+ 'rescope denied by scope authorization of the new scopes (§3.4)',
439
+ );
440
+ }
441
+ const deleteReject = await runValidator(
442
+ validators,
443
+ table,
444
+ 'delete',
445
+ op.rowId,
446
+ undefined,
447
+ storedValues,
448
+ opIndex,
449
+ tx,
450
+ partition,
451
+ actorId,
452
+ );
453
+ if (deleteReject !== undefined) return deleteReject;
454
+ const upsertReject = await runValidator(
455
+ validators,
456
+ table,
457
+ 'upsert',
458
+ op.rowId,
459
+ values,
460
+ storedValues,
461
+ opIndex,
462
+ tx,
463
+ partition,
464
+ actorId,
465
+ );
466
+ if (upsertReject !== undefined) return upsertReject;
467
+ const blobCheck = await checkAndRecordBlobs(
468
+ tx,
469
+ table,
470
+ op.rowId,
471
+ values,
472
+ opIndex,
473
+ blobCtx,
474
+ );
475
+ if (blobCheck !== undefined) return blobCheck;
476
+ const payload = encodeRow(table.columns, values);
477
+ // The move clears any tombstone from an earlier incarnation: the row is
478
+ // live at the end of this commit, so the next move must not hit §5.2.
479
+ await tx.clearTombstone(op.table, op.rowId);
480
+ await tx.upsertRow(
481
+ op.table,
482
+ {
483
+ rowId: op.rowId,
484
+ serverVersion: stored.serverVersion,
485
+ scopes: extracted.scopes,
486
+ payload,
487
+ ...(stored.columnVersions !== undefined
488
+ ? { columnVersions: stored.columnVersions }
489
+ : {}),
490
+ },
491
+ { opIndex },
492
+ );
493
+ const storedRow = toValidateRow(table.columns, storedValues);
494
+ return {
495
+ kind: 'applied',
496
+ changes: [
497
+ {
498
+ table: op.table,
499
+ rowId: op.rowId,
500
+ op: 'delete',
501
+ scopes: stored.scopes,
502
+ },
503
+ {
504
+ table: op.table,
505
+ rowId: op.rowId,
506
+ op: 'upsert',
507
+ rowVersion: stored.serverVersion,
508
+ scopes: extracted.scopes,
509
+ payload,
510
+ },
511
+ ],
512
+ operations: [
513
+ {
332
514
  opIndex,
333
515
  op: 'delete',
334
516
  table: table.name,
335
517
  rowId: op.rowId,
336
518
  row: undefined,
337
- stored: undefined,
519
+ stored: storedRow,
520
+ storedServerVersion: stored.serverVersion,
338
521
  },
522
+ {
523
+ opIndex,
524
+ op: 'upsert',
525
+ table: table.name,
526
+ rowId: op.rowId,
527
+ row: toValidateRow(table.columns, values),
528
+ stored: storedRow,
529
+ storedServerVersion: stored.serverVersion,
530
+ nextServerVersion: stored.serverVersion,
531
+ },
532
+ ],
533
+ staged: {
534
+ table,
535
+ op: 'upsert',
536
+ rowId: op.rowId,
537
+ opIndex,
538
+ values,
539
+ stored,
540
+ },
541
+ };
542
+ }
543
+
544
+ if (op.op === 'delete') {
545
+ if (stored === undefined) {
546
+ // Deleting an absent row is applied (idempotent, §6.2); no change.
547
+ return {
548
+ kind: 'applied',
549
+ changes: [],
550
+ operations: [
551
+ {
552
+ opIndex,
553
+ op: 'delete',
554
+ table: table.name,
555
+ rowId: op.rowId,
556
+ row: undefined,
557
+ stored: undefined,
558
+ },
559
+ ],
339
560
  staged: {
340
561
  table,
341
562
  op: 'delete',
@@ -380,21 +601,25 @@ async function applyOperation(
380
601
  await tx.deleteRow(op.table, op.rowId);
381
602
  return {
382
603
  kind: 'applied',
383
- change: {
384
- table: op.table,
385
- rowId: op.rowId,
386
- op: 'delete',
387
- scopes: stored.scopes,
388
- },
389
- operation: {
390
- opIndex,
391
- op: 'delete',
392
- table: table.name,
393
- rowId: op.rowId,
394
- row: undefined,
395
- stored: toValidateRow(table.columns, storedValues),
396
- storedServerVersion: stored.serverVersion,
397
- },
604
+ changes: [
605
+ {
606
+ table: op.table,
607
+ rowId: op.rowId,
608
+ op: 'delete',
609
+ scopes: stored.scopes,
610
+ },
611
+ ],
612
+ operations: [
613
+ {
614
+ opIndex,
615
+ op: 'delete',
616
+ table: table.name,
617
+ rowId: op.rowId,
618
+ row: undefined,
619
+ stored: toValidateRow(table.columns, storedValues),
620
+ storedServerVersion: stored.serverVersion,
621
+ },
622
+ ],
398
623
  staged: { table, op: 'delete', rowId: op.rowId, opIndex, stored },
399
624
  };
400
625
  }
@@ -586,24 +811,28 @@ async function applyOperation(
586
811
  await tx.upsertRow(op.table, newRow, { opIndex });
587
812
  return {
588
813
  kind: 'applied',
589
- change: {
590
- table: op.table,
591
- rowId: op.rowId,
592
- op: 'upsert',
593
- rowVersion: newVersion,
594
- scopes: stored.scopes,
595
- payload: newPayload,
596
- },
597
- operation: {
598
- opIndex,
599
- op: 'upsert',
600
- table: table.name,
601
- rowId: op.rowId,
602
- row: toValidateRow(table.columns, values),
603
- stored: toValidateRow(table.columns, storedValues),
604
- storedServerVersion: stored.serverVersion,
605
- nextServerVersion: newVersion,
606
- },
814
+ changes: [
815
+ {
816
+ table: op.table,
817
+ rowId: op.rowId,
818
+ op: 'upsert',
819
+ rowVersion: newVersion,
820
+ scopes: stored.scopes,
821
+ payload: newPayload,
822
+ },
823
+ ],
824
+ operations: [
825
+ {
826
+ opIndex,
827
+ op: 'upsert',
828
+ table: table.name,
829
+ rowId: op.rowId,
830
+ row: toValidateRow(table.columns, values),
831
+ stored: toValidateRow(table.columns, storedValues),
832
+ storedServerVersion: stored.serverVersion,
833
+ nextServerVersion: newVersion,
834
+ },
835
+ ],
607
836
  staged: { table, op: 'upsert', rowId: op.rowId, opIndex, values, stored },
608
837
  };
609
838
  }
@@ -721,23 +950,27 @@ async function applyOperation(
721
950
  await tx.upsertRow(op.table, newRow, { opIndex });
722
951
  return {
723
952
  kind: 'applied',
724
- change: {
725
- table: op.table,
726
- rowId: op.rowId,
727
- op: 'upsert',
728
- rowVersion: 1,
729
- scopes: extracted.scopes,
730
- payload: insertPayload,
731
- },
732
- operation: {
733
- opIndex,
734
- op: 'upsert',
735
- table: table.name,
736
- rowId: op.rowId,
737
- row: toValidateRow(table.columns, values),
738
- stored: undefined,
739
- nextServerVersion: 1,
740
- },
953
+ changes: [
954
+ {
955
+ table: op.table,
956
+ rowId: op.rowId,
957
+ op: 'upsert',
958
+ rowVersion: 1,
959
+ scopes: extracted.scopes,
960
+ payload: insertPayload,
961
+ },
962
+ ],
963
+ operations: [
964
+ {
965
+ opIndex,
966
+ op: 'upsert',
967
+ table: table.name,
968
+ rowId: op.rowId,
969
+ row: toValidateRow(table.columns, values),
970
+ stored: undefined,
971
+ nextServerVersion: 1,
972
+ },
973
+ ],
741
974
  staged: {
742
975
  table,
743
976
  op: 'upsert',
@@ -1342,6 +1575,16 @@ export async function processPushCommitWithTrace(
1342
1575
  frame: PushCommitFrame,
1343
1576
  wireSchema: CompiledSchema = schema,
1344
1577
  ): Promise<ProcessedPushCommit> {
1578
+ // The frame names its rows before the transaction opens, so the admission
1579
+ // read carries the commit's read-ahead and the apply issues no separate
1580
+ // prefetch statement.
1581
+ const targets = new Map<string, string[]>();
1582
+ for (const op of frame.operations) {
1583
+ if (!schema.tables.has(op.table)) continue;
1584
+ const ids = targets.get(op.table) ?? [];
1585
+ ids.push(op.rowId);
1586
+ targets.set(op.table, ids);
1587
+ }
1345
1588
  return processPushOperationsWithTrace(
1346
1589
  ctx,
1347
1590
  schema,
@@ -1350,6 +1593,9 @@ export async function processPushCommitWithTrace(
1350
1593
  frame.clientCommitId,
1351
1594
  async () => frame.operations,
1352
1595
  wireSchema,
1596
+ targets.size === 0
1597
+ ? undefined
1598
+ : [...targets].map(([table, rowIds]) => ({ table, rowIds })),
1353
1599
  );
1354
1600
  }
1355
1601
 
@@ -1366,88 +1612,48 @@ export async function processPushOperationsWithTrace(
1366
1612
  clientCommitId: string,
1367
1613
  buildOperations: (
1368
1614
  tx: StorageTransaction,
1369
- ) => Promise<readonly PushOperation[]>,
1615
+ ) => Promise<readonly ApplyOperation[]>,
1370
1616
  wireSchema: CompiledSchema = schema,
1617
+ prefetchTargets?: readonly {
1618
+ readonly table: string;
1619
+ readonly rowIds: readonly string[];
1620
+ }[],
1371
1621
  ): Promise<ProcessedPushCommit> {
1372
- const { storage, partition } = ctx;
1373
- let persisted: StoredPushResult | undefined;
1374
- try {
1375
- persisted = await storage.getPushResult(
1376
- partition,
1377
- clientId,
1378
- clientCommitId,
1379
- );
1380
- } catch (error) {
1381
- if (
1382
- error instanceof SyncError &&
1383
- error.code === 'sync.idempotency_cache_miss'
1384
- ) {
1385
- // §6.3: answer the retryable cache-miss for this commit rather than
1386
- // re-applying. Not persisted — a retry may find a readable record.
1387
- return {
1388
- frame: idempotencyCacheMissFrame(clientCommitId, error),
1389
- replayed: false,
1390
- };
1391
- }
1392
- throw error;
1393
- }
1394
- if (persisted !== undefined) {
1395
- return processedPushCommit(clientCommitId, persisted, true);
1396
- }
1397
-
1622
+ const { partition } = ctx;
1398
1623
  const createdAtMs = clockOf(ctx)();
1399
1624
  const blobCtx: BlobApplyContext = { store: ctx.blobs, partition };
1400
1625
  const crdtMergers = ctx.crdtMergers;
1401
1626
  const validators = ctx.validators;
1402
1627
  const commitValidator = ctx.commitValidator;
1403
1628
  const reactionPlanner = ctx.reactionPlanner;
1404
- const tx = await storage.begin(partition);
1405
- const lockPartitionForPush =
1406
- tx.lockPartitionForPush?.bind(tx) ??
1407
- tx.lockPartitionForCommitValidation?.bind(tx);
1408
- const commitRejectedPushResult = tx.commitRejectedPushResult?.bind(tx);
1629
+ const tx = await ctx.storage.begin(partition);
1409
1630
  try {
1410
- if (
1411
- lockPartitionForPush === undefined ||
1412
- commitRejectedPushResult === undefined ||
1413
- (reactionPlanner !== undefined && tx.enqueueReactions === undefined)
1414
- ) {
1631
+ if (reactionPlanner !== undefined && tx.enqueueReactions === undefined) {
1415
1632
  throw new Error(
1416
- 'storage transaction does not support serialized push apply, atomic rejection finalization, and configured durable reactions',
1633
+ 'storage transaction does not support configured durable reactions',
1417
1634
  );
1418
1635
  }
1419
- await lockPartitionForPush();
1420
- // RFC 0007: evaluate the gate on this transaction's own connection while
1421
- // the partition lock is held, so the migration check and the write share
1422
- // one transaction and no migration can interleave in between.
1423
- const gate = await tx.readServeGate(schema.version);
1424
- const refusal = serveGateRefusal(gate, schema.version, ctx.checkpoints);
1425
- if (refusal !== undefined) {
1426
- await tx.rollback();
1427
- throw serveNotReadyError(refusal);
1428
- }
1429
- // The optimistic lookup above may have raced another delivery. Re-check
1430
- // only after acquiring partition serialization and before any operation
1431
- // read, validation, merge, or staged write. The re-check runs on the
1432
- // transaction's own connection when the backend provides one: a pooled
1433
- // Postgres client holding the partition lock would otherwise wait for a
1434
- // second pool slot and deadlock against pushes waiting on the lock. A
1435
- // racing duplicate's result commits before the lock releases, so the
1436
- // transaction-scoped read observes it.
1636
+ // The storage takes the partition lock and then reads the admission token
1637
+ // on the transaction's own connection: the RFC 0007 serve gate and the
1638
+ // §2.3 idempotency record, plus any read-ahead the caller already knows.
1639
+ // A racing duplicate's result commits before the lock releases, so the
1640
+ // later read is exact and a duplicate never reruns an operation,
1641
+ // validator, or merge.
1642
+ let prepared: PreparedPushApply;
1437
1643
  try {
1438
- const serializedPersisted =
1439
- tx.getPushResult !== undefined
1440
- ? await tx.getPushResult(clientId, clientCommitId)
1441
- : await storage.getPushResult(partition, clientId, clientCommitId);
1442
- if (serializedPersisted !== undefined) {
1443
- await tx.rollback();
1444
- return processedPushCommit(clientCommitId, serializedPersisted, true);
1445
- }
1644
+ prepared = await tx.preparePushApply(
1645
+ clientId,
1646
+ clientCommitId,
1647
+ schema.version,
1648
+ prefetchTargets,
1649
+ );
1446
1650
  } catch (error) {
1447
1651
  if (
1448
1652
  error instanceof SyncError &&
1449
1653
  error.code === 'sync.idempotency_cache_miss'
1450
1654
  ) {
1655
+ // §6.3: answer the retryable cache-miss for this commit rather than
1656
+ // re-applying. Not persisted — a retry may find a readable record.
1451
1657
  await tx.rollback();
1452
1658
  return {
1453
1659
  frame: idempotencyCacheMissFrame(clientCommitId, error),
@@ -1456,6 +1662,22 @@ export async function processPushOperationsWithTrace(
1456
1662
  }
1457
1663
  throw error;
1458
1664
  }
1665
+ // RFC 0007: the gate is evaluated while the partition lock is held, so
1666
+ // the migration check and the write share one transaction and no
1667
+ // migration can interleave in between.
1668
+ const refusal = serveGateRefusal(
1669
+ prepared.gate,
1670
+ schema.version,
1671
+ ctx.checkpoints,
1672
+ );
1673
+ if (refusal !== undefined) {
1674
+ await tx.rollback();
1675
+ throw serveNotReadyError(refusal);
1676
+ }
1677
+ if (prepared.persisted !== undefined) {
1678
+ await tx.rollback();
1679
+ return processedPushCommit(clientCommitId, prepared.persisted, true);
1680
+ }
1459
1681
  const results: PushOperationResult[] = [];
1460
1682
  const changes: NewChange[] = [];
1461
1683
  const validatedOperations: ValidateCommitOperation[] = [];
@@ -1470,8 +1692,10 @@ export async function processPushOperationsWithTrace(
1470
1692
  ids.push(op.rowId);
1471
1693
  targets.set(op.table, ids);
1472
1694
  }
1473
- for (const [table, rowIds] of targets) {
1474
- await tx.prefetchRows(table, rowIds);
1695
+ if (targets.size > 0) {
1696
+ await tx.prefetchRows(
1697
+ [...targets].map(([table, rowIds]) => ({ table, rowIds })),
1698
+ );
1475
1699
  }
1476
1700
  }
1477
1701
  for (let opIndex = 0; opIndex < operations.length; opIndex++) {
@@ -1495,9 +1719,9 @@ export async function processPushOperationsWithTrace(
1495
1719
  break;
1496
1720
  }
1497
1721
  results.push({ opIndex, status: 'applied' });
1498
- validatedOperations.push(outcome.operation);
1722
+ validatedOperations.push(...outcome.operations);
1499
1723
  stagedWrites.push(outcome.staged);
1500
- if (outcome.change !== undefined) changes.push(outcome.change);
1724
+ changes.push(...outcome.changes);
1501
1725
  }
1502
1726
 
1503
1727
  // §6.11: reference enforcement runs after every client operation is
@@ -1570,18 +1794,13 @@ export async function processPushOperationsWithTrace(
1570
1794
  results: [terminated],
1571
1795
  });
1572
1796
  // Discard candidates and persist the rejection while retaining the same
1573
- // partition lock. There is no unlock gap in which a duplicate can rerun.
1574
- await commitRejectedPushResult(clientId, clientCommitId, stored);
1575
- const canonical = await storage.getPushResult(
1576
- partition,
1797
+ // partition lock. There is no unlock gap in which a duplicate can rerun,
1798
+ // and the finalization returns the canonical outcome it persisted.
1799
+ const canonical = await tx.commitRejectedPushResult(
1577
1800
  clientId,
1578
1801
  clientCommitId,
1802
+ stored,
1579
1803
  );
1580
- if (canonical === undefined) {
1581
- throw new Error(
1582
- 'push rejection finalization did not persist an outcome',
1583
- );
1584
- }
1585
1804
  return processedPushCommit(
1586
1805
  clientCommitId,
1587
1806
  canonical,
@@ -1663,24 +1882,12 @@ export async function processPushOperationsWithTrace(
1663
1882
  },
1664
1883
  ],
1665
1884
  });
1666
- if (commitRejectedPushResult === undefined) {
1667
- await tx.rollback();
1668
- throw new Error(
1669
- 'storage transaction lost atomic push rejection finalization support',
1670
- );
1671
- }
1672
1885
  try {
1673
- await commitRejectedPushResult(clientId, clientCommitId, stored);
1674
- const canonical = await storage.getPushResult(
1675
- partition,
1886
+ const canonical = await tx.commitRejectedPushResult(
1676
1887
  clientId,
1677
1888
  clientCommitId,
1889
+ stored,
1678
1890
  );
1679
- if (canonical === undefined) {
1680
- throw new Error(
1681
- 'push rejection finalization did not persist an outcome',
1682
- );
1683
- }
1684
1891
  return processedPushCommit(
1685
1892
  clientCommitId,
1686
1893
  canonical,