@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/dist/storage.d.ts CHANGED
@@ -67,6 +67,17 @@ export interface ServeGate {
67
67
  readonly name: string;
68
68
  }[];
69
69
  }
70
+ /**
71
+ * The push apply's admission token: the partition lock, the serve gate, and
72
+ * the idempotency record read in one round trip. A storage with per-statement
73
+ * network cost answers all three with one statement (see
74
+ * `PostgresServerStorage`); the lock is held from this call through commit.
75
+ */
76
+ export interface PreparedPushApply {
77
+ readonly gate: ServeGate;
78
+ /** The already-persisted outcome for this key, or `undefined` on a miss. */
79
+ readonly persisted: StoredPushResult | undefined;
80
+ }
70
81
  /** Why `serveGate` refuses, or `undefined` when this request may serve. */
71
82
  export type ServeGateRefusal = {
72
83
  readonly kind: 'schema';
@@ -128,6 +139,13 @@ export interface NewCommit {
128
139
  readonly createdAtMs: number;
129
140
  readonly changes: readonly NewChange[];
130
141
  }
142
+ /**
143
+ * §2.2: rows whose final change in this commit is an upsert that followed a
144
+ * delete. That delete is one half of a scope move, so it MUST NOT leave a
145
+ * tombstone behind the live row. A row whose final change is a delete keeps
146
+ * its tombstone.
147
+ */
148
+ export declare function rowsLiveAfterDelete(changes: readonly NewChange[]): Set<string>;
131
149
  export interface StoredChange {
132
150
  readonly table: string;
133
151
  readonly rowId: string;
@@ -409,12 +427,15 @@ export interface StorageTransaction {
409
427
  getRow(table: string, rowId: string): Promise<StoredRow | undefined>;
410
428
  /**
411
429
  * Optional read-ahead: the push layer names every row a commit's
412
- * operations target, per table, before it applies them. A storage with
413
- * per-statement network cost reads them in one statement and answers the
414
- * following `getRow` calls from that snapshot until the row is written in
415
- * this transaction.
430
+ * operations target, across every table, before it applies them. A storage
431
+ * with per-statement network cost reads them in one statement (one branch
432
+ * per table) and answers the following `getRow` calls from that snapshot
433
+ * until the row is written in this transaction.
416
434
  */
417
- prefetchRows?(table: string, rowIds: readonly string[]): Promise<void>;
435
+ prefetchRows?(targets: readonly {
436
+ readonly table: string;
437
+ readonly rowIds: readonly string[];
438
+ }[]): Promise<void>;
418
439
  /**
419
440
  * §5 delete-precedence tombstone read: the `commit_seq` of the newest
420
441
  * applied delete of this row still within the pruning horizon, or
@@ -429,17 +450,6 @@ export interface StorageTransaction {
429
450
  * over an absent row; a no-op when no tombstone exists.
430
451
  */
431
452
  clearTombstone(table: string, rowId: string): Promise<void>;
432
- /**
433
- * Optional transaction-scoped idempotency lookup (§2.3) with the same
434
- * semantics as `ServerStorage.getPushResult`, including the
435
- * `sync.idempotency_cache_miss` throw. The push layer prefers it for the
436
- * post-serialization duplicate re-check so the read runs on the
437
- * transaction's own connection — a pooled Postgres client that holds the
438
- * partition lock must never wait for a second pool slot mid-push. In-tree
439
- * SQLite/Postgres/D1 adapters implement it; a custom adapter that omits it
440
- * keeps the pool-level read.
441
- */
442
- getPushResult?(clientId: string, clientCommitId: string): Promise<StoredPushResult | undefined>;
443
453
  /**
444
454
  * Optional candidate-state scan used only by whole-commit validation.
445
455
  * In-tree SQLite/Postgres/D1 backends implement it with read-your-own-writes
@@ -467,24 +477,28 @@ export interface StorageTransaction {
467
477
  queryAuthoritative?(query: AuthoritativeQueryRequest): Promise<AuthoritativeQueryResult>;
468
478
  /**
469
479
  * Serialize every push apply for this partition before any operation read,
470
- * validation, merge, or write. The push layer re-checks idempotency only
471
- * after this resolves and retains the lock through terminal-result commit.
480
+ * validation, merge, or write, and read the admission token in the same
481
+ * round trip: the RFC 0007 serve gate and the §2.3 idempotency record for
482
+ * `clientCommitId`. The lock is retained through terminal-result commit,
483
+ * so the idempotency read is exact and a racing duplicate cannot rerun.
484
+ * `targets` are the rows the commit is known to touch before its operations
485
+ * are built; a storage with per-statement network cost folds that read-ahead
486
+ * into the admission read and answers the following `getRow` calls from it.
472
487
  * Missing support fails closed before an app-row mutation.
473
488
  */
474
- lockPartitionForPush?(): Promise<void>;
475
- /**
476
- * @deprecated Implement `lockPartitionForPush`. Kept as a compatibility
477
- * bridge for custom adapters whose existing implementation already locks
478
- * the complete partition from before candidate reads through commit.
479
- */
480
- lockPartitionForCommitValidation?(): Promise<void>;
489
+ preparePushApply(clientId: string, clientCommitId: string, runningSchemaVersion: number, targets?: readonly {
490
+ readonly table: string;
491
+ readonly rowIds: readonly string[];
492
+ }[]): Promise<PreparedPushApply>;
481
493
  /**
482
494
  * Rejection finalization while the push-apply serialization lock is still
483
495
  * held: discard every candidate write, persist the rejected idempotency
484
- * result, and finish the transaction atomically. Required for every push so
485
- * a concurrent duplicate cannot rerun operations, validators, or merges.
496
+ * result, and finish the transaction atomically. Returns the persisted
497
+ * canonical outcome, so the caller never re-reads it. Required for every
498
+ * push so a concurrent duplicate cannot rerun operations, validators, or
499
+ * merges.
486
500
  */
487
- commitRejectedPushResult?(clientId: string, clientCommitId: string, result: StoredPushResult): Promise<void>;
501
+ commitRejectedPushResult(clientId: string, clientCommitId: string, result: StoredPushResult): Promise<StoredPushResult>;
488
502
  upsertRow(table: string, row: StoredRow, context?: {
489
503
  readonly opIndex: number;
490
504
  }): Promise<void>;
@@ -566,10 +580,10 @@ export interface ServerStorage {
566
580
  * same command races a push on Postgres and a SQLite-only test conceals it.
567
581
  *
568
582
  * A trusted command must therefore run through `registerRemoteCommand`,
569
- * which builds after `lockPartitionForPush` and the idempotency recheck and
570
- * shares the committed transaction, or call `lockPartitionForPush` before
571
- * its first authority, version, or replay read. SSP2 request handling does
572
- * this for you; only host-authored commands choose.
583
+ * which builds after `preparePushApply` and shares the committed
584
+ * transaction, or call `preparePushApply` before its first authority,
585
+ * version, or replay read. SSP2 request handling does this for you; only
586
+ * host-authored commands choose.
573
587
  */
574
588
  begin(partition: string): Promise<StorageTransaction>;
575
589
  getMaxCommitSeq(partition: string): Promise<number>;
package/dist/storage.js CHANGED
@@ -38,6 +38,32 @@ export function serveGateTokenChanged(before, after) {
38
38
  }
39
39
  return undefined;
40
40
  }
41
+ /**
42
+ * §2.2: rows whose final change in this commit is an upsert that followed a
43
+ * delete. That delete is one half of a scope move, so it MUST NOT leave a
44
+ * tombstone behind the live row. A row whose final change is a delete keeps
45
+ * its tombstone.
46
+ */
47
+ export function rowsLiveAfterDelete(changes) {
48
+ const last = new Map();
49
+ const deleted = new Set();
50
+ const live = new Set();
51
+ for (const change of changes) {
52
+ const key = `${change.table}\u0000${change.rowId}`;
53
+ if (change.op === 'delete') {
54
+ deleted.add(key);
55
+ }
56
+ else if (deleted.has(key)) {
57
+ live.add(key);
58
+ }
59
+ last.set(key, change.op);
60
+ }
61
+ for (const key of live) {
62
+ if (last.get(key) !== 'upsert')
63
+ live.delete(key);
64
+ }
65
+ return live;
66
+ }
41
67
  /** The round-state fields of a stored `sync_clients` row. */
42
68
  export function clientRoundState(lastRoundAtMs, lastErrorCode, consecutiveFailures) {
43
69
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@syncular/server",
3
- "version": "0.42.2",
3
+ "version": "0.44.0",
4
4
  "description": "Syncular server: handleSyncRequest + storage/auth interfaces for the sync protocol",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Benjamin Kniffler",
@@ -68,7 +68,7 @@
68
68
  "verify:node": "cd ../.. && bun build ./packages/server/test/sqlite-runtime/verify-node.mjs --target=node --conditions=bun --outfile=./packages/server/.verify-node.built.mjs && node ./packages/server/.verify-node.built.mjs"
69
69
  },
70
70
  "dependencies": {
71
- "@syncular/core": "0.42.2"
71
+ "@syncular/core": "0.44.0"
72
72
  },
73
73
  "devDependencies": {
74
74
  "@electric-sql/pglite": "^0.5.4"
package/src/d1-storage.ts CHANGED
@@ -1,7 +1,11 @@
1
1
  import { validateCommitPruneQuery } from './prune';
2
2
  import { StorageQueryError } from './storage-errors';
3
3
  import type { CommitPruneQuery, CommitPruneResult } from './storage';
4
- import { clientRoundState, PARTITION_TOUCH_INTERVAL_MS } from './storage';
4
+ import {
5
+ clientRoundState,
6
+ PARTITION_TOUCH_INTERVAL_MS,
7
+ rowsLiveAfterDelete,
8
+ } from './storage';
5
9
  /**
6
10
  * Cloudflare D1 server storage for Workers deployments.
7
11
  *
@@ -127,6 +131,7 @@ import type {
127
131
  IndexRowScanQuery,
128
132
  NewCommit,
129
133
  NewReaction,
134
+ PreparedPushApply,
130
135
  PartitionRegistryEntry,
131
136
  PrunedReactionCounts,
132
137
  ReactionClaimQuery,
@@ -546,7 +551,15 @@ class D1Transaction implements StorageTransaction {
546
551
  );
547
552
  }
548
553
 
549
- async lockPartitionForPush(): Promise<void> {
554
+ async preparePushApply(
555
+ clientId: string,
556
+ clientCommitId: string,
557
+ runningSchemaVersion: number,
558
+ _targets: readonly {
559
+ readonly table: string;
560
+ readonly rowIds: readonly string[];
561
+ }[] = [],
562
+ ): Promise<PreparedPushApply> {
550
563
  this.#assertOpen();
551
564
  if (!this.#pushApplySerialized) {
552
565
  throw new Error(
@@ -556,6 +569,10 @@ class D1Transaction implements StorageTransaction {
556
569
  // D1 has no interactive lock. The caller explicitly asserted that every
557
570
  // write for this partition is already serialized (normally by its DO).
558
571
  this.#pushApplyCheckpoint = this.#buffer.length;
572
+ return {
573
+ gate: await this.readServeGate(runningSchemaVersion),
574
+ persisted: await this.getPushResult(clientId, clientCommitId),
575
+ };
559
576
  }
560
577
 
561
578
  advanceCheckpoint(
@@ -587,7 +604,7 @@ class D1Transaction implements StorageTransaction {
587
604
  clientId: string,
588
605
  clientCommitId: string,
589
606
  result: StoredPushResult,
590
- ): Promise<void> {
607
+ ): Promise<StoredPushResult> {
591
608
  this.#assertOpen();
592
609
  const checkpoint = this.#pushApplyCheckpoint;
593
610
  if (checkpoint === undefined) {
@@ -600,6 +617,9 @@ class D1Transaction implements StorageTransaction {
600
617
  this.#applicationOpIndexes.clear();
601
618
  await this.putPushResult(clientId, clientCommitId, result);
602
619
  await this.commit();
620
+ // The idempotency re-check found no record under the serialized apply, so
621
+ // the insert above is the canonical outcome.
622
+ return result;
603
623
  }
604
624
 
605
625
  async #assertNoUniqueCollision(
@@ -790,6 +810,7 @@ class D1Transaction implements StorageTransaction {
790
810
  commit.createdAtMs,
791
811
  ],
792
812
  );
813
+ const liveAfterDelete = rowsLiveAfterDelete(commit.changes);
793
814
  commit.changes.forEach((change, idx) => {
794
815
  this.#buffer_(
795
816
  'INSERT INTO sync_changes(partition, commit_seq, idx, tbl, row_id, op, row_version, scopes, payload) VALUES (?,?,?,?,?,?,?,?,?)',
@@ -805,9 +826,13 @@ class D1Transaction implements StorageTransaction {
805
826
  change.payload ?? null,
806
827
  ],
807
828
  );
808
- if (change.op === 'delete') {
829
+ if (
830
+ change.op === 'delete' &&
831
+ !liveAfterDelete.has(`${change.table}\u0000${change.rowId}`)
832
+ ) {
809
833
  // §5 delete precedence: every applied delete leaves a tombstone,
810
- // pruned with the commit log (§4.6).
834
+ // pruned with the commit log (§4.6). A delete that the same commit
835
+ // follows with an upsert is a §2.2 scope move, not a deletion.
811
836
  this.#buffer_(
812
837
  `INSERT INTO sync_tombstones(partition, tbl, row_id, commit_seq) VALUES (?,?,?,?)
813
838
  ON CONFLICT(partition, tbl, row_id) DO UPDATE SET commit_seq=excluded.commit_seq`,
package/src/operations.ts CHANGED
@@ -7,7 +7,6 @@ import {
7
7
  decodeRemoteOperationRequest,
8
8
  encodeRemoteOperationResponse,
9
9
  encodeSparseRow,
10
- type PushOperation,
11
10
  type RemoteOperationRequest,
12
11
  type RemoteOperationResponse,
13
12
  type RowValue,
@@ -20,7 +19,7 @@ import {
20
19
  touchAuthenticatedPartition,
21
20
  } from './context';
22
21
  import { reportError, SyncError, syncError } from './errors';
23
- import { processPushOperationsWithTrace } from './push';
22
+ import { processPushOperationsWithTrace, type ApplyOperation } from './push';
24
23
  import { compileSchema } from './schema';
25
24
  import {
26
25
  authorizeWrite,
@@ -108,6 +107,12 @@ export type CommandMutation =
108
107
  readonly op: 'upsert';
109
108
  readonly values: Readonly<Record<string, RowValue>>;
110
109
  }
110
+ | {
111
+ readonly table: string;
112
+ readonly op: 'rescope';
113
+ /** Full row; only its scope columns may differ from the stored row. */
114
+ readonly values: Readonly<Record<string, RowValue>>;
115
+ }
111
116
  | {
112
117
  readonly table: string;
113
118
  readonly op: 'delete';
@@ -501,7 +506,7 @@ export function remoteCommand<Input = undefined>(
501
506
  function commandOperations(
502
507
  mutations: readonly CommandMutation[],
503
508
  schema: ReturnType<typeof compileSchema>,
504
- ): PushOperation[] {
509
+ ): ApplyOperation[] {
505
510
  if (mutations.length === 0) {
506
511
  throw syncError(
507
512
  'operation.invalid_request',
@@ -530,7 +535,7 @@ function commandOperations(
530
535
  if (!table.columnIndex.has(name)) {
531
536
  throw syncError(
532
537
  'operation.invalid_request',
533
- 'command upsert has an unknown column',
538
+ 'command upsert or rescope has an unknown column',
534
539
  );
535
540
  }
536
541
  }
@@ -538,7 +543,7 @@ function commandOperations(
538
543
  if (!supplied.has(column.name)) {
539
544
  throw syncError(
540
545
  'operation.invalid_request',
541
- 'command upsert must provide a full row',
546
+ 'command upsert or rescope must provide a full row',
542
547
  );
543
548
  }
544
549
  return mutation.values[column.name] ?? null;
@@ -547,13 +552,13 @@ function commandOperations(
547
552
  if (rowId === undefined) {
548
553
  throw syncError(
549
554
  'operation.invalid_request',
550
- 'command upsert requires a non-empty primary key',
555
+ 'command upsert or rescope requires a non-empty primary key',
551
556
  );
552
557
  }
553
558
  return {
554
559
  table: table.name,
555
560
  rowId,
556
- op: 'upsert',
561
+ op: mutation.op,
557
562
  payload: encodeSparseRow(table.columns, table.primaryKeyIndex, values),
558
563
  };
559
564
  });