@syncular/server 0.16.1 → 0.17.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.
@@ -230,6 +230,13 @@ async function lockPartitionOn(
230
230
  client: PgQueryable,
231
231
  partition: string,
232
232
  ): Promise<void> {
233
+ const locked = await client.query(
234
+ 'SELECT max_commit_seq FROM sync_partitions WHERE partition=$1 FOR UPDATE',
235
+ [partition],
236
+ );
237
+ if (locked.rows.length > 0) return;
238
+ // The first writer initializes the partition. Concurrent initializers may
239
+ // wait on this insert, so acquire the row lock again before applying writes.
233
240
  await client.query(
234
241
  `INSERT INTO sync_partitions(partition, max_commit_seq) VALUES ($1, 0)
235
242
  ON CONFLICT (partition) DO NOTHING`,
@@ -753,32 +760,40 @@ class PostgresTransaction implements StorageTransaction {
753
760
  // Allocate the next dense commitSeq under a per-partition row lock: the
754
761
  // UPDATE … RETURNING serializes concurrent pushes to this partition and
755
762
  // never leaves a gap on rollback (see the file header).
756
- const { rows } = await q.query<{ max_commit_seq: unknown }>(
757
- `INSERT INTO sync_partitions(partition, max_commit_seq) VALUES ($1, 1)
758
- ON CONFLICT (partition) DO UPDATE
759
- SET max_commit_seq = sync_partitions.max_commit_seq + 1
760
- RETURNING max_commit_seq`,
761
- [p],
762
- );
763
- const commitSeq = asNumber(rows[0]?.max_commit_seq);
764
- await q.query(
765
- `INSERT INTO sync_commits(partition, commit_seq, client_id, client_commit_id, actor_id, created_at_ms)
766
- VALUES ($1,$2,$3,$4,$5,$6)`,
763
+ const { rows } = await q.query<{ commit_seq: unknown }>(
764
+ `WITH allocated AS (
765
+ INSERT INTO sync_partitions(partition, max_commit_seq) VALUES ($1, 1)
766
+ ON CONFLICT (partition) DO UPDATE
767
+ SET max_commit_seq = sync_partitions.max_commit_seq + 1
768
+ RETURNING max_commit_seq
769
+ )
770
+ INSERT INTO sync_commits(partition, commit_seq, client_id, client_commit_id, actor_id, created_at_ms)
771
+ SELECT $1, max_commit_seq, $2, $3, $4, $5 FROM allocated
772
+ RETURNING commit_seq`,
767
773
  [
768
774
  p,
769
- commitSeq,
770
775
  commit.clientId,
771
776
  commit.clientCommitId,
772
777
  commit.actorId,
773
778
  commit.createdAtMs,
774
779
  ],
775
780
  );
781
+ const commitSeq = asNumber(rows[0]?.commit_seq);
776
782
  for (let idx = 0; idx < commit.changes.length; idx++) {
777
783
  const change = commit.changes[idx];
778
784
  if (change === undefined) continue;
785
+ // Bind serialized scopes as text before parsing JSONB. Drivers that
786
+ // encode JSONB parameters would otherwise store this string as a scalar.
779
787
  await q.query(
780
- `INSERT INTO sync_changes(partition, commit_seq, idx, tbl, row_id, op, row_version, scopes, payload)
781
- VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)`,
788
+ `WITH inserted AS (
789
+ INSERT INTO sync_changes(partition, commit_seq, idx, tbl, row_id, op, row_version, scopes, payload)
790
+ VALUES ($1,$2,$3,$4,$5,$6,$7,$8::text::jsonb,$9)
791
+ RETURNING partition, tbl, commit_seq, scopes
792
+ )
793
+ INSERT INTO sync_change_scopes(partition, tbl, var, value, commit_seq)
794
+ SELECT inserted.partition, inserted.tbl, scope.key, scope.value, inserted.commit_seq
795
+ FROM inserted CROSS JOIN LATERAL jsonb_each_text(inserted.scopes) AS scope
796
+ ON CONFLICT DO NOTHING`,
782
797
  [
783
798
  p,
784
799
  commitSeq,
@@ -791,13 +806,6 @@ class PostgresTransaction implements StorageTransaction {
791
806
  change.payload ?? null,
792
807
  ],
793
808
  );
794
- for (const [variable, value] of Object.entries(change.scopes)) {
795
- await q.query(
796
- `INSERT INTO sync_change_scopes(partition, tbl, var, value, commit_seq)
797
- VALUES ($1,$2,$3,$4,$5) ON CONFLICT DO NOTHING`,
798
- [p, change.table, variable, value, commitSeq],
799
- );
800
- }
801
809
  }
802
810
  return commitSeq;
803
811
  }
@@ -986,6 +994,9 @@ export class PostgresServerStorage implements ServerStorage {
986
994
  await client.query('DELETE FROM sync_row_scopes WHERE tbl=$1', [
987
995
  tableName,
988
996
  ]);
997
+ await client.query('DELETE FROM sync_blob_refs WHERE tbl=$1', [
998
+ tableName,
999
+ ]);
989
1000
  await client.query(dropTableDdl(tableName));
990
1001
  }
991
1002
  for (const statement of schemaDdl(
@@ -1676,6 +1687,38 @@ export class PostgresServerStorage implements ServerStorage {
1676
1687
  );
1677
1688
  }
1678
1689
 
1690
+ async advanceClientCursor(
1691
+ partition: string,
1692
+ clientId: string,
1693
+ actorId: string,
1694
+ logEpoch: string,
1695
+ cursor: number,
1696
+ updatedAtMs: number,
1697
+ ): Promise<void> {
1698
+ await this.#exec.query(
1699
+ `UPDATE sync_clients
1700
+ SET cursor=GREATEST(cursor, $1), updated_at_ms=GREATEST(updated_at_ms, $2)
1701
+ WHERE partition=$3 AND client_id=$4 AND actor_id=$5
1702
+ AND EXISTS (SELECT 1 FROM sync_partition_registry
1703
+ WHERE partition=sync_clients.partition AND log_epoch=$6)`,
1704
+ [cursor, updatedAtMs, partition, clientId, actorId, logEpoch],
1705
+ );
1706
+ }
1707
+
1708
+ async updateClientCursor(
1709
+ partition: string,
1710
+ clientId: string,
1711
+ cursor: number,
1712
+ updatedAtMs: number,
1713
+ ): Promise<void> {
1714
+ await this.#exec.query(
1715
+ `UPDATE sync_clients
1716
+ SET cursor=GREATEST(cursor, $3), updated_at_ms=GREATEST(updated_at_ms, $4)
1717
+ WHERE partition=$1 AND client_id=$2`,
1718
+ [partition, clientId, cursor, updatedAtMs],
1719
+ );
1720
+ }
1721
+
1679
1722
  async getActiveClientCursorFloor(
1680
1723
  partition: string,
1681
1724
  cutoffMs: number,
package/src/pull.ts CHANGED
@@ -501,6 +501,28 @@ export async function* subscriptionSection(
501
501
 
502
502
  const token = parseBootstrapToken(sub.bootstrapState, sub.table);
503
503
 
504
+ let commits: StoredCommit[] = [];
505
+ if (
506
+ token === undefined &&
507
+ sub.cursor >= 0 &&
508
+ sub.cursor >= horizonSeq &&
509
+ sub.cursor <= maxSeq
510
+ ) {
511
+ commits = await ctx.storage.readCommitWindow(ctx.partition, {
512
+ table: sub.table,
513
+ scopeFilter: plan.effective,
514
+ afterSeq: sub.cursor,
515
+ throughSeq: maxSeq,
516
+ limitChanges: limits.limitCommits + 1,
517
+ });
518
+ // Validate continuity before committing to an active section. A prune
519
+ // during a paged read can otherwise make an incomplete window look empty.
520
+ horizonSeq = Math.max(
521
+ horizonSeq,
522
+ await ctx.storage.getHorizonSeq(ctx.partition),
523
+ );
524
+ }
525
+
504
526
  // §4.6: a cursor behind the horizon (and not resuming a bootstrap)
505
527
  // cannot compute deltas — answer `reset` and echo the cursor.
506
528
  if (token === undefined && sub.cursor >= 0 && sub.cursor < horizonSeq) {
@@ -574,13 +596,6 @@ export async function* subscriptionSection(
574
596
  effectiveScopes: plan.effective,
575
597
  bootstrap: false,
576
598
  };
577
- const commits = await ctx.storage.readCommitWindow(ctx.partition, {
578
- table: sub.table,
579
- scopeFilter: plan.effective,
580
- afterSeq: sub.cursor,
581
- throughSeq: maxSeq,
582
- limitChanges: limits.limitCommits + 1,
583
- });
584
599
  let delivered = 0;
585
600
  let deliveredCommits = 0;
586
601
  let lastDeliveredSeq = sub.cursor;
package/src/realtime.ts CHANGED
@@ -307,6 +307,10 @@ export class RealtimeSession {
307
307
  const cursor = (parsed as { cursor?: unknown }).cursor;
308
308
  if (typeof cursor !== 'number' || !Number.isSafeInteger(cursor)) return;
309
309
  this.cursor = Math.max(this.cursor, cursor);
310
+ // The client may have caught up through another binding (§8.4). Its ack
311
+ // proves those commits no longer need socket notifications, so the next
312
+ // notification must be adjacent to the acknowledged cursor.
313
+ this.lastKnownSeq = Math.max(this.lastKnownSeq, this.cursor);
310
314
  if (this.cursor >= this.lastKnownSeq) this.wakePending = false;
311
315
  // §8.2: acks update the client cursor record without an HTTP pull.
312
316
  void this.#persistCursor();
@@ -626,16 +630,14 @@ export class RealtimeSession {
626
630
 
627
631
  async #persistCursor(): Promise<void> {
628
632
  try {
629
- const record = await this.#storage.getClientRecord(
633
+ await this.#storage.advanceClientCursor(
630
634
  this.partition,
631
635
  this.clientId,
636
+ this.actorId,
637
+ this.logEpoch,
638
+ this.cursor,
639
+ this.#clock(),
632
640
  );
633
- if (record === undefined) return;
634
- await this.#storage.putClientRecord(this.partition, {
635
- ...record,
636
- cursor: Math.max(record.cursor, this.cursor),
637
- updatedAtMs: this.#clock(),
638
- });
639
641
  } catch {
640
642
  // Cursor persistence is best-effort; the next pull repairs it.
641
643
  }
@@ -688,8 +690,9 @@ export class RealtimeSession {
688
690
  }
689
691
  }
690
692
 
691
- /** Called by the hub for every applied commit, in commitSeq order. */
693
+ /** Called by the hub for every applied commit notification. */
692
694
  deliverCommit(commit: StoredCommit): void {
695
+ const contiguous = commit.commitSeq === this.lastKnownSeq + 1;
693
696
  this.lastKnownSeq = Math.max(this.lastKnownSeq, commit.commitSeq);
694
697
  const sections: Array<{
695
698
  registration: Registration;
@@ -716,6 +719,14 @@ export class RealtimeSession {
716
719
  );
717
720
  if (changes.length > 0) sections.push({ registration, changes });
718
721
  }
722
+ if (!contiguous) {
723
+ // Pushes allocate commitSeq under the partition lock but notify after
724
+ // commit, so racing notifications may arrive out of order. A gap,
725
+ // duplicate, or regression cannot be a delta: the client would apply
726
+ // and acknowledge a cursor that does not prove the intervening log.
727
+ this.sendWake('catchup-required');
728
+ return;
729
+ }
719
730
  if (sections.length === 0) return;
720
731
  if (this.#activeRound !== undefined) {
721
732
  // §8.7 interleaving: no 0x00 messages while a response stream is
@@ -771,7 +782,7 @@ export class RealtimeSession {
771
782
  tagged[0] = REALTIME_TAG_DELTA;
772
783
  tagged.set(bytes, 1);
773
784
  this.#sendSafe(tagged);
774
- this.cursor = commit.commitSeq;
785
+ this.cursor = Math.max(this.cursor, commit.commitSeq);
775
786
  const events = this.#events;
776
787
  if (events !== undefined) {
777
788
  emitEvent(events, {
@@ -205,7 +205,7 @@ export function physicalIndexName(declaredName: string): string {
205
205
 
206
206
  /**
207
207
  * CREATE INDEX IF NOT EXISTS for the table's user-declared indexes. These use
208
- * the same declared names and columns the client materializes. Cross-table
208
+ * the declared columns with a leading server partition column. Cross-table
209
209
  * index-name uniqueness is the user's schema concern, as it is client-side.
210
210
  * Server-side the physical name carries the {@link SYNC_INDEX_PREFIX}
211
211
  * ownership marker.
@@ -215,7 +215,7 @@ export function createIndexDdl(table: CompiledTable): string[] {
215
215
  if (!table.materialize) return [];
216
216
  return table.indexes.map((index) => {
217
217
  const unique = index.unique ? 'UNIQUE ' : '';
218
- const columns = index.columns
218
+ const columns = [SYNC_PARTITION_COLUMN, ...index.columns]
219
219
  .map((column) => quoteIdent(column))
220
220
  .join(', ');
221
221
  return `CREATE ${unique}INDEX IF NOT EXISTS ${quoteIdent(physicalIndexName(index.name))} ON ${quoteIdent(table.name)} (${columns})`;
@@ -477,6 +477,22 @@ export function deleteRowSql(
477
477
  return `DELETE FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(SYNC_PARTITION_COLUMN)}=${p[0]} AND ${quoteIdent(SYNC_ROW_ID_COLUMN)}=${p[1]}`;
478
478
  }
479
479
 
480
+ /**
481
+ * SQLite: remove the old row's exact scope keys before replacing or deleting the row.
482
+ * Params: [partition, table, rowId, partition, rowId]. The caller keeps this
483
+ * statement and the row write in the same transaction (or D1 atomic batch).
484
+ */
485
+ export function deleteSqliteRowScopesSql(table: CompiledTable): string {
486
+ return `DELETE FROM sync_row_scopes
487
+ WHERE partition=? AND tbl=? AND row_id=?
488
+ AND (var, value) IN (
489
+ SELECT key, value FROM json_each((
490
+ SELECT ${quoteIdent(SYNC_SCOPES_COLUMN)} FROM ${quoteIdent(table.name)}
491
+ WHERE ${quoteIdent(SYNC_PARTITION_COLUMN)}=? AND ${quoteIdent(SYNC_ROW_ID_COLUMN)}=?
492
+ ))
493
+ )`;
494
+ }
495
+
480
496
  /**
481
497
  * The schema-version marker table gates DDL work. `ensureSchema` compares the
482
498
  * stored version and skips
@@ -17,6 +17,7 @@ import { syncError } from './errors';
17
17
  import {
18
18
  commitWindowPageSql,
19
19
  deleteRowSql,
20
+ deleteSqliteRowScopesSql,
20
21
  dropTableDdl,
21
22
  indexRowPageStatement,
22
23
  layoutsOf,
@@ -228,13 +229,15 @@ class SqliteTransaction implements StorageTransaction {
228
229
  async deleteRow(table: string, rowId: string): Promise<void> {
229
230
  this.#assertOpen();
230
231
  const db = this.#storage.db;
231
- db.query(deleteRowSql(this.#storage.table(table), 'sqlite')).run(
232
+ const compiled = this.#storage.table(table);
233
+ db.query(deleteSqliteRowScopesSql(compiled)).run(
234
+ this.#partition,
235
+ table,
236
+ rowId,
232
237
  this.#partition,
233
238
  rowId,
234
239
  );
235
- db.query(
236
- 'DELETE FROM sync_row_scopes WHERE partition=? AND tbl=? AND row_id=?',
237
- ).run(this.#partition, table, rowId);
240
+ db.query(deleteRowSql(compiled, 'sqlite')).run(this.#partition, rowId);
238
241
  // §5.9.4: a deleted row references no blobs.
239
242
  db.query(
240
243
  'DELETE FROM sync_blob_refs WHERE partition=? AND tbl=? AND row_id=?',
@@ -485,6 +488,9 @@ export class SqliteServerStorage implements ServerStorage {
485
488
  this.db
486
489
  .query('DELETE FROM sync_row_scopes WHERE tbl=?')
487
490
  .run(tableName);
491
+ this.db
492
+ .query('DELETE FROM sync_blob_refs WHERE tbl=?')
493
+ .run(tableName);
488
494
  this.db.exec(dropTableDdl(tableName));
489
495
  }
490
496
  for (const statement of schemaDdl(
@@ -683,6 +689,9 @@ export class SqliteServerStorage implements ServerStorage {
683
689
  /** Internal: write a row + refresh its scope-index entries. */
684
690
  writeRow(partition: string, table: string, row: StoredRow): void {
685
691
  const compiled = this.table(table);
692
+ this.db
693
+ .query(deleteSqliteRowScopesSql(compiled))
694
+ .run(partition, table, row.rowId, partition, row.rowId);
686
695
  this.db
687
696
  .query(upsertSql(compiled, 'sqlite'))
688
697
  .run(
@@ -694,11 +703,6 @@ export class SqliteServerStorage implements ServerStorage {
694
703
  | null
695
704
  )[]),
696
705
  );
697
- this.db
698
- .query(
699
- 'DELETE FROM sync_row_scopes WHERE partition=? AND tbl=? AND row_id=?',
700
- )
701
- .run(partition, table, row.rowId);
702
706
  for (const [variable, value] of Object.entries(row.scopes)) {
703
707
  this.db
704
708
  .query(
@@ -1272,6 +1276,38 @@ export class SqliteServerStorage implements ServerStorage {
1272
1276
  );
1273
1277
  }
1274
1278
 
1279
+ async advanceClientCursor(
1280
+ partition: string,
1281
+ clientId: string,
1282
+ actorId: string,
1283
+ logEpoch: string,
1284
+ cursor: number,
1285
+ updatedAtMs: number,
1286
+ ): Promise<void> {
1287
+ this.db
1288
+ .query(`UPDATE sync_clients
1289
+ SET cursor=MAX(cursor, ?), updated_at_ms=MAX(updated_at_ms, ?)
1290
+ WHERE partition=? AND client_id=? AND actor_id=?
1291
+ AND EXISTS (SELECT 1 FROM sync_partition_registry
1292
+ WHERE partition=sync_clients.partition AND log_epoch=?)`)
1293
+ .run(cursor, updatedAtMs, partition, clientId, actorId, logEpoch);
1294
+ }
1295
+
1296
+ async updateClientCursor(
1297
+ partition: string,
1298
+ clientId: string,
1299
+ cursor: number,
1300
+ updatedAtMs: number,
1301
+ ): Promise<void> {
1302
+ this.db
1303
+ .query(
1304
+ `UPDATE sync_clients
1305
+ SET cursor=MAX(cursor, ?), updated_at_ms=MAX(updated_at_ms, ?)
1306
+ WHERE partition=? AND client_id=?`,
1307
+ )
1308
+ .run(cursor, updatedAtMs, partition, clientId);
1309
+ }
1310
+
1275
1311
  async getActiveClientCursorFloor(
1276
1312
  partition: string,
1277
1313
  cutoffMs: number,
@@ -15,6 +15,10 @@ export class StorageConstraintError extends Error {
15
15
 
16
16
  /** Stable, privacy-safe failures for trusted server storage queries. */
17
17
  export type StorageQueryErrorCode =
18
+ | 'sync.storage.schema_migration_pending'
19
+ | 'sync.storage.schema_migration_conflict'
20
+ | 'sync.storage.schema_changed'
21
+ | 'sync.storage.invalid_migration_budget'
18
22
  | 'sync.storage.scan_requires_scope'
19
23
  | 'sync.storage.index_not_found'
20
24
  | 'sync.storage.index_not_materialized'
@@ -26,6 +30,14 @@ export type StorageQueryErrorCode =
26
30
 
27
31
  const STORAGE_QUERY_MESSAGES: Readonly<Record<StorageQueryErrorCode, string>> =
28
32
  {
33
+ 'sync.storage.schema_migration_pending':
34
+ 'schema migration requires another invocation',
35
+ 'sync.storage.schema_migration_conflict':
36
+ 'another schema migration target is already pending',
37
+ 'sync.storage.schema_changed':
38
+ 'storage schema is not ready for this operation',
39
+ 'sync.storage.invalid_migration_budget':
40
+ 'migration statement budget must be an integer from 10 through 1000',
29
41
  'sync.storage.prune_epoch_mismatch':
30
42
  'partition log epoch changed; recompute retention inputs',
31
43
  'sync.storage.partition_unregistered':
package/src/storage.ts CHANGED
@@ -560,6 +560,27 @@ export interface ServerStorage {
560
560
  clientId: string,
561
561
  ): Promise<ClientRecord | undefined>;
562
562
  putClientRecord(partition: string, record: ClientRecord): Promise<void>;
563
+ /**
564
+ * Atomically advance an existing record's cursor and activity timestamp.
565
+ * Preserve registration fields; require matching actor and current log epoch.
566
+ * Missing records and mismatched identities are unchanged (§8.2).
567
+ */
568
+ advanceClientCursor(
569
+ partition: string,
570
+ clientId: string,
571
+ actorId: string,
572
+ logEpoch: string,
573
+ cursor: number,
574
+ updatedAtMs: number,
575
+ ): Promise<void>;
576
+ /** Advance an existing client's ACK cursor and timestamp atomically,
577
+ * preserving actor, wire version, and subscriptions. Missing records stay absent. */
578
+ updateClientCursor(
579
+ partition: string,
580
+ clientId: string,
581
+ cursor: number,
582
+ updatedAtMs: number,
583
+ ): Promise<void>;
563
584
  /** Minimum cursor with updatedAtMs >= cutoff; null when none are active. */
564
585
  getActiveClientCursorFloor(
565
586
  partition: string,