@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
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 &&
@@ -2,7 +2,8 @@ import { Database } from 'bun:sqlite';
2
2
  import type { SqliteDatabase, SqliteRunResult, SqliteStatement, SqliteValue } from './sqlite-driver.js';
3
3
  export declare class BunSqliteDatabase implements SqliteDatabase {
4
4
  readonly native: Database;
5
- constructor(path?: string);
5
+ constructor(value?: string | Database);
6
+ static deserialize(bytes: Uint8Array): BunSqliteDatabase;
6
7
  exec(sql: string): void;
7
8
  run(sql: string, bindings?: readonly SqliteValue[]): SqliteRunResult;
8
9
  query<Row, Params extends readonly SqliteValue[]>(sql: string): SqliteStatement<Row, Params>;
@@ -1,8 +1,11 @@
1
1
  import { Database } from 'bun:sqlite';
2
2
  export class BunSqliteDatabase {
3
3
  native;
4
- constructor(path = ':memory:') {
5
- this.native = new Database(path);
4
+ constructor(value = ':memory:') {
5
+ this.native = typeof value === 'string' ? new Database(value) : value;
6
+ }
7
+ static deserialize(bytes) {
8
+ return new BunSqliteDatabase(Database.deserialize(bytes));
6
9
  }
7
10
  exec(sql) {
8
11
  this.native.exec(sql);
@@ -27,10 +27,10 @@ export class SqliteLeaseStore extends SharedSqliteLeaseStore {
27
27
  super(database(value), options);
28
28
  }
29
29
  }
30
- export const buildSqliteImage = (input) => {
30
+ export const buildSqliteImage = async (input) => {
31
31
  const db = new BunSqliteDatabase();
32
32
  try {
33
- writeSqliteImage(db, input);
33
+ await writeSqliteImage(db, input);
34
34
  return db.serialize();
35
35
  }
36
36
  finally {
@@ -30,7 +30,7 @@ import type { StoredChange, StoredCommit, StoredPushResult, StoredRow } from './
30
30
  * the Postgres storage documents (§3.1, performance-by-
31
31
  * construction).
32
32
  */
33
- export declare const SQLITE_DDL = "\nCREATE TABLE IF NOT EXISTS sync_partitions(\n partition TEXT PRIMARY KEY,\n max_commit_seq INTEGER NOT NULL DEFAULT 0,\n horizon_seq INTEGER 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 INTEGER NOT NULL,\n client_id TEXT NOT NULL, client_commit_id TEXT NOT NULL,\n actor_id TEXT NOT NULL, created_at_ms INTEGER 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 INTEGER NOT NULL, idx INTEGER NOT NULL,\n tbl TEXT NOT NULL, row_id TEXT NOT NULL, op INTEGER NOT NULL,\n row_version INTEGER, scopes TEXT NOT NULL, payload BLOB,\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 INTEGER 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 TEXT 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 TEXT NOT NULL,\n source_client_id TEXT NOT NULL, source_client_commit_id TEXT NOT NULL,\n source_commit_seq INTEGER NOT NULL, created_at_ms INTEGER NOT NULL,\n available_at_ms INTEGER NOT NULL, status TEXT NOT NULL,\n attempts INTEGER NOT NULL, max_attempts INTEGER NOT NULL,\n lease_owner TEXT, lease_expires_at_ms INTEGER, completed_at_ms INTEGER,\n last_failure TEXT,\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 INTEGER NOT NULL, subscriptions TEXT NOT NULL,\n updated_at_ms INTEGER 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";
33
+ export declare const SQLITE_DDL = "\nCREATE TABLE IF NOT EXISTS sync_partitions(\n partition TEXT PRIMARY KEY,\n max_commit_seq INTEGER NOT NULL DEFAULT 0,\n horizon_seq INTEGER 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 INTEGER NOT NULL DEFAULT 0,\n last_authenticated_at_ms INTEGER 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 INTEGER NOT NULL,\n client_id TEXT NOT NULL, client_commit_id TEXT NOT NULL,\n actor_id TEXT NOT NULL, created_at_ms INTEGER 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 INTEGER NOT NULL, idx INTEGER NOT NULL,\n tbl TEXT NOT NULL, row_id TEXT NOT NULL, op INTEGER NOT NULL,\n row_version INTEGER, scopes TEXT NOT NULL, payload BLOB,\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 INTEGER 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 TEXT 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 TEXT NOT NULL,\n source_client_id TEXT NOT NULL, source_client_commit_id TEXT NOT NULL,\n source_commit_seq INTEGER NOT NULL, created_at_ms INTEGER NOT NULL,\n available_at_ms INTEGER NOT NULL, status TEXT NOT NULL,\n attempts INTEGER NOT NULL, max_attempts INTEGER NOT NULL,\n lease_owner TEXT, lease_expires_at_ms INTEGER, completed_at_ms INTEGER,\n last_failure TEXT,\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 INTEGER NOT NULL, subscriptions TEXT NOT NULL,\n updated_at_ms INTEGER 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";
34
34
  /** Split the DDL into individual statements (D1 applies them one by one). */
35
35
  export declare function sqliteDdlStatements(): string[];
36
36
  /** `?,?,…` for an `IN (…)` clause of `count` positional parameters. */
@@ -18,6 +18,12 @@ CREATE TABLE IF NOT EXISTS sync_partitions(
18
18
  max_commit_seq INTEGER NOT NULL DEFAULT 0,
19
19
  horizon_seq INTEGER NOT NULL DEFAULT 0
20
20
  );
21
+ CREATE TABLE IF NOT EXISTS sync_partition_registry(
22
+ partition TEXT PRIMARY KEY,
23
+ log_epoch TEXT NOT NULL,
24
+ epoch_required INTEGER NOT NULL DEFAULT 0,
25
+ last_authenticated_at_ms INTEGER NOT NULL
26
+ );
21
27
  CREATE TABLE IF NOT EXISTS sync_row_scopes(
22
28
  partition TEXT NOT NULL, tbl TEXT NOT NULL,
23
29
  var TEXT NOT NULL, value TEXT NOT NULL, row_id TEXT NOT NULL,
@@ -71,6 +77,7 @@ CREATE INDEX IF NOT EXISTS sync_reactions_dead_letter
71
77
  ON sync_reactions(partition, status, available_at_ms, idempotency_key);
72
78
  CREATE TABLE IF NOT EXISTS sync_clients(
73
79
  partition TEXT NOT NULL, client_id TEXT NOT NULL, actor_id TEXT NOT NULL,
80
+ wire_version INTEGER NOT NULL DEFAULT 1,
74
81
  cursor INTEGER NOT NULL, subscriptions TEXT NOT NULL,
75
82
  updated_at_ms INTEGER NOT NULL,
76
83
  PRIMARY KEY(partition, client_id)
@@ -10,7 +10,7 @@ export interface SqliteImageInput {
10
10
  readonly schemaVersion: number;
11
11
  readonly asOfCommitSeq: number;
12
12
  readonly scopeDigest: string;
13
- readonly rows: readonly StoredRow[];
13
+ readonly rowBatches: AsyncIterable<readonly StoredRow[]> | Iterable<readonly StoredRow[]>;
14
14
  }
15
15
  /**
16
16
  * The §5.3 image-builder capability, injected through
@@ -20,6 +20,6 @@ export interface SqliteImageInput {
20
20
  * on the pull path. A Bun or Node host passes `buildSqliteImage`; a Workers
21
21
  * host omits it and serves the rows lane.
22
22
  */
23
- export type SqliteImageBuilder = (input: SqliteImageInput) => Uint8Array;
23
+ export type SqliteImageBuilder = (input: SqliteImageInput) => Promise<Uint8Array>;
24
24
  /** Populate a §5.3 image database for a whole-table snapshot. */
25
- export declare function writeSqliteImage(db: SqliteDatabase, input: SqliteImageInput): void;
25
+ export declare function writeSqliteImage(db: SqliteDatabase, input: SqliteImageInput): Promise<void>;
@@ -45,8 +45,8 @@ function toSql(value) {
45
45
  return value;
46
46
  }
47
47
  /** Populate a §5.3 image database for a whole-table snapshot. */
48
- export function writeSqliteImage(db, input) {
49
- const { table, rows } = input;
48
+ export async function writeSqliteImage(db, input) {
49
+ const { table, rowBatches } = input;
50
50
  const primaryKey = table.columns[table.primaryKeyIndex]?.name;
51
51
  const columnDefs = table.columns.map((column) => {
52
52
  const notNull = column.nullable ? '' : ' NOT NULL';
@@ -59,7 +59,6 @@ export function writeSqliteImage(db, input) {
59
59
  format INTEGER NOT NULL, "table" TEXT NOT NULL,
60
60
  "schemaVersion" INTEGER NOT NULL, "asOfCommitSeq" INTEGER NOT NULL,
61
61
  "scopeDigest" TEXT NOT NULL, "rowCount" INTEGER NOT NULL)`);
62
- db.query(`INSERT INTO ${IMAGE_METADATA_TABLE} VALUES (1, ?, ?, ?, ?, ?)`).run(table.name, input.schemaVersion, input.asOfCommitSeq, input.scopeDigest, rows.length);
63
62
  const names = [
64
63
  ...table.columns.map((column) => quoteIdent(column.name)),
65
64
  quoteIdent(IMAGE_VERSION_COLUMN),
@@ -68,10 +67,15 @@ export function writeSqliteImage(db, input) {
68
67
  VALUES (${names.map(() => '?').join(', ')})`);
69
68
  db.exec('BEGIN');
70
69
  try {
71
- for (const row of rows) {
72
- const values = decodeRow(table.columns, row.payload);
73
- insert.run(...values.map(toSql), row.serverVersion);
70
+ let rowCount = 0;
71
+ for await (const rows of rowBatches) {
72
+ for (const row of rows) {
73
+ const values = decodeRow(table.columns, row.payload);
74
+ insert.run(...values.map(toSql), row.serverVersion);
75
+ }
76
+ rowCount += rows.length;
74
77
  }
78
+ db.query(`INSERT INTO ${IMAGE_METADATA_TABLE} VALUES (1, ?, ?, ?, ?, ?)`).run(table.name, input.schemaVersion, input.asOfCommitSeq, input.scopeDigest, rowCount);
75
79
  db.exec('COMMIT');
76
80
  }
77
81
  catch (error) {
@@ -30,13 +30,13 @@ export class SqliteLeaseStore extends SharedSqliteLeaseStore {
30
30
  super(database(value), options);
31
31
  }
32
32
  }
33
- export const buildSqliteImage = (input) => {
33
+ export const buildSqliteImage = async (input) => {
34
34
  const directory = mkdtempSync(join(tmpdir(), 'syncular-server-image-'));
35
35
  const path = join(directory, 'segment.db');
36
36
  const db = new NodeSqliteDatabase(path);
37
37
  try {
38
38
  try {
39
- writeSqliteImage(db, input);
39
+ await writeSqliteImage(db, input);
40
40
  }
41
41
  finally {
42
42
  db.close();
@@ -15,6 +15,7 @@ export class SqliteSegmentStore {
15
15
  this.db.exec(`
16
16
  CREATE TABLE IF NOT EXISTS sync_segments(
17
17
  segment_id TEXT PRIMARY KEY, partition TEXT NOT NULL,
18
+ log_epoch TEXT NOT NULL,
18
19
  tbl TEXT NOT NULL, schema_version INTEGER NOT NULL,
19
20
  media_type TEXT NOT NULL, scope_digest TEXT NOT NULL,
20
21
  as_of_commit_seq INTEGER NOT NULL, row_count INTEGER NOT NULL,
@@ -23,6 +24,12 @@ export class SqliteSegmentStore {
23
24
  expires_at_ms INTEGER NOT NULL, bytes BLOB NOT NULL
24
25
  );
25
26
  `);
27
+ const columns = this.db
28
+ .query('PRAGMA table_info(sync_segments)')
29
+ .all();
30
+ if (!columns.some((column) => column.name === 'log_epoch')) {
31
+ this.db.exec("ALTER TABLE sync_segments ADD COLUMN log_epoch TEXT NOT NULL DEFAULT ''");
32
+ }
26
33
  }
27
34
  async put(metadata, bytes, nowMs) {
28
35
  const segmentId = await segmentIdFor(bytes);
@@ -35,11 +42,11 @@ export class SqliteSegmentStore {
35
42
  };
36
43
  this.db
37
44
  .query(`INSERT OR REPLACE INTO sync_segments(
38
- segment_id, partition, tbl, schema_version, media_type,
45
+ segment_id, partition, log_epoch, tbl, schema_version, media_type,
39
46
  scope_digest, as_of_commit_seq, row_count, row_cursor,
40
47
  next_row_cursor, byte_length, created_at_ms, expires_at_ms, bytes
41
- ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)
42
- .run(record.segmentId, record.partition, record.table, record.schemaVersion, record.mediaType, record.scopeDigest, record.asOfCommitSeq, record.rowCount, record.rowCursor, record.nextRowCursor, record.byteLength, record.createdAtMs, record.expiresAtMs, bytes);
48
+ ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)
49
+ .run(record.segmentId, record.partition, record.logEpoch, record.table, record.schemaVersion, record.mediaType, record.scopeDigest, record.asOfCommitSeq, record.rowCount, record.rowCursor, record.nextRowCursor, record.byteLength, record.createdAtMs, record.expiresAtMs, bytes);
43
50
  return record;
44
51
  }
45
52
  async get(segmentId) {
@@ -52,6 +59,7 @@ export class SqliteSegmentStore {
52
59
  record: {
53
60
  segmentId: row.segment_id,
54
61
  partition: row.partition,
62
+ logEpoch: row.log_epoch,
55
63
  table: row.tbl,
56
64
  schemaVersion: row.schema_version,
57
65
  mediaType: row.media_type === 'sqlite' ? 'sqlite' : 'rows',
@@ -72,16 +80,17 @@ export class SqliteSegmentStore {
72
80
  .query(`SELECT segment_id, row_count, next_row_cursor, byte_length,
73
81
  created_at_ms, expires_at_ms
74
82
  FROM sync_segments
75
- WHERE partition=? AND tbl=? AND schema_version=? AND media_type=?
83
+ WHERE partition=? AND log_epoch=? AND tbl=? AND schema_version=? AND media_type=?
76
84
  AND scope_digest=? AND as_of_commit_seq=? AND row_cursor IS NULL
77
85
  AND expires_at_ms > ?
78
86
  LIMIT 1`)
79
- .get(key.partition, key.table, key.schemaVersion, key.mediaType, key.scopeDigest, key.asOfCommitSeq, nowMs);
87
+ .get(key.partition, key.logEpoch, key.table, key.schemaVersion, key.mediaType, key.scopeDigest, key.asOfCommitSeq, nowMs);
80
88
  if (row === null)
81
89
  return undefined;
82
90
  return {
83
91
  segmentId: row.segment_id,
84
92
  partition: key.partition,
93
+ logEpoch: key.logEpoch,
85
94
  table: key.table,
86
95
  schemaVersion: key.schemaVersion,
87
96
  mediaType: key.mediaType,
@@ -1,6 +1,7 @@
1
+ import type { CommitPruneQuery, CommitPruneResult } from './storage.js';
1
2
  import type { CompiledSchema, CompiledTable } from './schema.js';
2
3
  import { type SqliteDatabase } from './sqlite-driver.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
  export declare class SqliteServerStorage implements ServerStorage {
5
6
  #private;
6
7
  readonly db: SqliteDatabase;
@@ -8,14 +9,18 @@ export declare class SqliteServerStorage implements ServerStorage {
8
9
  /** Resolve a table's compiled schema; row operations require `ensureSchema`. */
9
10
  table(name: string): CompiledTable;
10
11
  ensureSchema(schema: CompiledSchema): Promise<void>;
12
+ touchPartition(partition: string, authenticatedAtMs: number, initialLogEpoch: string): Promise<PartitionRegistryEntry>;
13
+ rotatePartitionLogEpoch(partition: string, logEpoch: string, authenticatedAtMs: number): Promise<PartitionRegistryEntry>;
14
+ listPartitionRegistry(): Promise<PartitionRegistryEntry[]>;
11
15
  begin(partition: string): Promise<StorageTransaction>;
12
16
  /** Internal: write a row + refresh its scope-index entries. */
13
17
  writeRow(partition: string, table: string, row: StoredRow): void;
14
18
  getMaxCommitSeq(partition: string): Promise<number>;
15
19
  queryAuthoritative(partition: string, query: AuthoritativeQueryRequest): Promise<AuthoritativeQueryResult>;
20
+ getPartitionLogEpoch(partition: string): Promise<string | undefined>;
16
21
  getHorizonSeq(partition: string): Promise<number>;
17
22
  setHorizonSeq(partition: string, seq: number): Promise<void>;
18
- pruneCommitsThrough(partition: string, seq: number): Promise<number>;
23
+ pruneCommitsThrough(partition: string, query: CommitPruneQuery): Promise<CommitPruneResult>;
19
24
  getCommitSeqBefore(partition: string, createdBeforeMs: number): Promise<number>;
20
25
  getRow(partition: string, table: string, rowId: string): Promise<StoredRow | undefined>;
21
26
  getPushResult(partition: string, clientId: string, clientCommitId: string): Promise<StoredPushResult | undefined>;
@@ -32,6 +37,7 @@ export declare class SqliteServerStorage implements ServerStorage {
32
37
  scanRowsByIndex(partition: string, query: IndexRowScanQuery): Promise<StoredRow[]>;
33
38
  getClientRecord(partition: string, clientId: string): Promise<ClientRecord | undefined>;
34
39
  putClientRecord(partition: string, record: ClientRecord): Promise<void>;
40
+ getActiveClientCursorFloor(partition: string, cutoffMs: number): Promise<number | null>;
35
41
  listClientCursors(partition: string): Promise<ClientCursorInfo[]>;
36
42
  listRowsReferencingBlob(partition: string, blobId: string): Promise<{
37
43
  readonly table: string;