@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.
@@ -807,17 +807,38 @@ export const COMMIT_WINDOW_BATCH_SQL = `SELECT s.i AS sub, c.commit_seq AS commi
807
807
  ORDER BY s.i, c.commit_seq, ch.idx`;
808
808
 
809
809
  /**
810
- * PostgreSQL push read-ahead: every requested row id with its stored row
811
- * (NULL columns when absent, `selectRowSql` shape) and its §5 delete
812
- * tombstone. Params: [partition, rowIds as JSON array text, tbl].
810
+ * PostgreSQL push read-ahead across every table a commit targets, in one
811
+ * statement: each requested row id with its stored row (NULL columns when
812
+ * absent, `selectRowSql` shape) and its §5 delete tombstone. Params:
813
+ * [partition, specs as JSON text], `specs` = `{tbl, ids: string[]}` records.
813
814
  */
814
- export function prefetchRowsSql(table: CompiledTable): string {
815
- return `SELECT ids.id AS lookup_id, r.${quoteIdent(SYNC_ROW_ID_COLUMN)} AS row_id, r.${quoteIdent(SYNC_VERSION_COLUMN)} AS server_version, r.${quoteIdent(SYNC_SCOPES_COLUMN)} AS scopes, r.${quoteIdent(SYNC_PAYLOAD_COLUMN)} AS payload, r.${quoteIdent(SYNC_COLUMN_VERSIONS_COLUMN)} AS column_versions, t.commit_seq AS tombstone_seq
816
- FROM jsonb_array_elements_text($2::text::jsonb) AS ids(id)
817
- LEFT JOIN ${quoteIdent(table.name)} r
818
- ON r.${quoteIdent(SYNC_PARTITION_COLUMN)}=$1 AND r.${quoteIdent(SYNC_ROW_ID_COLUMN)}=ids.id
819
- LEFT JOIN sync_tombstones t
820
- ON t.partition=$1 AND t.tbl=$3 AND t.row_id=ids.id`;
815
+ export function prefetchRowsBatchSql(
816
+ tables: readonly CompiledTable[],
817
+ params: { readonly partition: string; readonly specs: string } = {
818
+ partition: '$1',
819
+ specs: '$2',
820
+ },
821
+ ): string {
822
+ // One branch per table: a lateral join cannot switch its relation per
823
+ // record, and the union keeps the whole read-ahead one round trip.
824
+ const projection = tables
825
+ .map((table) => {
826
+ const quoted = quoteIdent(table.name);
827
+ return `SELECT ids.tbl AS tbl, ids.id AS lookup_id, r.${quoteIdent(SYNC_ROW_ID_COLUMN)} AS row_id, r.${quoteIdent(SYNC_VERSION_COLUMN)} AS server_version, r.${quoteIdent(SYNC_SCOPES_COLUMN)} AS scopes, r.${quoteIdent(SYNC_PAYLOAD_COLUMN)} AS payload, r.${quoteIdent(SYNC_COLUMN_VERSIONS_COLUMN)} AS column_versions, t.commit_seq AS tombstone_seq
828
+ FROM ids
829
+ LEFT JOIN ${quoted} r
830
+ ON r.${quoteIdent(SYNC_PARTITION_COLUMN)}=${params.partition} AND r.${quoteIdent(SYNC_ROW_ID_COLUMN)}=ids.id
831
+ LEFT JOIN sync_tombstones t
832
+ ON t.partition=${params.partition} AND t.tbl=ids.tbl AND t.row_id=ids.id
833
+ WHERE ids.tbl='${table.name.replaceAll("'", "''")}'`;
834
+ })
835
+ .join('\n UNION ALL ');
836
+ return `WITH ids AS (
837
+ SELECT spec.tbl AS tbl, entry.id AS id
838
+ FROM jsonb_to_recordset(${params.specs}::text::jsonb) AS spec(tbl text, ids jsonb)
839
+ CROSS JOIN LATERAL jsonb_array_elements_text(spec.ids) AS entry(id)
840
+ )
841
+ ${projection}`;
821
842
  }
822
843
 
823
844
  /**
@@ -5,6 +5,7 @@ import type { CommitPruneQuery, CommitPruneResult } from './storage';
5
5
  import {
6
6
  clientRoundState,
7
7
  PARTITION_TOUCH_INTERVAL_MS,
8
+ rowsLiveAfterDelete,
8
9
  serveGateRefusal,
9
10
  } from './storage';
10
11
  import { serveNotReadyError } from './readiness';
@@ -100,6 +101,7 @@ import type {
100
101
  IndexRowScanQuery,
101
102
  NewCommit,
102
103
  NewReaction,
104
+ PreparedPushApply,
103
105
  PartitionRegistryEntry,
104
106
  PrunedReactionCounts,
105
107
  ReactionClaimQuery,
@@ -254,20 +256,6 @@ class SqliteTransaction implements StorageTransaction {
254
256
  .run(this.#partition, table, rowId);
255
257
  }
256
258
 
257
- getPushResult(
258
- clientId: string,
259
- clientCommitId: string,
260
- ): Promise<StoredPushResult | undefined> {
261
- this.#assertOpen();
262
- // One shared SQLite connection: this read runs inside this
263
- // transaction's BEGIN IMMEDIATE.
264
- return this.#storage.getPushResult(
265
- this.#partition,
266
- clientId,
267
- clientCommitId,
268
- );
269
- }
270
-
271
259
  scanRows(query: RowScanQuery): Promise<StoredRow[]> {
272
260
  this.#assertOpen();
273
261
  return this.#storage.scanRows(this.#partition, query);
@@ -287,11 +275,30 @@ class SqliteTransaction implements StorageTransaction {
287
275
  return this.#storage.runAuthoritativeQuery(this.#partition, query);
288
276
  }
289
277
 
290
- async lockPartitionForPush(): Promise<void> {
278
+ async preparePushApply(
279
+ clientId: string,
280
+ clientCommitId: string,
281
+ runningSchemaVersion: number,
282
+ _targets: readonly {
283
+ readonly table: string;
284
+ readonly rowIds: readonly string[];
285
+ }[] = [],
286
+ ): Promise<PreparedPushApply> {
291
287
  this.#assertOpen();
292
288
  // BEGIN IMMEDIATE in the constructor already owns SQLite's writer lock.
293
289
  this.#storage.db.exec('SAVEPOINT syncular_push_candidate');
294
290
  this.#pushApplySavepoint = true;
291
+ return {
292
+ gate: await this.#storage.readServeGate(
293
+ this.#partition,
294
+ runningSchemaVersion,
295
+ ),
296
+ persisted: await this.#storage.getPushResult(
297
+ this.#partition,
298
+ clientId,
299
+ clientCommitId,
300
+ ),
301
+ };
295
302
  }
296
303
 
297
304
  async advanceCheckpoint(
@@ -323,7 +330,7 @@ class SqliteTransaction implements StorageTransaction {
323
330
  clientId: string,
324
331
  clientCommitId: string,
325
332
  result: StoredPushResult,
326
- ): Promise<void> {
333
+ ): Promise<StoredPushResult> {
327
334
  this.#assertOpen();
328
335
  if (!this.#pushApplySavepoint) {
329
336
  throw new Error('push rejection requires its apply savepoint');
@@ -333,6 +340,9 @@ class SqliteTransaction implements StorageTransaction {
333
340
  this.#pushApplySavepoint = false;
334
341
  await this.putPushResult(clientId, clientCommitId, result);
335
342
  await this.commit();
343
+ // The idempotency re-check found no record under the writer lock, so the
344
+ // insert above is the canonical outcome.
345
+ return result;
336
346
  }
337
347
 
338
348
  async upsertRow(
@@ -414,6 +424,7 @@ class SqliteTransaction implements StorageTransaction {
414
424
  commit.createdAtMs,
415
425
  this.#writerVersion,
416
426
  );
427
+ const liveAfterDelete = rowsLiveAfterDelete(commit.changes);
417
428
  commit.changes.forEach((change, idx) => {
418
429
  db.query(
419
430
  'INSERT INTO sync_changes(partition, commit_seq, idx, tbl, row_id, op, row_version, scopes, payload) VALUES (?,?,?,?,?,?,?,?,?)',
@@ -428,9 +439,13 @@ class SqliteTransaction implements StorageTransaction {
428
439
  JSON.stringify(change.scopes),
429
440
  change.payload ?? null,
430
441
  );
431
- if (change.op === 'delete') {
442
+ if (
443
+ change.op === 'delete' &&
444
+ !liveAfterDelete.has(`${change.table}\u0000${change.rowId}`)
445
+ ) {
432
446
  // §5 delete precedence: every applied delete leaves a tombstone,
433
- // pruned with the commit log (§4.6).
447
+ // pruned with the commit log (§4.6). A delete that the same commit
448
+ // follows with an upsert is a §2.2 scope move, not a deletion.
434
449
  db.query(
435
450
  `INSERT INTO sync_tombstones(partition, tbl, row_id, commit_seq) VALUES (?,?,?,?)
436
451
  ON CONFLICT(partition, tbl, row_id) DO UPDATE SET commit_seq=excluded.commit_seq`,
package/src/storage.ts CHANGED
@@ -74,6 +74,18 @@ export interface ServeGate {
74
74
  }[];
75
75
  }
76
76
 
77
+ /**
78
+ * The push apply's admission token: the partition lock, the serve gate, and
79
+ * the idempotency record read in one round trip. A storage with per-statement
80
+ * network cost answers all three with one statement (see
81
+ * `PostgresServerStorage`); the lock is held from this call through commit.
82
+ */
83
+ export interface PreparedPushApply {
84
+ readonly gate: ServeGate;
85
+ /** The already-persisted outcome for this key, or `undefined` on a miss. */
86
+ readonly persisted: StoredPushResult | undefined;
87
+ }
88
+
77
89
  /** Why `serveGate` refuses, or `undefined` when this request may serve. */
78
90
  export type ServeGateRefusal =
79
91
  | {
@@ -182,6 +194,33 @@ export interface NewCommit {
182
194
  readonly changes: readonly NewChange[];
183
195
  }
184
196
 
197
+ /**
198
+ * §2.2: rows whose final change in this commit is an upsert that followed a
199
+ * delete. That delete is one half of a scope move, so it MUST NOT leave a
200
+ * tombstone behind the live row. A row whose final change is a delete keeps
201
+ * its tombstone.
202
+ */
203
+ export function rowsLiveAfterDelete(
204
+ changes: readonly NewChange[],
205
+ ): Set<string> {
206
+ const last = new Map<string, NewChange['op']>();
207
+ const deleted = new Set<string>();
208
+ const live = new Set<string>();
209
+ for (const change of changes) {
210
+ const key = `${change.table}\u0000${change.rowId}`;
211
+ if (change.op === 'delete') {
212
+ deleted.add(key);
213
+ } else if (deleted.has(key)) {
214
+ live.add(key);
215
+ }
216
+ last.set(key, change.op);
217
+ }
218
+ for (const key of live) {
219
+ if (last.get(key) !== 'upsert') live.delete(key);
220
+ }
221
+ return live;
222
+ }
223
+
185
224
  export interface StoredChange {
186
225
  readonly table: string;
187
226
  readonly rowId: string;
@@ -520,12 +559,17 @@ export interface StorageTransaction {
520
559
  getRow(table: string, rowId: string): Promise<StoredRow | undefined>;
521
560
  /**
522
561
  * Optional read-ahead: the push layer names every row a commit's
523
- * operations target, per table, before it applies them. A storage with
524
- * per-statement network cost reads them in one statement and answers the
525
- * following `getRow` calls from that snapshot until the row is written in
526
- * this transaction.
562
+ * operations target, across every table, before it applies them. A storage
563
+ * with per-statement network cost reads them in one statement (one branch
564
+ * per table) and answers the following `getRow` calls from that snapshot
565
+ * until the row is written in this transaction.
527
566
  */
528
- prefetchRows?(table: string, rowIds: readonly string[]): Promise<void>;
567
+ prefetchRows?(
568
+ targets: readonly {
569
+ readonly table: string;
570
+ readonly rowIds: readonly string[];
571
+ }[],
572
+ ): Promise<void>;
529
573
  /**
530
574
  * §5 delete-precedence tombstone read: the `commit_seq` of the newest
531
575
  * applied delete of this row still within the pruning horizon, or
@@ -540,20 +584,6 @@ export interface StorageTransaction {
540
584
  * over an absent row; a no-op when no tombstone exists.
541
585
  */
542
586
  clearTombstone(table: string, rowId: string): Promise<void>;
543
- /**
544
- * Optional transaction-scoped idempotency lookup (§2.3) with the same
545
- * semantics as `ServerStorage.getPushResult`, including the
546
- * `sync.idempotency_cache_miss` throw. The push layer prefers it for the
547
- * post-serialization duplicate re-check so the read runs on the
548
- * transaction's own connection — a pooled Postgres client that holds the
549
- * partition lock must never wait for a second pool slot mid-push. In-tree
550
- * SQLite/Postgres/D1 adapters implement it; a custom adapter that omits it
551
- * keeps the pool-level read.
552
- */
553
- getPushResult?(
554
- clientId: string,
555
- clientCommitId: string,
556
- ): Promise<StoredPushResult | undefined>;
557
587
  /**
558
588
  * Optional candidate-state scan used only by whole-commit validation.
559
589
  * In-tree SQLite/Postgres/D1 backends implement it with read-your-own-writes
@@ -583,28 +613,37 @@ export interface StorageTransaction {
583
613
  ): Promise<AuthoritativeQueryResult>;
584
614
  /**
585
615
  * Serialize every push apply for this partition before any operation read,
586
- * validation, merge, or write. The push layer re-checks idempotency only
587
- * after this resolves and retains the lock through terminal-result commit.
616
+ * validation, merge, or write, and read the admission token in the same
617
+ * round trip: the RFC 0007 serve gate and the §2.3 idempotency record for
618
+ * `clientCommitId`. The lock is retained through terminal-result commit,
619
+ * so the idempotency read is exact and a racing duplicate cannot rerun.
620
+ * `targets` are the rows the commit is known to touch before its operations
621
+ * are built; a storage with per-statement network cost folds that read-ahead
622
+ * into the admission read and answers the following `getRow` calls from it.
588
623
  * Missing support fails closed before an app-row mutation.
589
624
  */
590
- lockPartitionForPush?(): Promise<void>;
591
- /**
592
- * @deprecated Implement `lockPartitionForPush`. Kept as a compatibility
593
- * bridge for custom adapters whose existing implementation already locks
594
- * the complete partition from before candidate reads through commit.
595
- */
596
- lockPartitionForCommitValidation?(): Promise<void>;
625
+ preparePushApply(
626
+ clientId: string,
627
+ clientCommitId: string,
628
+ runningSchemaVersion: number,
629
+ targets?: readonly {
630
+ readonly table: string;
631
+ readonly rowIds: readonly string[];
632
+ }[],
633
+ ): Promise<PreparedPushApply>;
597
634
  /**
598
635
  * Rejection finalization while the push-apply serialization lock is still
599
636
  * held: discard every candidate write, persist the rejected idempotency
600
- * result, and finish the transaction atomically. Required for every push so
601
- * a concurrent duplicate cannot rerun operations, validators, or merges.
637
+ * result, and finish the transaction atomically. Returns the persisted
638
+ * canonical outcome, so the caller never re-reads it. Required for every
639
+ * push so a concurrent duplicate cannot rerun operations, validators, or
640
+ * merges.
602
641
  */
603
- commitRejectedPushResult?(
642
+ commitRejectedPushResult(
604
643
  clientId: string,
605
644
  clientCommitId: string,
606
645
  result: StoredPushResult,
607
- ): Promise<void>;
646
+ ): Promise<StoredPushResult>;
608
647
  upsertRow(
609
648
  table: string,
610
649
  row: StoredRow,
@@ -717,10 +756,10 @@ export interface ServerStorage {
717
756
  * same command races a push on Postgres and a SQLite-only test conceals it.
718
757
  *
719
758
  * A trusted command must therefore run through `registerRemoteCommand`,
720
- * which builds after `lockPartitionForPush` and the idempotency recheck and
721
- * shares the committed transaction, or call `lockPartitionForPush` before
722
- * its first authority, version, or replay read. SSP2 request handling does
723
- * this for you; only host-authored commands choose.
759
+ * which builds after `preparePushApply` and shares the committed
760
+ * transaction, or call `preparePushApply` before its first authority,
761
+ * version, or replay read. SSP2 request handling does this for you; only
762
+ * host-authored commands choose.
724
763
  */
725
764
  begin(partition: string): Promise<StorageTransaction>;
726
765