@syncular/server 0.15.47 → 0.16.1

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 (80) hide show
  1. package/README.md +48 -5
  2. package/dist/admin.d.ts +1 -5
  3. package/dist/admin.js +3 -12
  4. package/dist/authoritative-query.d.ts +14 -6
  5. package/dist/authoritative-query.js +60 -82
  6. package/dist/blob-handlers.js +4 -1
  7. package/dist/context.d.ts +3 -1
  8. package/dist/context.js +4 -0
  9. package/dist/d1-storage.d.ts +8 -2
  10. package/dist/d1-storage.js +134 -26
  11. package/dist/errors.js +6 -0
  12. package/dist/events.d.ts +2 -1
  13. package/dist/frame-bytes.d.ts +2 -2
  14. package/dist/frame-bytes.js +33 -14
  15. package/dist/handler.js +58 -14
  16. package/dist/index.d.ts +1 -0
  17. package/dist/index.js +1 -0
  18. package/dist/operations.d.ts +2 -0
  19. package/dist/operations.js +25 -5
  20. package/dist/postgres-storage.d.ts +9 -3
  21. package/dist/postgres-storage.js +110 -20
  22. package/dist/prune.d.ts +3 -1
  23. package/dist/prune.js +18 -14
  24. package/dist/pull.d.ts +1 -1
  25. package/dist/pull.js +74 -37
  26. package/dist/push.js +5 -5
  27. package/dist/realtime.d.ts +4 -1
  28. package/dist/realtime.js +33 -9
  29. package/dist/restore.d.ts +13 -0
  30. package/dist/restore.js +13 -0
  31. package/dist/s3-segment-store.js +10 -1
  32. package/dist/seed.js +36 -4
  33. package/dist/segment-download.js +5 -2
  34. package/dist/segment-store.d.ts +3 -0
  35. package/dist/segment-store.js +1 -0
  36. package/dist/sqlite-bun-driver.d.ts +2 -1
  37. package/dist/sqlite-bun-driver.js +5 -2
  38. package/dist/sqlite-bun.js +2 -2
  39. package/dist/sqlite-dialect.d.ts +1 -1
  40. package/dist/sqlite-dialect.js +7 -0
  41. package/dist/sqlite-image.d.ts +3 -3
  42. package/dist/sqlite-image.js +10 -6
  43. package/dist/sqlite-node.js +2 -2
  44. package/dist/sqlite-segment-store.js +14 -5
  45. package/dist/sqlite-storage.d.ts +8 -2
  46. package/dist/sqlite-storage.js +147 -36
  47. package/dist/storage-errors.d.ts +1 -1
  48. package/dist/storage-errors.js +3 -0
  49. package/dist/storage.d.ts +38 -10
  50. package/package.json +2 -2
  51. package/src/admin.ts +7 -15
  52. package/src/authoritative-query.ts +89 -94
  53. package/src/blob-handlers.ts +8 -1
  54. package/src/context.ts +16 -1
  55. package/src/d1-storage.ts +193 -29
  56. package/src/errors.ts +6 -0
  57. package/src/events.ts +2 -1
  58. package/src/frame-bytes.ts +40 -14
  59. package/src/handler.ts +102 -29
  60. package/src/index.ts +1 -0
  61. package/src/operations.ts +37 -4
  62. package/src/postgres-storage.ts +184 -38
  63. package/src/prune.ts +29 -15
  64. package/src/pull.ts +90 -41
  65. package/src/push.ts +5 -5
  66. package/src/realtime.ts +46 -7
  67. package/src/restore.ts +28 -0
  68. package/src/s3-segment-store.ts +10 -1
  69. package/src/seed.ts +46 -4
  70. package/src/segment-download.ts +11 -2
  71. package/src/segment-store.ts +4 -0
  72. package/src/sqlite-bun-driver.ts +6 -2
  73. package/src/sqlite-bun.ts +2 -2
  74. package/src/sqlite-dialect.ts +7 -0
  75. package/src/sqlite-image.ts +26 -15
  76. package/src/sqlite-node.ts +2 -2
  77. package/src/sqlite-segment-store.ts +18 -4
  78. package/src/sqlite-storage.ts +203 -39
  79. package/src/storage-errors.ts +10 -1
  80. package/src/storage.ts +57 -10
@@ -1,6 +1,7 @@
1
+ import type { CommitPruneQuery, CommitPruneResult } from './storage.js';
1
2
  import { type PgExecutor } from './pg-executor.js';
2
3
  import type { CompiledSchema, CompiledTable } from './schema.js';
3
- import type { AuthoritativeQueryRequest, AuthoritativeQueryResult, ClientCursorInfo, ClientRecord, CommitMetadata, CommitMetadataQuery, CommitWindowQuery, IndexRowScanQuery, PrunedReactionCounts, ReactionClaimQuery, ReactionFailureUpdate, ReactionListQuery, ReactionPruneQuery, RowScanQuery, ScopeActivityQuery, ScopeCommitActivity, ServerStorage, StorageTransaction, StoredCommit, StoredPushResult, StoredReaction, StoredRow } from './storage.js';
4
+ import type { AuthoritativeQueryRequest, AuthoritativeQueryResult, ClientCursorInfo, ClientRecord, CommitMetadata, CommitMetadataQuery, CommitWindowQuery, IndexRowScanQuery, PartitionRegistryEntry, PrunedReactionCounts, ReactionClaimQuery, ReactionFailureUpdate, ReactionListQuery, ReactionPruneQuery, RowScanQuery, ScopeActivityQuery, ScopeCommitActivity, ServerStorage, StorageTransaction, StoredCommit, StoredPushResult, StoredReaction, StoredRow } from './storage.js';
4
5
  /**
5
6
  * Schema DDL. Applied by `PostgresServerStorage.migrate()` (idempotent).
6
7
  *
@@ -23,7 +24,7 @@ import type { AuthoritativeQueryRequest, AuthoritativeQueryResult, ClientCursorI
23
24
  * set, §5.9.5) as an index range, never a scan. `postgres-explain
24
25
  * .test.ts` asserts an `Index` node here too.
25
26
  */
26
- export declare const POSTGRES_DDL = "\nCREATE TABLE IF NOT EXISTS sync_partitions(\n partition TEXT PRIMARY KEY,\n max_commit_seq BIGINT NOT NULL DEFAULT 0,\n horizon_seq BIGINT NOT NULL DEFAULT 0\n);\nCREATE TABLE IF NOT EXISTS sync_row_scopes(\n partition TEXT NOT NULL, tbl TEXT NOT NULL,\n var TEXT NOT NULL, value TEXT NOT NULL, row_id TEXT NOT NULL,\n PRIMARY KEY(partition, tbl, var, value, row_id)\n);\nCREATE TABLE IF NOT EXISTS sync_commits(\n partition TEXT NOT NULL, commit_seq BIGINT NOT NULL,\n client_id TEXT NOT NULL, client_commit_id TEXT NOT NULL,\n actor_id TEXT NOT NULL, created_at_ms BIGINT NOT NULL,\n PRIMARY KEY(partition, commit_seq)\n);\nCREATE INDEX IF NOT EXISTS sync_commits_by_time\n ON sync_commits(partition, created_at_ms);\nCREATE TABLE IF NOT EXISTS sync_changes(\n partition TEXT NOT NULL, commit_seq BIGINT NOT NULL, idx INTEGER NOT NULL,\n tbl TEXT NOT NULL, row_id TEXT NOT NULL, op SMALLINT NOT NULL,\n row_version BIGINT, scopes JSONB NOT NULL, payload BYTEA,\n PRIMARY KEY(partition, commit_seq, idx)\n);\nCREATE INDEX IF NOT EXISTS sync_changes_by_table\n ON sync_changes(partition, commit_seq, tbl, idx);\nCREATE TABLE IF NOT EXISTS sync_change_scopes(\n partition TEXT NOT NULL, tbl TEXT NOT NULL,\n var TEXT NOT NULL, value TEXT NOT NULL, commit_seq BIGINT NOT NULL,\n PRIMARY KEY(partition, tbl, var, value, commit_seq)\n);\nCREATE TABLE IF NOT EXISTS sync_push_results(\n partition TEXT NOT NULL, client_id TEXT NOT NULL,\n client_commit_id TEXT NOT NULL, result JSONB NOT NULL,\n PRIMARY KEY(partition, client_id, client_commit_id)\n);\nCREATE TABLE IF NOT EXISTS sync_reactions(\n partition TEXT NOT NULL, idempotency_key TEXT NOT NULL,\n type TEXT NOT NULL, version INTEGER NOT NULL, payload JSONB NOT NULL,\n source_client_id TEXT NOT NULL, source_client_commit_id TEXT NOT NULL,\n source_commit_seq BIGINT NOT NULL, created_at_ms BIGINT NOT NULL,\n available_at_ms BIGINT NOT NULL, status TEXT NOT NULL,\n attempts INTEGER NOT NULL, max_attempts INTEGER NOT NULL,\n lease_owner TEXT, lease_expires_at_ms BIGINT, completed_at_ms BIGINT,\n last_failure JSONB,\n PRIMARY KEY(partition, idempotency_key),\n CHECK(status IN ('pending', 'leased', 'completed', 'dead-letter'))\n);\nCREATE INDEX IF NOT EXISTS sync_reactions_due\n ON sync_reactions(partition, status, available_at_ms, created_at_ms, idempotency_key);\nCREATE INDEX IF NOT EXISTS sync_reactions_lease\n ON sync_reactions(partition, status, lease_expires_at_ms);\nCREATE INDEX IF NOT EXISTS sync_reactions_completed\n ON sync_reactions(partition, status, completed_at_ms, idempotency_key);\nCREATE INDEX IF NOT EXISTS sync_reactions_dead_letter\n ON sync_reactions(partition, status, available_at_ms, idempotency_key);\nCREATE TABLE IF NOT EXISTS sync_clients(\n partition TEXT NOT NULL, client_id TEXT NOT NULL, actor_id TEXT NOT NULL,\n cursor BIGINT NOT NULL, subscriptions JSONB NOT NULL,\n updated_at_ms BIGINT NOT NULL,\n PRIMARY KEY(partition, client_id)\n);\nCREATE TABLE IF NOT EXISTS sync_blob_refs(\n partition TEXT NOT NULL, tbl TEXT NOT NULL, row_id TEXT NOT NULL,\n blob_id TEXT NOT NULL,\n PRIMARY KEY(partition, tbl, row_id, blob_id)\n);\nCREATE INDEX IF NOT EXISTS sync_blob_refs_by_blob\n ON sync_blob_refs(partition, blob_id);\n";
27
+ export declare const POSTGRES_DDL = "\nCREATE TABLE IF NOT EXISTS sync_partitions(\n partition TEXT PRIMARY KEY,\n max_commit_seq BIGINT NOT NULL DEFAULT 0,\n horizon_seq BIGINT NOT NULL DEFAULT 0\n);\nCREATE TABLE IF NOT EXISTS sync_partition_registry(\n partition TEXT PRIMARY KEY,\n log_epoch TEXT NOT NULL,\n epoch_required BOOLEAN NOT NULL DEFAULT FALSE,\n last_authenticated_at_ms BIGINT NOT NULL\n);\nCREATE TABLE IF NOT EXISTS sync_row_scopes(\n partition TEXT NOT NULL, tbl TEXT NOT NULL,\n var TEXT NOT NULL, value TEXT NOT NULL, row_id TEXT NOT NULL,\n PRIMARY KEY(partition, tbl, var, value, row_id)\n);\nCREATE TABLE IF NOT EXISTS sync_commits(\n partition TEXT NOT NULL, commit_seq BIGINT NOT NULL,\n client_id TEXT NOT NULL, client_commit_id TEXT NOT NULL,\n actor_id TEXT NOT NULL, created_at_ms BIGINT NOT NULL,\n PRIMARY KEY(partition, commit_seq)\n);\nCREATE INDEX IF NOT EXISTS sync_commits_by_time\n ON sync_commits(partition, created_at_ms);\nCREATE TABLE IF NOT EXISTS sync_changes(\n partition TEXT NOT NULL, commit_seq BIGINT NOT NULL, idx INTEGER NOT NULL,\n tbl TEXT NOT NULL, row_id TEXT NOT NULL, op SMALLINT NOT NULL,\n row_version BIGINT, scopes JSONB NOT NULL, payload BYTEA,\n PRIMARY KEY(partition, commit_seq, idx)\n);\nCREATE INDEX IF NOT EXISTS sync_changes_by_table\n ON sync_changes(partition, commit_seq, tbl, idx);\nCREATE TABLE IF NOT EXISTS sync_change_scopes(\n partition TEXT NOT NULL, tbl TEXT NOT NULL,\n var TEXT NOT NULL, value TEXT NOT NULL, commit_seq BIGINT NOT NULL,\n PRIMARY KEY(partition, tbl, var, value, commit_seq)\n);\nCREATE TABLE IF NOT EXISTS sync_push_results(\n partition TEXT NOT NULL, client_id TEXT NOT NULL,\n client_commit_id TEXT NOT NULL, result JSONB NOT NULL,\n PRIMARY KEY(partition, client_id, client_commit_id)\n);\nCREATE TABLE IF NOT EXISTS sync_reactions(\n partition TEXT NOT NULL, idempotency_key TEXT NOT NULL,\n type TEXT NOT NULL, version INTEGER NOT NULL, payload JSONB NOT NULL,\n source_client_id TEXT NOT NULL, source_client_commit_id TEXT NOT NULL,\n source_commit_seq BIGINT NOT NULL, created_at_ms BIGINT NOT NULL,\n available_at_ms BIGINT NOT NULL, status TEXT NOT NULL,\n attempts INTEGER NOT NULL, max_attempts INTEGER NOT NULL,\n lease_owner TEXT, lease_expires_at_ms BIGINT, completed_at_ms BIGINT,\n last_failure JSONB,\n PRIMARY KEY(partition, idempotency_key),\n CHECK(status IN ('pending', 'leased', 'completed', 'dead-letter'))\n);\nCREATE INDEX IF NOT EXISTS sync_reactions_due\n ON sync_reactions(partition, status, available_at_ms, created_at_ms, idempotency_key);\nCREATE INDEX IF NOT EXISTS sync_reactions_lease\n ON sync_reactions(partition, status, lease_expires_at_ms);\nCREATE INDEX IF NOT EXISTS sync_reactions_completed\n ON sync_reactions(partition, status, completed_at_ms, idempotency_key);\nCREATE INDEX IF NOT EXISTS sync_reactions_dead_letter\n ON sync_reactions(partition, status, available_at_ms, idempotency_key);\nCREATE TABLE IF NOT EXISTS sync_clients(\n partition TEXT NOT NULL, client_id TEXT NOT NULL, actor_id TEXT NOT NULL,\n wire_version INTEGER NOT NULL DEFAULT 1,\n cursor BIGINT NOT NULL, subscriptions JSONB NOT NULL,\n updated_at_ms BIGINT NOT NULL,\n PRIMARY KEY(partition, client_id)\n);\nALTER TABLE sync_clients\n ADD COLUMN IF NOT EXISTS wire_version INTEGER NOT NULL DEFAULT 1;\nCREATE TABLE IF NOT EXISTS sync_blob_refs(\n partition TEXT NOT NULL, tbl TEXT NOT NULL, row_id TEXT NOT NULL,\n blob_id TEXT NOT NULL,\n PRIMARY KEY(partition, tbl, row_id, blob_id)\n);\nCREATE INDEX IF NOT EXISTS sync_blob_refs_by_blob\n ON sync_blob_refs(partition, blob_id);\n";
27
28
  export declare class PostgresServerStorage implements ServerStorage {
28
29
  #private;
29
30
  constructor(exec: PgExecutor);
@@ -32,6 +33,9 @@ export declare class PostgresServerStorage implements ServerStorage {
32
33
  /** Resolve a table's compiled schema; row operations require `ensureSchema`. */
33
34
  table(name: string): CompiledTable;
34
35
  ensureSchema(schema: CompiledSchema): Promise<void>;
36
+ touchPartition(partition: string, authenticatedAtMs: number, initialLogEpoch: string): Promise<PartitionRegistryEntry>;
37
+ rotatePartitionLogEpoch(partition: string, logEpoch: string, authenticatedAtMs: number): Promise<PartitionRegistryEntry>;
38
+ listPartitionRegistry(): Promise<PartitionRegistryEntry[]>;
35
39
  /**
36
40
  * Open a real Postgres transaction. The push handler drives the returned
37
41
  * `StorageTransaction` imperatively (getRow/upsert/…/commit), but the
@@ -43,9 +47,10 @@ export declare class PostgresServerStorage implements ServerStorage {
43
47
  begin(partition: string): Promise<StorageTransaction>;
44
48
  getMaxCommitSeq(partition: string): Promise<number>;
45
49
  queryAuthoritative(partition: string, query: AuthoritativeQueryRequest): Promise<AuthoritativeQueryResult>;
50
+ getPartitionLogEpoch(partition: string): Promise<string | undefined>;
46
51
  getHorizonSeq(partition: string): Promise<number>;
47
52
  setHorizonSeq(partition: string, seq: number): Promise<void>;
48
- pruneCommitsThrough(partition: string, seq: number): Promise<number>;
53
+ pruneCommitsThrough(partition: string, query: CommitPruneQuery): Promise<CommitPruneResult>;
49
54
  getCommitSeqBefore(partition: string, createdBeforeMs: number): Promise<number>;
50
55
  getRow(partition: string, table: string, rowId: string): Promise<StoredRow | undefined>;
51
56
  getPushResult(partition: string, clientId: string, clientCommitId: string): Promise<StoredPushResult | undefined>;
@@ -62,6 +67,7 @@ export declare class PostgresServerStorage implements ServerStorage {
62
67
  scanRowsByIndex(partition: string, query: IndexRowScanQuery): Promise<StoredRow[]>;
63
68
  getClientRecord(partition: string, clientId: string): Promise<ClientRecord | undefined>;
64
69
  putClientRecord(partition: string, record: ClientRecord): Promise<void>;
70
+ getActiveClientCursorFloor(partition: string, cutoffMs: number): Promise<number | null>;
65
71
  listClientCursors(partition: string): Promise<ClientCursorInfo[]>;
66
72
  listRowsReferencingBlob(partition: string, blobId: string): Promise<{
67
73
  readonly table: string;
@@ -1,3 +1,5 @@
1
+ import { validateCommitPruneQuery } from './prune.js';
2
+ import { StorageQueryError } from './storage-errors.js';
1
3
  import { bindAuthoritativePartition, postgresPlaceholders, prepareAuthoritativeQuery, } from './authoritative-query.js';
2
4
  import { syncError } from './errors.js';
3
5
  import { asBytes, asNumber, } from './pg-executor.js';
@@ -33,6 +35,12 @@ CREATE TABLE IF NOT EXISTS sync_partitions(
33
35
  max_commit_seq BIGINT NOT NULL DEFAULT 0,
34
36
  horizon_seq BIGINT NOT NULL DEFAULT 0
35
37
  );
38
+ CREATE TABLE IF NOT EXISTS sync_partition_registry(
39
+ partition TEXT PRIMARY KEY,
40
+ log_epoch TEXT NOT NULL,
41
+ epoch_required BOOLEAN NOT NULL DEFAULT FALSE,
42
+ last_authenticated_at_ms BIGINT NOT NULL
43
+ );
36
44
  CREATE TABLE IF NOT EXISTS sync_row_scopes(
37
45
  partition TEXT NOT NULL, tbl TEXT NOT NULL,
38
46
  var TEXT NOT NULL, value TEXT NOT NULL, row_id TEXT NOT NULL,
@@ -86,10 +94,13 @@ CREATE INDEX IF NOT EXISTS sync_reactions_dead_letter
86
94
  ON sync_reactions(partition, status, available_at_ms, idempotency_key);
87
95
  CREATE TABLE IF NOT EXISTS sync_clients(
88
96
  partition TEXT NOT NULL, client_id TEXT NOT NULL, actor_id TEXT NOT NULL,
97
+ wire_version INTEGER NOT NULL DEFAULT 1,
89
98
  cursor BIGINT NOT NULL, subscriptions JSONB NOT NULL,
90
99
  updated_at_ms BIGINT NOT NULL,
91
100
  PRIMARY KEY(partition, client_id)
92
101
  );
102
+ ALTER TABLE sync_clients
103
+ ADD COLUMN IF NOT EXISTS wire_version INTEGER NOT NULL DEFAULT 1;
93
104
  CREATE TABLE IF NOT EXISTS sync_blob_refs(
94
105
  partition TEXT NOT NULL, tbl TEXT NOT NULL, row_id TEXT NOT NULL,
95
106
  blob_id TEXT NOT NULL,
@@ -98,6 +109,11 @@ CREATE TABLE IF NOT EXISTS sync_blob_refs(
98
109
  CREATE INDEX IF NOT EXISTS sync_blob_refs_by_blob
99
110
  ON sync_blob_refs(partition, blob_id);
100
111
  `;
112
+ async function lockPartitionOn(client, partition) {
113
+ await client.query(`INSERT INTO sync_partitions(partition, max_commit_seq) VALUES ($1, 0)
114
+ ON CONFLICT (partition) DO NOTHING`, [partition]);
115
+ await client.query('SELECT max_commit_seq FROM sync_partitions WHERE partition=$1 FOR UPDATE', [partition]);
116
+ }
101
117
  function toBase64(bytes) {
102
118
  return Buffer.from(bytes).toString('base64');
103
119
  }
@@ -372,9 +388,7 @@ class PostgresTransaction {
372
388
  }
373
389
  async lockPartitionForPush() {
374
390
  this.#assertOpen();
375
- await this.#client.query(`INSERT INTO sync_partitions(partition, max_commit_seq) VALUES ($1, 0)
376
- ON CONFLICT (partition) DO NOTHING`, [this.#partition]);
377
- await this.#client.query('SELECT max_commit_seq FROM sync_partitions WHERE partition=$1 FOR UPDATE', [this.#partition]);
391
+ await lockPartitionOn(this.#client, this.#partition);
378
392
  await this.#client.query('SAVEPOINT syncular_push_candidate');
379
393
  this.#pushApplySavepoint = true;
380
394
  }
@@ -628,6 +642,59 @@ export class PostgresServerStorage {
628
642
  this.#tables = schema.tables;
629
643
  this.#schemaVersion = schema.version;
630
644
  }
645
+ async touchPartition(partition, authenticatedAtMs, initialLogEpoch) {
646
+ if (initialLogEpoch.length === 0) {
647
+ throw new Error('initial log epoch must be non-empty');
648
+ }
649
+ const { rows } = await this.#exec.query(`INSERT INTO sync_partition_registry(
650
+ partition, log_epoch, last_authenticated_at_ms
651
+ ) VALUES ($1,$2,$3)
652
+ ON CONFLICT(partition) DO UPDATE SET
653
+ last_authenticated_at_ms=EXCLUDED.last_authenticated_at_ms
654
+ RETURNING log_epoch, epoch_required, last_authenticated_at_ms`, [partition, initialLogEpoch, authenticatedAtMs]);
655
+ const row = rows[0];
656
+ if (row === undefined)
657
+ throw new Error('partition registry write did not persist');
658
+ return {
659
+ partition,
660
+ logEpoch: row.log_epoch,
661
+ epochRequired: row.epoch_required,
662
+ lastAuthenticatedAtMs: asNumber(row.last_authenticated_at_ms),
663
+ };
664
+ }
665
+ async rotatePartitionLogEpoch(partition, logEpoch, authenticatedAtMs) {
666
+ if (logEpoch.length === 0)
667
+ throw new Error('log epoch must be non-empty');
668
+ await this.#exec.transaction(async (client) => {
669
+ await lockPartitionOn(client, partition);
670
+ await client.query(`INSERT INTO sync_partition_registry(
671
+ partition, log_epoch, epoch_required, last_authenticated_at_ms
672
+ ) VALUES ($1,$2,TRUE,$3)
673
+ ON CONFLICT(partition) DO UPDATE SET
674
+ log_epoch=EXCLUDED.log_epoch,
675
+ epoch_required=TRUE,
676
+ last_authenticated_at_ms=EXCLUDED.last_authenticated_at_ms`, [partition, logEpoch, authenticatedAtMs]);
677
+ await client.query('DELETE FROM sync_clients WHERE partition=$1', [
678
+ partition,
679
+ ]);
680
+ });
681
+ return {
682
+ partition,
683
+ logEpoch,
684
+ epochRequired: true,
685
+ lastAuthenticatedAtMs: authenticatedAtMs,
686
+ };
687
+ }
688
+ async listPartitionRegistry() {
689
+ const { rows } = await this.#exec.query(`SELECT partition, log_epoch, epoch_required, last_authenticated_at_ms
690
+ FROM sync_partition_registry ORDER BY partition`, []);
691
+ return rows.map((row) => ({
692
+ partition: row.partition,
693
+ logEpoch: row.log_epoch,
694
+ epochRequired: row.epoch_required,
695
+ lastAuthenticatedAtMs: asNumber(row.last_authenticated_at_ms),
696
+ }));
697
+ }
631
698
  /**
632
699
  * Open a real Postgres transaction. The push handler drives the returned
633
700
  * `StorageTransaction` imperatively (getRow/upsert/…/commit), but the
@@ -671,7 +738,7 @@ export class PostgresServerStorage {
671
738
  if (this.#tables === undefined) {
672
739
  throw new Error('ensureSchema(schema) must run before registered queries');
673
740
  }
674
- const prepared = bindAuthoritativePartition(prepareAuthoritativeQuery(query.sql, query.params, query.tables, this.#tables), partition);
741
+ const prepared = bindAuthoritativePartition(prepareAuthoritativeQuery(query.plan, query.params, query.tables, this.#tables), partition);
675
742
  return this.#exec.transaction(async (client) => {
676
743
  await client.query('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');
677
744
  const result = await client.query(postgresPlaceholders(prepared.sql), prepared.params);
@@ -684,19 +751,38 @@ export class PostgresServerStorage {
684
751
  };
685
752
  });
686
753
  }
754
+ async getPartitionLogEpoch(partition) {
755
+ const { rows } = await this.#exec.query('SELECT log_epoch FROM sync_partition_registry WHERE partition=$1', [partition]);
756
+ return rows[0]?.log_epoch;
757
+ }
687
758
  async getHorizonSeq(partition) {
688
759
  const { rows } = await this.#exec.query('SELECT horizon_seq FROM sync_partitions WHERE partition=$1', [partition]);
689
760
  return rows[0] === undefined ? 0 : asNumber(rows[0].horizon_seq);
690
761
  }
691
762
  async setHorizonSeq(partition, seq) {
692
763
  await this.#exec.query(`INSERT INTO sync_partitions(partition, horizon_seq) VALUES ($1,$2)
693
- ON CONFLICT (partition) DO UPDATE SET horizon_seq=EXCLUDED.horizon_seq`, [partition, seq]);
764
+ ON CONFLICT (partition) DO UPDATE SET horizon_seq=GREATEST(sync_partitions.horizon_seq,EXCLUDED.horizon_seq)`, [partition, seq]);
694
765
  }
695
- async pruneCommitsThrough(partition, seq) {
696
- const removed = await this.#exec.query('DELETE FROM sync_commits WHERE partition=$1 AND commit_seq<=$2', [partition, seq]);
697
- await this.#exec.query('DELETE FROM sync_changes WHERE partition=$1 AND commit_seq<=$2', [partition, seq]);
698
- await this.#exec.query('DELETE FROM sync_change_scopes WHERE partition=$1 AND commit_seq<=$2', [partition, seq]);
699
- return removed.rowCount;
766
+ async pruneCommitsThrough(partition, query) {
767
+ validateCommitPruneQuery(query);
768
+ return this.#exec.transaction(async (client) => {
769
+ await lockPartitionOn(client, partition);
770
+ const epoch = await client.query('SELECT log_epoch FROM sync_partition_registry WHERE partition=$1 FOR UPDATE', [partition]);
771
+ if (epoch.rows[0]?.log_epoch !== query.logEpoch)
772
+ throw new StorageQueryError('sync.storage.prune_epoch_mismatch');
773
+ const previous = await client.query('SELECT horizon_seq FROM sync_partitions WHERE partition=$1', [partition]);
774
+ const previousHorizonSeq = asNumber(previous.rows[0]?.horizon_seq);
775
+ const horizonSeq = Math.max(previousHorizonSeq, query.throughSeq);
776
+ await client.query('UPDATE sync_partitions SET horizon_seq=$2 WHERE partition=$1', [partition, horizonSeq]);
777
+ const removed = await client.query('DELETE FROM sync_commits WHERE partition=$1 AND commit_seq<=$2', [partition, horizonSeq]);
778
+ await client.query('DELETE FROM sync_changes WHERE partition=$1 AND commit_seq<=$2', [partition, horizonSeq]);
779
+ await client.query('DELETE FROM sync_change_scopes WHERE partition=$1 AND commit_seq<=$2', [partition, horizonSeq]);
780
+ return {
781
+ previousHorizonSeq,
782
+ horizonSeq,
783
+ removedCommits: removed.rowCount,
784
+ };
785
+ });
700
786
  }
701
787
  async getCommitSeqBefore(partition, createdBeforeMs) {
702
788
  const { rows } = await this.#exec.query('SELECT max(commit_seq) AS seq FROM sync_commits WHERE partition=$1 AND created_at_ms<$2', [partition, createdBeforeMs]);
@@ -978,33 +1064,40 @@ export class PostgresServerStorage {
978
1064
  return scanRowsByIndexOn(this.#exec, this.table(query.table), partition, query);
979
1065
  }
980
1066
  async getClientRecord(partition, clientId) {
981
- const { rows } = await this.#exec.query('SELECT client_id, actor_id, cursor, subscriptions, updated_at_ms FROM sync_clients WHERE partition=$1 AND client_id=$2', [partition, clientId]);
1067
+ const { rows } = await this.#exec.query('SELECT client_id, actor_id, wire_version, cursor, subscriptions, updated_at_ms FROM sync_clients WHERE partition=$1 AND client_id=$2', [partition, clientId]);
982
1068
  const record = rows[0];
983
1069
  if (record === undefined)
984
1070
  return undefined;
985
1071
  return {
986
1072
  clientId: record.client_id,
987
1073
  actorId: record.actor_id,
1074
+ wireVersion: asNumber(record.wire_version),
988
1075
  cursor: asNumber(record.cursor),
989
1076
  updatedAtMs: asNumber(record.updated_at_ms),
990
1077
  subscriptions: asJson(record.subscriptions),
991
1078
  };
992
1079
  }
993
1080
  async putClientRecord(partition, record) {
994
- await this.#exec.query(`INSERT INTO sync_clients(partition, client_id, actor_id, cursor, subscriptions, updated_at_ms)
995
- VALUES ($1,$2,$3,$4,$5,$6)
1081
+ await this.#exec.query(`INSERT INTO sync_clients(partition, client_id, actor_id, wire_version, cursor, subscriptions, updated_at_ms)
1082
+ VALUES ($1,$2,$3,$4,$5,$6,$7)
996
1083
  ON CONFLICT (partition, client_id) DO UPDATE
997
- SET actor_id=EXCLUDED.actor_id, cursor=EXCLUDED.cursor,
1084
+ SET actor_id=EXCLUDED.actor_id, wire_version=EXCLUDED.wire_version,
1085
+ cursor=EXCLUDED.cursor,
998
1086
  subscriptions=EXCLUDED.subscriptions,
999
1087
  updated_at_ms=EXCLUDED.updated_at_ms`, [
1000
1088
  partition,
1001
1089
  record.clientId,
1002
1090
  record.actorId,
1091
+ record.wireVersion,
1003
1092
  record.cursor,
1004
1093
  JSON.stringify(record.subscriptions),
1005
1094
  record.updatedAtMs,
1006
1095
  ]);
1007
1096
  }
1097
+ async getActiveClientCursorFloor(partition, cutoffMs) {
1098
+ const { rows } = await this.#exec.query('SELECT MIN(cursor) AS cursor FROM sync_clients WHERE partition=$1 AND updated_at_ms>=$2', [partition, cutoffMs]);
1099
+ return rows[0].cursor === null ? null : asNumber(rows[0].cursor);
1100
+ }
1008
1101
  async listClientCursors(partition) {
1009
1102
  const { rows } = await this.#exec.query('SELECT client_id, cursor, updated_at_ms FROM sync_clients WHERE partition=$1', [partition]);
1010
1103
  return rows.map((r) => ({
@@ -1040,10 +1133,11 @@ export class PostgresServerStorage {
1040
1133
  }
1041
1134
  // -- admin/console read surface --------------------------------------------
1042
1135
  async listClientRecords(partition) {
1043
- const { rows } = await this.#exec.query('SELECT client_id, actor_id, cursor, subscriptions, updated_at_ms FROM sync_clients WHERE partition=$1 ORDER BY updated_at_ms DESC', [partition]);
1136
+ const { rows } = await this.#exec.query('SELECT client_id, actor_id, wire_version, cursor, subscriptions, updated_at_ms FROM sync_clients WHERE partition=$1 ORDER BY updated_at_ms DESC', [partition]);
1044
1137
  return rows.map((r) => ({
1045
1138
  clientId: r.client_id,
1046
1139
  actorId: r.actor_id,
1140
+ wireVersion: asNumber(r.wire_version),
1047
1141
  cursor: asNumber(r.cursor),
1048
1142
  updatedAtMs: asNumber(r.updated_at_ms),
1049
1143
  subscriptions: (typeof r.subscriptions === 'string'
@@ -1114,10 +1208,6 @@ export class PostgresServerStorage {
1114
1208
  };
1115
1209
  }
1116
1210
  async listPartitions() {
1117
- // Union: the registry row appears on first commit, the client row on
1118
- // first pull — a partition with only one of the two still shows up.
1119
- const { rows } = await this.#exec.query(`SELECT partition FROM sync_partitions
1120
- UNION SELECT partition FROM sync_clients ORDER BY partition`, []);
1121
- return rows.map((r) => r.partition);
1211
+ return (await this.listPartitionRegistry()).map((entry) => entry.partition);
1122
1212
  }
1123
1213
  }
package/dist/prune.d.ts CHANGED
@@ -7,7 +7,9 @@
7
7
  * least the newest `minRetainedCommits` commits are always retained.
8
8
  */
9
9
  import { type SyncularServerEvents } from './events.js';
10
- import type { ServerStorage } from './storage.js';
10
+ import type { CommitPruneQuery, ServerStorage } from './storage.js';
11
+ /** Shared validation for the built-in atomic pruning adapters. */
12
+ export declare function validateCommitPruneQuery(query: CommitPruneQuery): void;
11
13
  export interface RetentionPolicy {
12
14
  /** Active window for laggard cursors (default 14 days). */
13
15
  readonly activeWindowMs: number;
package/dist/prune.js CHANGED
@@ -7,6 +7,16 @@
7
7
  * least the newest `minRetainedCommits` commits are always retained.
8
8
  */
9
9
  import { emitEvent } from './events.js';
10
+ import { StorageQueryError } from './storage-errors.js';
11
+ /** Shared validation for the built-in atomic pruning adapters. */
12
+ export function validateCommitPruneQuery(query) {
13
+ if (!Number.isSafeInteger(query.throughSeq) ||
14
+ query.throughSeq < 0 ||
15
+ typeof query.logEpoch !== 'string' ||
16
+ query.logEpoch.length === 0) {
17
+ throw new StorageQueryError('sync.storage.invalid_prune_cursor');
18
+ }
19
+ }
10
20
  export const DEFAULT_RETENTION = {
11
21
  activeWindowMs: 14 * 24 * 60 * 60 * 1000,
12
22
  ageForceMs: 30 * 24 * 60 * 60 * 1000,
@@ -16,24 +26,18 @@ export const DEFAULT_RETENTION = {
16
26
  export async function pruneCommitLog(options) {
17
27
  const { storage, partition, nowMs } = options;
18
28
  const policy = { ...DEFAULT_RETENTION, ...options.retention };
29
+ const logEpoch = await storage.getPartitionLogEpoch(partition);
30
+ if (logEpoch === undefined)
31
+ throw new StorageQueryError('sync.storage.partition_unregistered');
19
32
  const maxSeq = await storage.getMaxCommitSeq(partition);
20
- const cursors = await storage.listClientCursors(partition);
21
- const activeCursors = cursors
22
- .filter((c) => c.updatedAtMs >= nowMs - policy.activeWindowMs)
23
- .map((c) => c.cursor);
24
- const cursorFloor = activeCursors.length > 0
25
- ? Math.min(...activeCursors)
26
- : Number.MAX_SAFE_INTEGER;
33
+ const cursorFloor = (await storage.getActiveClientCursorFloor(partition, nowMs - policy.activeWindowMs)) ?? Number.MAX_SAFE_INTEGER;
27
34
  const forcedSeq = await storage.getCommitSeqBefore(partition, nowMs - policy.ageForceMs);
28
35
  const retainFloor = maxSeq - policy.minRetainedCommits;
29
36
  const target = Math.min(Math.max(cursorFloor, forcedSeq), retainFloor);
30
- const current = await storage.getHorizonSeq(partition);
31
- const horizon = Math.max(current, Math.max(0, target));
32
- let removedCommits = 0;
33
- if (horizon > current) {
34
- await storage.setHorizonSeq(partition, horizon);
35
- removedCommits = await storage.pruneCommitsThrough(partition, horizon);
36
- }
37
+ const { previousHorizonSeq: current, horizonSeq: horizon, removedCommits, } = await storage.pruneCommitsThrough(partition, {
38
+ logEpoch,
39
+ throughSeq: Math.max(0, target),
40
+ });
37
41
  const events = options.events;
38
42
  if (events !== undefined) {
39
43
  emitEvent(events, {
package/dist/pull.d.ts CHANGED
@@ -41,4 +41,4 @@ export interface PullSectionTrace {
41
41
  * Produce the `SUB_START … SUB_END` section for one subscription (§1.6),
42
42
  * returning the cursor recorded for the retention watermark (§4.5).
43
43
  */
44
- export declare function subscriptionSection(ctx: SyncRequestContext, schema: CompiledSchema, limits: PullLimits, plan: SubscriptionPlan, maxSeq: number, horizonSeq: number, trace?: PullSectionTrace): AsyncGenerator<ResponseFrame, SubscriptionResult>;
44
+ export declare function subscriptionSection(ctx: SyncRequestContext, schema: CompiledSchema, limits: PullLimits, plan: SubscriptionPlan, maxSeq: number, horizonSeq: number, trace?: PullSectionTrace, logEpoch?: string): AsyncGenerator<ResponseFrame, SubscriptionResult>;
package/dist/pull.js CHANGED
@@ -6,6 +6,8 @@ import { decodeRow, encodeRowsSegment, } from '@syncular/core';
6
6
  import { clockOf, limitsOf } from './context.js';
7
7
  import { scopeDigest } from './scopes.js';
8
8
  import { issueSegmentUrl } from './signed-url.js';
9
+ // One artifact build per owning storage pair and complete immutable identity.
10
+ const imageBuilds = new WeakMap();
9
11
  /**
10
12
  * Resolve the §5.3 image builder: the host-injected one if present, else the
11
13
  * in-tree `buildSqliteImage` on a Bun runtime (dynamic import so `bun:sqlite`
@@ -134,11 +136,12 @@ function segmentRefFrame(record, extra) {
134
136
  * bootstrap-storm rule. Returns false when the table is not eligible
135
137
  * (the rows lane takes over).
136
138
  */
137
- async function* sqliteImageSegment(ctx, schema, limits, plan, asOf, digest, trace) {
139
+ async function* sqliteImageSegment(ctx, schema, limits, plan, asOf, digest, trace, logEpoch) {
138
140
  const { storage, segments, partition } = ctx;
139
141
  const now = clockOf(ctx)();
140
142
  const existing = await segments.find({
141
143
  partition,
144
+ logEpoch,
142
145
  table: plan.table.name,
143
146
  schemaVersion: schema.version,
144
147
  mediaType: 'sqlite',
@@ -176,54 +179,84 @@ async function* sqliteImageSegment(ctx, schema, limits, plan, asOf, digest, trac
176
179
  const buildImage = await resolveImageBuilder(ctx);
177
180
  if (buildImage === undefined)
178
181
  return false;
179
- // The probe rows are the snapshot's first page — keep them and scan on
180
- // from the probe's cursor instead of re-reading the whole prefix (the
181
- // scan is keyset-ordered by rowId, so the concatenation is exactly the
182
- // rows a single full scan would return).
183
- const rows = [...probe];
184
- let afterRowId = probe[probe.length - 1]?.rowId ?? null;
185
- for (;;) {
186
- const scanned = await storage.scanRows(partition, {
187
- table: plan.table.name,
188
- scopeFilter: plan.effective,
189
- afterRowId,
190
- limit: 50_000,
191
- });
192
- rows.push(...scanned);
193
- const last = scanned[scanned.length - 1];
194
- if (scanned.length < 50_000 || last === undefined)
195
- break;
196
- afterRowId = last.rowId;
182
+ let stores = imageBuilds.get(storage);
183
+ if (stores === undefined) {
184
+ stores = new WeakMap();
185
+ imageBuilds.set(storage, stores);
197
186
  }
198
- const bytes = buildImage({
199
- table: plan.table,
200
- schemaVersion: schema.version,
201
- asOfCommitSeq: asOf,
202
- scopeDigest: digest,
203
- rows,
204
- });
205
- const record = await segments.put({
187
+ let builds = stores.get(segments);
188
+ if (builds === undefined) {
189
+ builds = new Map();
190
+ stores.set(segments, builds);
191
+ }
192
+ const identity = {
206
193
  partition,
194
+ logEpoch,
207
195
  table: plan.table.name,
208
196
  schemaVersion: schema.version,
209
197
  mediaType: 'sqlite',
210
198
  scopeDigest: digest,
211
199
  asOfCommitSeq: asOf,
212
- rowCount: rows.length,
213
- rowCursor: null,
214
- nextRowCursor: null,
215
- }, bytes, now);
200
+ };
201
+ const key = JSON.stringify(identity);
202
+ let building = builds.get(key);
203
+ let builtHere = false;
204
+ if (building === undefined) {
205
+ building = (async () => {
206
+ // Another request can finish while this one's eligibility probe awaits.
207
+ const cached = await segments.find(identity, clockOf(ctx)());
208
+ if (cached !== undefined)
209
+ return cached;
210
+ builtHere = true;
211
+ let rowCount = 0;
212
+ const bytes = await buildImage({
213
+ table: plan.table,
214
+ schemaVersion: schema.version,
215
+ asOfCommitSeq: asOf,
216
+ scopeDigest: digest,
217
+ rowBatches: (async function* () {
218
+ rowCount += probe.length;
219
+ yield probe;
220
+ let afterRowId = probe[probe.length - 1].rowId;
221
+ for (;;) {
222
+ const rows = await storage.scanRows(partition, {
223
+ table: plan.table.name,
224
+ scopeFilter: plan.effective,
225
+ afterRowId,
226
+ limit: 5_000,
227
+ });
228
+ rowCount += rows.length;
229
+ yield rows;
230
+ const last = rows[rows.length - 1];
231
+ if (rows.length < 5_000 || last === undefined)
232
+ break;
233
+ afterRowId = last.rowId;
234
+ }
235
+ })(),
236
+ });
237
+ return segments.put({ ...identity, rowCount, rowCursor: null, nextRowCursor: null }, bytes, clockOf(ctx)());
238
+ })();
239
+ builds.set(key, building);
240
+ }
241
+ let record;
242
+ try {
243
+ record = await building;
244
+ }
245
+ finally {
246
+ if (builds.get(key) === building)
247
+ builds.delete(key);
248
+ }
216
249
  trace?.segments.push({
217
250
  mediaType: 'sqlite',
218
251
  delivery: 'ref',
219
- origin: 'built',
252
+ origin: builtHere ? 'built' : 'reused',
220
253
  bytes: record.byteLength,
221
254
  rows: record.rowCount,
222
255
  });
223
- yield segmentRefFrame(record, await signedUrlFields(ctx, limits, record.segmentId, digest, now));
256
+ yield segmentRefFrame(record, await signedUrlFields(ctx, limits, record.segmentId, digest, clockOf(ctx)()));
224
257
  return true;
225
258
  }
226
- async function* bootstrapSegments(ctx, schema, limits, plan, asOf, startRowCursor, trace) {
259
+ async function* bootstrapSegments(ctx, schema, limits, plan, asOf, startRowCursor, trace, logEpoch) {
227
260
  const { storage, segments, partition } = ctx;
228
261
  const serverLimits = limitsOf(ctx);
229
262
  const digest = await scopeDigest(plan.effective);
@@ -237,7 +270,7 @@ async function* bootstrapSegments(ctx, schema, limits, plan, asOf, startRowCurso
237
270
  if ((limits.accept & ACCEPT_SQLITE) !== 0 &&
238
271
  startRowCursor === null &&
239
272
  plan.table.encryptedColumnIndices.length === 0) {
240
- const imaged = yield* sqliteImageSegment(ctx, schema, limits, plan, asOf, digest, trace);
273
+ const imaged = yield* sqliteImageSegment(ctx, schema, limits, plan, asOf, digest, trace, logEpoch);
241
274
  if (imaged)
242
275
  return { complete: true, rowCursor: null };
243
276
  }
@@ -281,6 +314,7 @@ async function* bootstrapSegments(ctx, schema, limits, plan, asOf, startRowCurso
281
314
  else {
282
315
  const record = await segments.put({
283
316
  partition,
317
+ logEpoch,
284
318
  table: plan.table.name,
285
319
  schemaVersion: schema.version,
286
320
  mediaType: 'rows',
@@ -309,8 +343,11 @@ async function* bootstrapSegments(ctx, schema, limits, plan, asOf, startRowCurso
309
343
  * Produce the `SUB_START … SUB_END` section for one subscription (§1.6),
310
344
  * returning the cursor recorded for the retention watermark (§4.5).
311
345
  */
312
- export async function* subscriptionSection(ctx, schema, limits, plan, maxSeq, horizonSeq, trace) {
346
+ export async function* subscriptionSection(ctx, schema, limits, plan, maxSeq, horizonSeq, trace, logEpoch) {
313
347
  const sub = plan.frame;
348
+ if (logEpoch === undefined || logEpoch.length === 0) {
349
+ throw new Error('subscriptionSection requires a non-empty log epoch');
350
+ }
314
351
  if (plan.status === 'revoked') {
315
352
  yield {
316
353
  type: 'SUB_START',
@@ -355,7 +392,7 @@ export async function* subscriptionSection(ctx, schema, limits, plan, maxSeq, ho
355
392
  effectiveScopes: plan.effective,
356
393
  bootstrap: true,
357
394
  };
358
- const outcome = yield* bootstrapSegments(ctx, schema, limits, plan, asOf, startCursor, trace);
395
+ const outcome = yield* bootstrapSegments(ctx, schema, limits, plan, asOf, startCursor, trace, logEpoch);
359
396
  if (outcome.complete) {
360
397
  yield { type: 'SUB_END', nextCursor: asOf };
361
398
  }
package/dist/push.js CHANGED
@@ -98,7 +98,7 @@ async function runValidator(validators, table, op, rowId, values, storedValues,
98
98
  // §6.7: a non-ValidationRejection throw is still a rejection, mapped to
99
99
  // the generic server-side constraint code (§10.2) — the validator's
100
100
  // failure never crashes the request or leaks its message as a code.
101
- return errorRecord(opIndex, 'sync.constraint_violation', `write validator for table ${JSON.stringify(table.name)} threw: ${error instanceof Error ? error.message : String(error)}`);
101
+ return errorRecord(opIndex, 'sync.constraint_violation', 'write validator failed');
102
102
  }
103
103
  return undefined;
104
104
  }
@@ -124,7 +124,7 @@ async function mergeCrdtColumns(table, values, storedValues, opIndex, mergers) {
124
124
  continue; // NULL clear or absent
125
125
  const merger = mergers?.[crdtType];
126
126
  if (merger === undefined) {
127
- return errorRecord(opIndex, 'sync.crdt_merge_failed', `no CRDT merger registered for crdtType ${JSON.stringify(crdtType)} (§5.10.2)`);
127
+ return errorRecord(opIndex, 'sync.crdt_merge_failed', 'no CRDT merger registered');
128
128
  }
129
129
  const storedRaw = storedValues?.[index];
130
130
  const stored = storedRaw instanceof Uint8Array ? storedRaw : null;
@@ -132,8 +132,8 @@ async function mergeCrdtColumns(table, values, storedValues, opIndex, mergers) {
132
132
  try {
133
133
  merged = await merger(stored, incoming);
134
134
  }
135
- catch (error) {
136
- return errorRecord(opIndex, 'sync.crdt_merge_failed', `CRDT merger for ${JSON.stringify(crdtType)} threw: ${error instanceof Error ? error.message : String(error)}`);
135
+ catch {
136
+ return errorRecord(opIndex, 'sync.crdt_merge_failed', 'CRDT merger failed');
137
137
  }
138
138
  values[index] = merged;
139
139
  changed = true;
@@ -452,7 +452,7 @@ async function runCommitValidator(validator, tx, schema, clientId, clientCommitI
452
452
  if (error instanceof ValidationRejection) {
453
453
  return errorRecord(operations[0]?.opIndex ?? 0, error.code, error.message, false, error.details);
454
454
  }
455
- return errorRecord(operations[0]?.opIndex ?? 0, 'sync.constraint_violation', `whole-commit validator threw: ${error instanceof Error ? error.message : String(error)}`);
455
+ return errorRecord(operations[0]?.opIndex ?? 0, 'sync.constraint_violation', 'whole-commit validator failed');
456
456
  }
457
457
  return undefined;
458
458
  }
@@ -96,6 +96,9 @@ export declare class RealtimeSession {
96
96
  readonly partition: string;
97
97
  readonly actorId: string;
98
98
  readonly clientId: string;
99
+ readonly logEpoch: string;
100
+ /** Response/delta layout selected by the most recent socket round. */
101
+ wireVersion: number;
99
102
  /** Highest contiguously applied commitSeq acknowledged by the client. */
100
103
  cursor: number;
101
104
  /** Suppress deltas until the client catches up via pull + ack (§8.2). */
@@ -105,7 +108,7 @@ export declare class RealtimeSession {
105
108
  registrations: readonly Registration[];
106
109
  /** Epoch-ms (hub clock) at registration, for `realtime.closed`. */
107
110
  readonly openedAtMs: number;
108
- constructor(hub: RealtimeHub, options: RealtimeConnectOptions, registrations: readonly Registration[], cursor: number, latestSeq: number, clock: () => number, maxDeltaBytes: number, storage: ServerStorage, events: SyncularServerEvents | undefined);
111
+ constructor(hub: RealtimeHub, options: RealtimeConnectOptions, registrations: readonly Registration[], cursor: number, latestSeq: number, clock: () => number, maxDeltaBytes: number, storage: ServerStorage, logEpoch: string, wireVersion: number, events: SyncularServerEvents | undefined);
109
112
  /** Feed an inbound text frame (client → server control message, §8.2
110
113
  * ack, §8.6.2 presence). */
111
114
  handleMessage(text: string): void;