@syncular/server 0.15.46 → 0.15.48

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 (91) hide show
  1. package/README.md +17 -4
  2. package/dist/admin.d.ts +1 -5
  3. package/dist/admin.js +2 -7
  4. package/dist/blob-handlers.js +4 -1
  5. package/dist/context.d.ts +5 -2
  6. package/dist/context.js +4 -0
  7. package/dist/d1-storage.d.ts +4 -1
  8. package/dist/d1-storage.js +75 -11
  9. package/dist/errors.js +6 -0
  10. package/dist/events.d.ts +2 -1
  11. package/dist/frame-bytes.d.ts +2 -2
  12. package/dist/frame-bytes.js +33 -14
  13. package/dist/handler.js +58 -14
  14. package/dist/index-bun.d.ts +2 -0
  15. package/dist/index-bun.js +2 -0
  16. package/dist/index-node.d.ts +2 -0
  17. package/dist/index-node.js +2 -0
  18. package/dist/index.d.ts +4 -2
  19. package/dist/index.js +4 -6
  20. package/dist/operations.js +2 -1
  21. package/dist/postgres-storage.d.ts +5 -2
  22. package/dist/postgres-storage.js +71 -10
  23. package/dist/pull.d.ts +1 -1
  24. package/dist/pull.js +12 -6
  25. package/dist/realtime.d.ts +4 -1
  26. package/dist/realtime.js +33 -9
  27. package/dist/restore.d.ts +13 -0
  28. package/dist/restore.js +13 -0
  29. package/dist/s3-segment-store.js +10 -1
  30. package/dist/seed.js +36 -4
  31. package/dist/segment-download.js +5 -2
  32. package/dist/segment-store.d.ts +3 -0
  33. package/dist/segment-store.js +1 -0
  34. package/dist/sqlite-blob-store.d.ts +4 -9
  35. package/dist/sqlite-blob-store.js +5 -10
  36. package/dist/sqlite-bun-driver.d.ts +12 -0
  37. package/dist/sqlite-bun-driver.js +30 -0
  38. package/dist/sqlite-bun.d.ts +24 -0
  39. package/dist/sqlite-bun.js +40 -0
  40. package/dist/sqlite-dialect.d.ts +8 -8
  41. package/dist/sqlite-dialect.js +9 -2
  42. package/dist/sqlite-driver.d.ts +26 -0
  43. package/dist/sqlite-driver.js +8 -0
  44. package/dist/sqlite-image.d.ts +7 -9
  45. package/dist/sqlite-image.js +26 -28
  46. package/dist/sqlite-lease-store.d.ts +4 -9
  47. package/dist/sqlite-lease-store.js +5 -10
  48. package/dist/sqlite-node-driver.d.ts +10 -0
  49. package/dist/sqlite-node-driver.js +30 -0
  50. package/dist/sqlite-node.d.ts +24 -0
  51. package/dist/sqlite-node.js +50 -0
  52. package/dist/sqlite-segment-store.d.ts +4 -10
  53. package/dist/sqlite-segment-store.js +20 -14
  54. package/dist/sqlite-storage.d.ts +7 -12
  55. package/dist/sqlite-storage.js +89 -16
  56. package/dist/storage-errors.js +4 -1
  57. package/dist/storage.d.ts +18 -5
  58. package/package.json +18 -3
  59. package/src/admin.ts +3 -9
  60. package/src/blob-handlers.ts +8 -1
  61. package/src/context.ts +18 -2
  62. package/src/d1-storage.ts +107 -12
  63. package/src/errors.ts +6 -0
  64. package/src/events.ts +2 -1
  65. package/src/frame-bytes.ts +40 -14
  66. package/src/handler.ts +102 -29
  67. package/src/index-bun.ts +9 -0
  68. package/src/index-node.ts +9 -0
  69. package/src/index.ts +9 -6
  70. package/src/operations.ts +6 -1
  71. package/src/postgres-storage.ts +102 -13
  72. package/src/pull.ts +12 -1
  73. package/src/realtime.ts +46 -7
  74. package/src/restore.ts +28 -0
  75. package/src/s3-segment-store.ts +10 -1
  76. package/src/seed.ts +46 -4
  77. package/src/segment-download.ts +11 -2
  78. package/src/segment-store.ts +4 -0
  79. package/src/sqlite-blob-store.ts +11 -10
  80. package/src/sqlite-bun-driver.ts +46 -0
  81. package/src/sqlite-bun.ts +53 -0
  82. package/src/sqlite-dialect.ts +14 -7
  83. package/src/sqlite-driver.ts +44 -0
  84. package/src/sqlite-image.ts +44 -49
  85. package/src/sqlite-lease-store.ts +11 -10
  86. package/src/sqlite-node-driver.ts +46 -0
  87. package/src/sqlite-node.ts +62 -0
  88. package/src/sqlite-segment-store.ts +29 -15
  89. package/src/sqlite-storage.ts +131 -19
  90. package/src/storage-errors.ts +4 -1
  91. package/src/storage.ts +28 -5
package/dist/index.d.ts CHANGED
@@ -32,6 +32,7 @@ export * from './push.js';
32
32
  export * from './readiness.js';
33
33
  export { DEFAULT_REACTION_INITIAL_BACKOFF_MS, DEFAULT_REACTION_LEASE_MS, DEFAULT_REACTION_MAX_ATTEMPTS, DEFAULT_REACTION_MAX_BACKOFF_MS, DEFAULT_REACTION_RETENTION, MAX_REACTION_FAILURE_DETAILS_BYTES, MAX_REACTION_PAYLOAD_BYTES, MAX_REACTIONS_PER_COMMIT, PermanentReactionError, pruneReactions, ReactionRunner, reactionIdempotencyKey, retryDeadLetterReaction, RetryableReactionError, type PlannedReaction, type PruneReactionsOptions, type ReactionHandler, type ReactionHandlerInput, type ReactionHandlers, type ReactionPlan, type ReactionPlanner, type ReactionPlannerInput, type ReactionPruneResult, type ReactionRetentionPolicy, type ReactionRunnerOptions, type ReactionRunResult, type ReactionTypeMap, } from './reactions.js';
34
34
  export * from './realtime.js';
35
+ export * from './restore.js';
35
36
  export * from './relational-rows.js';
36
37
  export * from './s3-blob-store.js';
37
38
  export * from './s3-segment-store.js';
@@ -41,9 +42,10 @@ export * from './seed.js';
41
42
  export * from './segment-download.js';
42
43
  export * from './segment-store.js';
43
44
  export * from './signed-url.js';
44
- export * from './sqlite-blob-store.js';
45
45
  export * from './sqlite-dialect.js';
46
- export * from './sqlite-image.js';
46
+ export * from './sqlite-driver.js';
47
+ export { IMAGE_METADATA_TABLE, IMAGE_VERSION_COLUMN, type SqliteImageBuilder, type SqliteImageInput, } from './sqlite-image.js';
48
+ export * from './sqlite-blob-store.js';
47
49
  export * from './sqlite-lease-store.js';
48
50
  export * from './sqlite-segment-store.js';
49
51
  export * from './sqlite-storage.js';
package/dist/index.js CHANGED
@@ -36,6 +36,7 @@ export * from './push.js';
36
36
  export * from './readiness.js';
37
37
  export { DEFAULT_REACTION_INITIAL_BACKOFF_MS, DEFAULT_REACTION_LEASE_MS, DEFAULT_REACTION_MAX_ATTEMPTS, DEFAULT_REACTION_MAX_BACKOFF_MS, DEFAULT_REACTION_RETENTION, MAX_REACTION_FAILURE_DETAILS_BYTES, MAX_REACTION_PAYLOAD_BYTES, MAX_REACTIONS_PER_COMMIT, PermanentReactionError, pruneReactions, ReactionRunner, reactionIdempotencyKey, retryDeadLetterReaction, RetryableReactionError, } from './reactions.js';
38
38
  export * from './realtime.js';
39
+ export * from './restore.js';
39
40
  export * from './relational-rows.js';
40
41
  export * from './s3-blob-store.js';
41
42
  export * from './s3-segment-store.js';
@@ -45,13 +46,10 @@ export * from './seed.js';
45
46
  export * from './segment-download.js';
46
47
  export * from './segment-store.js';
47
48
  export * from './signed-url.js';
48
- // Bun-specific storages (top-level `bun:sqlite`): re-exported for Bun/Node
49
- // hosts. Workers/edge builds tree-shake them (and their `bun:sqlite` import)
50
- // away — the runtime-neutral core closure is enforced by
51
- // `test/runtime-neutrality.test.ts`.
52
- export * from './sqlite-blob-store.js';
53
49
  export * from './sqlite-dialect.js';
54
- export * from './sqlite-image.js';
50
+ export * from './sqlite-driver.js';
51
+ export { IMAGE_METADATA_TABLE, IMAGE_VERSION_COLUMN, } from './sqlite-image.js';
52
+ export * from './sqlite-blob-store.js';
55
53
  export * from './sqlite-lease-store.js';
56
54
  export * from './sqlite-segment-store.js';
57
55
  export * from './sqlite-storage.js';
@@ -1,5 +1,5 @@
1
1
  import { decodeRow, decodeRemoteOperationRequest, encodeRow, encodeRemoteOperationResponse, } from '@syncular/core';
2
- import { REMOTE_COMMAND_CLIENT_ID_PREFIX, RESOLVER_OUTAGE } from './context.js';
2
+ import { REMOTE_COMMAND_CLIENT_ID_PREFIX, RESOLVER_OUTAGE, touchAuthenticatedPartition, } from './context.js';
3
3
  import { SyncError, syncError } from './errors.js';
4
4
  import { processPushOperationsWithTrace } from './push.js';
5
5
  import { compileSchema } from './schema.js';
@@ -362,6 +362,7 @@ export async function handleRemoteOperation(bytes, ctx, registry) {
362
362
  return encodeRemoteOperationError(error, 'operation.invalid_request');
363
363
  }
364
364
  try {
365
+ await touchAuthenticatedPartition(ctx);
365
366
  if (request.clientId.startsWith(REMOTE_COMMAND_CLIENT_ID_PREFIX)) {
366
367
  throw syncError('sync.invalid_client_id', 'clientId uses a reserved server-command namespace (§1.5)');
367
368
  }
@@ -1,6 +1,6 @@
1
1
  import { type PgExecutor } from './pg-executor.js';
2
2
  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';
3
+ 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
4
  /**
5
5
  * Schema DDL. Applied by `PostgresServerStorage.migrate()` (idempotent).
6
6
  *
@@ -23,7 +23,7 @@ import type { AuthoritativeQueryRequest, AuthoritativeQueryResult, ClientCursorI
23
23
  * set, §5.9.5) as an index range, never a scan. `postgres-explain
24
24
  * .test.ts` asserts an `Index` node here too.
25
25
  */
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";
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_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
27
  export declare class PostgresServerStorage implements ServerStorage {
28
28
  #private;
29
29
  constructor(exec: PgExecutor);
@@ -32,6 +32,9 @@ export declare class PostgresServerStorage implements ServerStorage {
32
32
  /** Resolve a table's compiled schema; row operations require `ensureSchema`. */
33
33
  table(name: string): CompiledTable;
34
34
  ensureSchema(schema: CompiledSchema): Promise<void>;
35
+ touchPartition(partition: string, authenticatedAtMs: number, initialLogEpoch: string): Promise<PartitionRegistryEntry>;
36
+ rotatePartitionLogEpoch(partition: string, logEpoch: string, authenticatedAtMs: number): Promise<PartitionRegistryEntry>;
37
+ listPartitionRegistry(): Promise<PartitionRegistryEntry[]>;
35
38
  /**
36
39
  * Open a real Postgres transaction. The push handler drives the returned
37
40
  * `StorageTransaction` imperatively (getRow/upsert/…/commit), but the
@@ -33,6 +33,12 @@ CREATE TABLE IF NOT EXISTS sync_partitions(
33
33
  max_commit_seq BIGINT NOT NULL DEFAULT 0,
34
34
  horizon_seq BIGINT NOT NULL DEFAULT 0
35
35
  );
36
+ CREATE TABLE IF NOT EXISTS sync_partition_registry(
37
+ partition TEXT PRIMARY KEY,
38
+ log_epoch TEXT NOT NULL,
39
+ epoch_required BOOLEAN NOT NULL DEFAULT FALSE,
40
+ last_authenticated_at_ms BIGINT NOT NULL
41
+ );
36
42
  CREATE TABLE IF NOT EXISTS sync_row_scopes(
37
43
  partition TEXT NOT NULL, tbl TEXT NOT NULL,
38
44
  var TEXT NOT NULL, value TEXT NOT NULL, row_id TEXT NOT NULL,
@@ -86,10 +92,13 @@ CREATE INDEX IF NOT EXISTS sync_reactions_dead_letter
86
92
  ON sync_reactions(partition, status, available_at_ms, idempotency_key);
87
93
  CREATE TABLE IF NOT EXISTS sync_clients(
88
94
  partition TEXT NOT NULL, client_id TEXT NOT NULL, actor_id TEXT NOT NULL,
95
+ wire_version INTEGER NOT NULL DEFAULT 1,
89
96
  cursor BIGINT NOT NULL, subscriptions JSONB NOT NULL,
90
97
  updated_at_ms BIGINT NOT NULL,
91
98
  PRIMARY KEY(partition, client_id)
92
99
  );
100
+ ALTER TABLE sync_clients
101
+ ADD COLUMN IF NOT EXISTS wire_version INTEGER NOT NULL DEFAULT 1;
93
102
  CREATE TABLE IF NOT EXISTS sync_blob_refs(
94
103
  partition TEXT NOT NULL, tbl TEXT NOT NULL, row_id TEXT NOT NULL,
95
104
  blob_id TEXT NOT NULL,
@@ -628,6 +637,58 @@ export class PostgresServerStorage {
628
637
  this.#tables = schema.tables;
629
638
  this.#schemaVersion = schema.version;
630
639
  }
640
+ async touchPartition(partition, authenticatedAtMs, initialLogEpoch) {
641
+ if (initialLogEpoch.length === 0) {
642
+ throw new Error('initial log epoch must be non-empty');
643
+ }
644
+ const { rows } = await this.#exec.query(`INSERT INTO sync_partition_registry(
645
+ partition, log_epoch, last_authenticated_at_ms
646
+ ) VALUES ($1,$2,$3)
647
+ ON CONFLICT(partition) DO UPDATE SET
648
+ last_authenticated_at_ms=EXCLUDED.last_authenticated_at_ms
649
+ RETURNING log_epoch, epoch_required, last_authenticated_at_ms`, [partition, initialLogEpoch, authenticatedAtMs]);
650
+ const row = rows[0];
651
+ if (row === undefined)
652
+ throw new Error('partition registry write did not persist');
653
+ return {
654
+ partition,
655
+ logEpoch: row.log_epoch,
656
+ epochRequired: row.epoch_required,
657
+ lastAuthenticatedAtMs: asNumber(row.last_authenticated_at_ms),
658
+ };
659
+ }
660
+ async rotatePartitionLogEpoch(partition, logEpoch, authenticatedAtMs) {
661
+ if (logEpoch.length === 0)
662
+ throw new Error('log epoch must be non-empty');
663
+ await this.#exec.transaction(async (client) => {
664
+ await client.query(`INSERT INTO sync_partition_registry(
665
+ partition, log_epoch, epoch_required, last_authenticated_at_ms
666
+ ) VALUES ($1,$2,TRUE,$3)
667
+ ON CONFLICT(partition) DO UPDATE SET
668
+ log_epoch=EXCLUDED.log_epoch,
669
+ epoch_required=TRUE,
670
+ last_authenticated_at_ms=EXCLUDED.last_authenticated_at_ms`, [partition, logEpoch, authenticatedAtMs]);
671
+ await client.query('DELETE FROM sync_clients WHERE partition=$1', [
672
+ partition,
673
+ ]);
674
+ });
675
+ return {
676
+ partition,
677
+ logEpoch,
678
+ epochRequired: true,
679
+ lastAuthenticatedAtMs: authenticatedAtMs,
680
+ };
681
+ }
682
+ async listPartitionRegistry() {
683
+ const { rows } = await this.#exec.query(`SELECT partition, log_epoch, epoch_required, last_authenticated_at_ms
684
+ FROM sync_partition_registry ORDER BY partition`, []);
685
+ return rows.map((row) => ({
686
+ partition: row.partition,
687
+ logEpoch: row.log_epoch,
688
+ epochRequired: row.epoch_required,
689
+ lastAuthenticatedAtMs: asNumber(row.last_authenticated_at_ms),
690
+ }));
691
+ }
631
692
  /**
632
693
  * Open a real Postgres transaction. The push handler drives the returned
633
694
  * `StorageTransaction` imperatively (getRow/upsert/…/commit), but the
@@ -978,28 +1039,31 @@ export class PostgresServerStorage {
978
1039
  return scanRowsByIndexOn(this.#exec, this.table(query.table), partition, query);
979
1040
  }
980
1041
  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]);
1042
+ 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
1043
  const record = rows[0];
983
1044
  if (record === undefined)
984
1045
  return undefined;
985
1046
  return {
986
1047
  clientId: record.client_id,
987
1048
  actorId: record.actor_id,
1049
+ wireVersion: asNumber(record.wire_version),
988
1050
  cursor: asNumber(record.cursor),
989
1051
  updatedAtMs: asNumber(record.updated_at_ms),
990
1052
  subscriptions: asJson(record.subscriptions),
991
1053
  };
992
1054
  }
993
1055
  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)
1056
+ await this.#exec.query(`INSERT INTO sync_clients(partition, client_id, actor_id, wire_version, cursor, subscriptions, updated_at_ms)
1057
+ VALUES ($1,$2,$3,$4,$5,$6,$7)
996
1058
  ON CONFLICT (partition, client_id) DO UPDATE
997
- SET actor_id=EXCLUDED.actor_id, cursor=EXCLUDED.cursor,
1059
+ SET actor_id=EXCLUDED.actor_id, wire_version=EXCLUDED.wire_version,
1060
+ cursor=EXCLUDED.cursor,
998
1061
  subscriptions=EXCLUDED.subscriptions,
999
1062
  updated_at_ms=EXCLUDED.updated_at_ms`, [
1000
1063
  partition,
1001
1064
  record.clientId,
1002
1065
  record.actorId,
1066
+ record.wireVersion,
1003
1067
  record.cursor,
1004
1068
  JSON.stringify(record.subscriptions),
1005
1069
  record.updatedAtMs,
@@ -1040,10 +1104,11 @@ export class PostgresServerStorage {
1040
1104
  }
1041
1105
  // -- admin/console read surface --------------------------------------------
1042
1106
  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]);
1107
+ 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
1108
  return rows.map((r) => ({
1045
1109
  clientId: r.client_id,
1046
1110
  actorId: r.actor_id,
1111
+ wireVersion: asNumber(r.wire_version),
1047
1112
  cursor: asNumber(r.cursor),
1048
1113
  updatedAtMs: asNumber(r.updated_at_ms),
1049
1114
  subscriptions: (typeof r.subscriptions === 'string'
@@ -1114,10 +1179,6 @@ export class PostgresServerStorage {
1114
1179
  };
1115
1180
  }
1116
1181
  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);
1182
+ return (await this.listPartitionRegistry()).map((entry) => entry.partition);
1122
1183
  }
1123
1184
  }
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
@@ -19,7 +19,7 @@ async function resolveImageBuilder(ctx) {
19
19
  if (cachedDefaultBuilder === undefined) {
20
20
  const hasBun = globalThis.Bun !== undefined;
21
21
  cachedDefaultBuilder = hasBun
22
- ? (await import('./sqlite-image.js')).buildSqliteImage
22
+ ? (await import('./sqlite-bun.js')).buildSqliteImage
23
23
  : null;
24
24
  }
25
25
  return cachedDefaultBuilder ?? undefined;
@@ -134,11 +134,12 @@ function segmentRefFrame(record, extra) {
134
134
  * bootstrap-storm rule. Returns false when the table is not eligible
135
135
  * (the rows lane takes over).
136
136
  */
137
- async function* sqliteImageSegment(ctx, schema, limits, plan, asOf, digest, trace) {
137
+ async function* sqliteImageSegment(ctx, schema, limits, plan, asOf, digest, trace, logEpoch) {
138
138
  const { storage, segments, partition } = ctx;
139
139
  const now = clockOf(ctx)();
140
140
  const existing = await segments.find({
141
141
  partition,
142
+ logEpoch,
142
143
  table: plan.table.name,
143
144
  schemaVersion: schema.version,
144
145
  mediaType: 'sqlite',
@@ -204,6 +205,7 @@ async function* sqliteImageSegment(ctx, schema, limits, plan, asOf, digest, trac
204
205
  });
205
206
  const record = await segments.put({
206
207
  partition,
208
+ logEpoch,
207
209
  table: plan.table.name,
208
210
  schemaVersion: schema.version,
209
211
  mediaType: 'sqlite',
@@ -223,7 +225,7 @@ async function* sqliteImageSegment(ctx, schema, limits, plan, asOf, digest, trac
223
225
  yield segmentRefFrame(record, await signedUrlFields(ctx, limits, record.segmentId, digest, now));
224
226
  return true;
225
227
  }
226
- async function* bootstrapSegments(ctx, schema, limits, plan, asOf, startRowCursor, trace) {
228
+ async function* bootstrapSegments(ctx, schema, limits, plan, asOf, startRowCursor, trace, logEpoch) {
227
229
  const { storage, segments, partition } = ctx;
228
230
  const serverLimits = limitsOf(ctx);
229
231
  const digest = await scopeDigest(plan.effective);
@@ -237,7 +239,7 @@ async function* bootstrapSegments(ctx, schema, limits, plan, asOf, startRowCurso
237
239
  if ((limits.accept & ACCEPT_SQLITE) !== 0 &&
238
240
  startRowCursor === null &&
239
241
  plan.table.encryptedColumnIndices.length === 0) {
240
- const imaged = yield* sqliteImageSegment(ctx, schema, limits, plan, asOf, digest, trace);
242
+ const imaged = yield* sqliteImageSegment(ctx, schema, limits, plan, asOf, digest, trace, logEpoch);
241
243
  if (imaged)
242
244
  return { complete: true, rowCursor: null };
243
245
  }
@@ -281,6 +283,7 @@ async function* bootstrapSegments(ctx, schema, limits, plan, asOf, startRowCurso
281
283
  else {
282
284
  const record = await segments.put({
283
285
  partition,
286
+ logEpoch,
284
287
  table: plan.table.name,
285
288
  schemaVersion: schema.version,
286
289
  mediaType: 'rows',
@@ -309,8 +312,11 @@ async function* bootstrapSegments(ctx, schema, limits, plan, asOf, startRowCurso
309
312
  * Produce the `SUB_START … SUB_END` section for one subscription (§1.6),
310
313
  * returning the cursor recorded for the retention watermark (§4.5).
311
314
  */
312
- export async function* subscriptionSection(ctx, schema, limits, plan, maxSeq, horizonSeq, trace) {
315
+ export async function* subscriptionSection(ctx, schema, limits, plan, maxSeq, horizonSeq, trace, logEpoch) {
313
316
  const sub = plan.frame;
317
+ if (logEpoch === undefined || logEpoch.length === 0) {
318
+ throw new Error('subscriptionSection requires a non-empty log epoch');
319
+ }
314
320
  if (plan.status === 'revoked') {
315
321
  yield {
316
322
  type: 'SUB_START',
@@ -355,7 +361,7 @@ export async function* subscriptionSection(ctx, schema, limits, plan, maxSeq, ho
355
361
  effectiveScopes: plan.effective,
356
362
  bootstrap: true,
357
363
  };
358
- const outcome = yield* bootstrapSegments(ctx, schema, limits, plan, asOf, startCursor, trace);
364
+ const outcome = yield* bootstrapSegments(ctx, schema, limits, plan, asOf, startCursor, trace, logEpoch);
359
365
  if (outcome.complete) {
360
366
  yield { type: 'SUB_END', nextCursor: asOf };
361
367
  }
@@ -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;
package/dist/realtime.js CHANGED
@@ -14,7 +14,7 @@
14
14
  * the `sync` wake-up (§8.3).
15
15
  */
16
16
  import { DecodeError, decodeMessage, encodeMessage, encodePresenceError, encodePresenceFanout, MessageStreamScanner, PROTOCOL_WIRE_VERSION, parseRealtimePresencePublish, REALTIME_TAG_DELTA, REALTIME_TAG_ROUND, } from '@syncular/core';
17
- import { REMOTE_COMMAND_CLIENT_ID_PREFIX, RESOLVER_OUTAGE } from './context.js';
17
+ import { REMOTE_COMMAND_CLIENT_ID_PREFIX, RESOLVER_OUTAGE, touchAuthenticatedPartition, } from './context.js';
18
18
  import { SyncError, syncError } from './errors.js';
19
19
  import { emitEvent } from './events.js';
20
20
  import { createSyncResponseStream } from './handler.js';
@@ -120,6 +120,9 @@ export class RealtimeSession {
120
120
  partition;
121
121
  actorId;
122
122
  clientId;
123
+ logEpoch;
124
+ /** Response/delta layout selected by the most recent socket round. */
125
+ wireVersion;
123
126
  /** Highest contiguously applied commitSeq acknowledged by the client. */
124
127
  cursor;
125
128
  /** Suppress deltas until the client catches up via pull + ack (§8.2). */
@@ -144,11 +147,13 @@ export class RealtimeSession {
144
147
  /** §8.6.4 rate cap: per-scope-key last-fanout time + a pending latest
145
148
  * document coalesced while over the cap. */
146
149
  #presenceRate = new Map();
147
- constructor(hub, options, registrations, cursor, latestSeq, clock, maxDeltaBytes, storage, events) {
150
+ constructor(hub, options, registrations, cursor, latestSeq, clock, maxDeltaBytes, storage, logEpoch, wireVersion, events) {
148
151
  this.sessionId = crypto.randomUUID();
149
152
  this.partition = options.partition;
150
153
  this.actorId = options.actorId;
151
154
  this.clientId = options.clientId;
155
+ this.logEpoch = logEpoch;
156
+ this.wireVersion = wireVersion;
152
157
  this.cursor = cursor;
153
158
  this.lastKnownSeq = latestSeq;
154
159
  this.wakePending = cursor < latestSeq;
@@ -376,6 +381,10 @@ export class RealtimeSession {
376
381
  this.#activeRound = undefined;
377
382
  };
378
383
  try {
384
+ const requestedWireVersion = (requestBytes[4] ?? 0) | ((requestBytes[5] ?? 0) << 8);
385
+ if (requestedWireVersion === 1 || requestedWireVersion === 2) {
386
+ this.wireVersion = requestedWireVersion;
387
+ }
379
388
  let stream;
380
389
  try {
381
390
  // §8.7: the round's clientId must match the connection's —
@@ -398,7 +407,7 @@ export class RealtimeSession {
398
407
  ? error
399
408
  : syncError(error.code, error.message);
400
409
  finishRound(); // END is in this one chunk
401
- await this.#sendRoundChunk(errorResponseBytes(sync));
410
+ await this.#sendRoundChunk(errorResponseBytes(sync, this.wireVersion, this.logEpoch));
402
411
  return;
403
412
  }
404
413
  throw error;
@@ -550,7 +559,14 @@ export class RealtimeSession {
550
559
  this.sendWake('catchup-required');
551
560
  return;
552
561
  }
553
- const frames = [{ type: 'RESP_HEADER' }];
562
+ const frames = [
563
+ {
564
+ type: 'RESP_HEADER',
565
+ ...(this.wireVersion >= 2
566
+ ? { logEpoch: this.logEpoch, resetRequired: false }
567
+ : {}),
568
+ },
569
+ ];
554
570
  for (const section of sections) {
555
571
  frames.push({
556
572
  type: 'SUB_START',
@@ -571,7 +587,7 @@ export class RealtimeSession {
571
587
  frames.push({ type: 'SUB_END', nextCursor: commit.commitSeq });
572
588
  }
573
589
  const bytes = encodeMessage({
574
- wireVersion: PROTOCOL_WIRE_VERSION,
590
+ wireVersion: this.wireVersion,
575
591
  msgKind: 'response',
576
592
  frames,
577
593
  });
@@ -770,6 +786,11 @@ export class RealtimeHub {
770
786
  async connect(options) {
771
787
  const { storage } = this.#config;
772
788
  const clock = this.#config.clock ?? Date.now;
789
+ const registry = await touchAuthenticatedPartition({
790
+ ...this.#config,
791
+ partition: options.partition,
792
+ actorId: options.actorId,
793
+ });
773
794
  if (options.clientId.startsWith(REMOTE_COMMAND_CLIENT_ID_PREFIX)) {
774
795
  throw syncError('sync.invalid_client_id', 'clientId uses a reserved server-command namespace (§1.5)');
775
796
  }
@@ -780,7 +801,7 @@ export class RealtimeHub {
780
801
  const registrations = await this.loadRegistrations(options.partition, options.actorId, options.clientId);
781
802
  const latestSeq = await storage.getMaxCommitSeq(options.partition);
782
803
  const cursor = record?.cursor ?? -1;
783
- const session = new RealtimeSession(this, options, registrations, cursor, latestSeq, clock, this.#config.maxDeltaBytes ?? DEFAULT_MAX_DELTA_BYTES, storage, this.#config.events);
804
+ const session = new RealtimeSession(this, options, registrations, cursor, latestSeq, clock, this.#config.maxDeltaBytes ?? DEFAULT_MAX_DELTA_BYTES, storage, registry.logEpoch, record?.wireVersion ?? PROTOCOL_WIRE_VERSION, this.#config.events);
784
805
  this.#sessions.add(session);
785
806
  const helloResult = options.send(JSON.stringify({
786
807
  event: 'hello',
@@ -860,12 +881,15 @@ export function createRealtimeHub(config) {
860
881
  /** §8.7 failures: the socket has no HTTP status surface, so a
861
882
  * request-level failure becomes a minimal RESP_HEADER/ERROR/END
862
883
  * response message delivered as the round's response stream. */
863
- function errorResponseBytes(error) {
884
+ function errorResponseBytes(error, wireVersion, logEpoch) {
864
885
  return encodeMessage({
865
- wireVersion: PROTOCOL_WIRE_VERSION,
886
+ wireVersion,
866
887
  msgKind: 'response',
867
888
  frames: [
868
- { type: 'RESP_HEADER' },
889
+ {
890
+ type: 'RESP_HEADER',
891
+ ...(wireVersion >= 2 ? { logEpoch, resetRequired: false } : {}),
892
+ },
869
893
  {
870
894
  type: 'ERROR',
871
895
  code: error.code,
@@ -0,0 +1,13 @@
1
+ import type { PartitionRegistryEntry, ServerStorage } from './storage.js';
2
+ export interface RotatePartitionLogEpochOptions {
3
+ readonly storage: ServerStorage;
4
+ readonly partition: string;
5
+ readonly nowMs?: number;
6
+ /** Deterministic operator/test override. Defaults to a random UUID. */
7
+ readonly logEpoch?: string;
8
+ }
9
+ /**
10
+ * Fence clients from a prior authoritative database timeline after restore.
11
+ * Call while traffic and realtime delivery remain stopped (§2.1).
12
+ */
13
+ export declare function rotatePartitionLogEpoch(options: RotatePartitionLogEpochOptions): Promise<PartitionRegistryEntry>;
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Fence clients from a prior authoritative database timeline after restore.
3
+ * Call while traffic and realtime delivery remain stopped (§2.1).
4
+ */
5
+ export function rotatePartitionLogEpoch(options) {
6
+ if (options.partition.length === 0) {
7
+ throw new TypeError('partition must be non-empty');
8
+ }
9
+ const logEpoch = options.logEpoch ?? crypto.randomUUID();
10
+ if (logEpoch.length === 0)
11
+ throw new TypeError('logEpoch must be non-empty');
12
+ return options.storage.rotatePartitionLogEpoch(options.partition, logEpoch, options.nowMs ?? Date.now());
13
+ }
@@ -70,7 +70,13 @@ function parseRecordJson(json, source) {
70
70
  throw new Error(`S3SegmentStore: corrupt record in ${source}`);
71
71
  }
72
72
  const r = parsed;
73
- const strings = ['segmentId', 'partition', 'table', 'scopeDigest'];
73
+ const strings = [
74
+ 'segmentId',
75
+ 'partition',
76
+ 'logEpoch',
77
+ 'table',
78
+ 'scopeDigest',
79
+ ];
74
80
  const numbers = [
75
81
  'schemaVersion',
76
82
  'asOfCommitSeq',
@@ -99,6 +105,7 @@ function parseRecordJson(json, source) {
99
105
  return {
100
106
  segmentId: r.segmentId,
101
107
  partition: r.partition,
108
+ logEpoch: r.logEpoch,
102
109
  table: r.table,
103
110
  schemaVersion: r.schemaVersion,
104
111
  mediaType: r.mediaType,
@@ -136,6 +143,7 @@ export class S3SegmentStore {
136
143
  async #findKeyFor(key) {
137
144
  const canonical = JSON.stringify([
138
145
  key.partition,
146
+ key.logEpoch,
139
147
  key.table,
140
148
  key.schemaVersion,
141
149
  key.mediaType,
@@ -304,6 +312,7 @@ export class S3SegmentStore {
304
312
  return undefined;
305
313
  const record = parseRecordJson(await response.text(), 'reuse pointer');
306
314
  if (record.partition !== key.partition ||
315
+ record.logEpoch !== key.logEpoch ||
307
316
  record.table !== key.table ||
308
317
  record.schemaVersion !== key.schemaVersion ||
309
318
  record.mediaType !== key.mediaType ||
package/dist/seed.js CHANGED
@@ -105,8 +105,42 @@ export async function seedMutations(config, target, mutations) {
105
105
  payload: encodeRow(table.columns, values),
106
106
  };
107
107
  });
108
+ const requestContext = {
109
+ ...config,
110
+ partition: target.partition,
111
+ actorId: target.actorId,
112
+ };
113
+ const acquisition = decodeMessage(await handleSyncRequest(encodeMessage({
114
+ wireVersion: PROTOCOL_WIRE_VERSION,
115
+ msgKind: 'request',
116
+ frames: [
117
+ {
118
+ type: 'REQ_HEADER',
119
+ clientId,
120
+ schemaVersion: config.schema.version,
121
+ },
122
+ {
123
+ type: 'PULL_HEADER',
124
+ limitCommits: 0,
125
+ limitSnapshotRows: 0,
126
+ maxSnapshotPages: 0,
127
+ accept: 0b0011,
128
+ },
129
+ ],
130
+ }), requestContext));
131
+ const acquisitionHeader = acquisition.frames[0];
132
+ if (acquisition.msgKind !== 'response' ||
133
+ acquisitionHeader?.type !== 'RESP_HEADER' ||
134
+ acquisitionHeader.logEpoch === undefined) {
135
+ throw new SyncError('sync.invalid_request', 'seedMutations: epoch acquisition returned an invalid response');
136
+ }
108
137
  const frames = [
109
- { type: 'REQ_HEADER', clientId, schemaVersion: config.schema.version },
138
+ {
139
+ type: 'REQ_HEADER',
140
+ clientId,
141
+ schemaVersion: config.schema.version,
142
+ logEpoch: acquisitionHeader.logEpoch,
143
+ },
110
144
  { type: 'PUSH_COMMIT', clientCommitId, operations },
111
145
  // A pull that asks for nothing: the round exists for its push half.
112
146
  {
@@ -132,9 +166,7 @@ export async function seedMutations(config, target, mutations) {
132
166
  msgKind: 'request',
133
167
  frames,
134
168
  }), {
135
- ...config,
136
- partition: target.partition,
137
- actorId: target.actorId,
169
+ ...requestContext,
138
170
  events: config.events === undefined
139
171
  ? capture
140
172
  : composeEvents(config.events, capture),
@@ -1,4 +1,4 @@
1
- import { clockOf, RESOLVER_OUTAGE } from './context.js';
1
+ import { clockOf, RESOLVER_OUTAGE, touchAuthenticatedPartition, } from './context.js';
2
2
  import { SyncError, syncError } from './errors.js';
3
3
  import { emitEvent } from './events.js';
4
4
  import { compileSchema } from './schema.js';
@@ -60,8 +60,11 @@ export async function handleSegmentDownload(ctx, request) {
60
60
  }
61
61
  }
62
62
  async function downloadSegment(ctx, request) {
63
+ const registry = await touchAuthenticatedPartition(ctx);
63
64
  const entry = await ctx.segments.get(request.segmentId);
64
- if (entry === undefined || entry.record.partition !== ctx.partition) {
65
+ if (entry === undefined ||
66
+ entry.record.partition !== ctx.partition ||
67
+ entry.record.logEpoch !== registry.logEpoch) {
65
68
  throw syncError('sync.not_found', 'unknown segment (§5.5)');
66
69
  }
67
70
  if (entry.record.expiresAtMs <= clockOf(ctx)()) {
@@ -1,6 +1,8 @@
1
1
  export declare const DEFAULT_SEGMENT_TTL_MS: number;
2
2
  export interface SegmentMetadata {
3
3
  readonly partition: string;
4
+ /** Partition log continuity that produced these bytes (§2.1). */
5
+ readonly logEpoch: string;
4
6
  readonly table: string;
5
7
  readonly schemaVersion: number;
6
8
  readonly mediaType: 'rows' | 'sqlite';
@@ -23,6 +25,7 @@ export interface SegmentRecord extends SegmentMetadata {
23
25
  */
24
26
  export interface SegmentFindKey {
25
27
  readonly partition: string;
28
+ readonly logEpoch: string;
26
29
  readonly table: string;
27
30
  readonly schemaVersion: number;
28
31
  readonly mediaType: 'rows' | 'sqlite';
@@ -35,6 +35,7 @@ export class MemorySegmentStore {
35
35
  async find(key, nowMs) {
36
36
  for (const { record } of this.#entries.values()) {
37
37
  if (record.partition === key.partition &&
38
+ record.logEpoch === key.logEpoch &&
38
39
  record.table === key.table &&
39
40
  record.schemaVersion === key.schemaVersion &&
40
41
  record.mediaType === key.mediaType &&