@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/src/realtime.ts CHANGED
@@ -31,7 +31,11 @@ import {
31
31
  type WakeReason,
32
32
  } from '@syncular/core';
33
33
  import type { SyncRequestContext, SyncServerConfig } from './context';
34
- import { REMOTE_COMMAND_CLIENT_ID_PREFIX, RESOLVER_OUTAGE } from './context';
34
+ import {
35
+ REMOTE_COMMAND_CLIENT_ID_PREFIX,
36
+ RESOLVER_OUTAGE,
37
+ touchAuthenticatedPartition,
38
+ } from './context';
35
39
  import { SyncError, syncError } from './errors';
36
40
  import { emitEvent, type SyncularServerEvents } from './events';
37
41
  import { createSyncResponseStream } from './handler';
@@ -215,6 +219,9 @@ export class RealtimeSession {
215
219
  readonly partition: string;
216
220
  readonly actorId: string;
217
221
  readonly clientId: string;
222
+ readonly logEpoch: string;
223
+ /** Response/delta layout selected by the most recent socket round. */
224
+ wireVersion: number;
218
225
  /** Highest contiguously applied commitSeq acknowledged by the client. */
219
226
  cursor: number;
220
227
  /** Suppress deltas until the client catches up via pull + ack (§8.2). */
@@ -256,12 +263,16 @@ export class RealtimeSession {
256
263
  clock: () => number,
257
264
  maxDeltaBytes: number,
258
265
  storage: ServerStorage,
266
+ logEpoch: string,
267
+ wireVersion: number,
259
268
  events: SyncularServerEvents | undefined,
260
269
  ) {
261
270
  this.sessionId = crypto.randomUUID();
262
271
  this.partition = options.partition;
263
272
  this.actorId = options.actorId;
264
273
  this.clientId = options.clientId;
274
+ this.logEpoch = logEpoch;
275
+ this.wireVersion = wireVersion;
265
276
  this.cursor = cursor;
266
277
  this.lastKnownSeq = latestSeq;
267
278
  this.wakePending = cursor < latestSeq;
@@ -515,6 +526,11 @@ export class RealtimeSession {
515
526
  if (this.#activeRound === token) this.#activeRound = undefined;
516
527
  };
517
528
  try {
529
+ const requestedWireVersion =
530
+ (requestBytes[4] ?? 0) | ((requestBytes[5] ?? 0) << 8);
531
+ if (requestedWireVersion === 1 || requestedWireVersion === 2) {
532
+ this.wireVersion = requestedWireVersion;
533
+ }
518
534
  let stream: AsyncIterable<Uint8Array> | undefined;
519
535
  try {
520
536
  // §8.7: the round's clientId must match the connection's —
@@ -543,7 +559,9 @@ export class RealtimeSession {
543
559
  ? error
544
560
  : syncError(error.code, error.message);
545
561
  finishRound(); // END is in this one chunk
546
- await this.#sendRoundChunk(errorResponseBytes(sync));
562
+ await this.#sendRoundChunk(
563
+ errorResponseBytes(sync, this.wireVersion, this.logEpoch),
564
+ );
547
565
  return;
548
566
  }
549
567
  throw error;
@@ -712,7 +730,14 @@ export class RealtimeSession {
712
730
  this.sendWake('catchup-required');
713
731
  return;
714
732
  }
715
- const frames: ResponseFrame[] = [{ type: 'RESP_HEADER' }];
733
+ const frames: ResponseFrame[] = [
734
+ {
735
+ type: 'RESP_HEADER',
736
+ ...(this.wireVersion >= 2
737
+ ? { logEpoch: this.logEpoch, resetRequired: false }
738
+ : {}),
739
+ },
740
+ ];
716
741
  for (const section of sections) {
717
742
  frames.push({
718
743
  type: 'SUB_START',
@@ -733,7 +758,7 @@ export class RealtimeSession {
733
758
  frames.push({ type: 'SUB_END', nextCursor: commit.commitSeq });
734
759
  }
735
760
  const bytes = encodeMessage({
736
- wireVersion: PROTOCOL_WIRE_VERSION,
761
+ wireVersion: this.wireVersion,
737
762
  msgKind: 'response',
738
763
  frames,
739
764
  });
@@ -963,6 +988,11 @@ export class RealtimeHub {
963
988
  async connect(options: RealtimeConnectOptions): Promise<RealtimeSession> {
964
989
  const { storage } = this.#config;
965
990
  const clock = this.#config.clock ?? Date.now;
991
+ const registry = await touchAuthenticatedPartition({
992
+ ...this.#config,
993
+ partition: options.partition,
994
+ actorId: options.actorId,
995
+ });
966
996
  if (options.clientId.startsWith(REMOTE_COMMAND_CLIENT_ID_PREFIX)) {
967
997
  throw syncError(
968
998
  'sync.invalid_client_id',
@@ -995,6 +1025,8 @@ export class RealtimeHub {
995
1025
  clock,
996
1026
  this.#config.maxDeltaBytes ?? DEFAULT_MAX_DELTA_BYTES,
997
1027
  storage,
1028
+ registry.logEpoch,
1029
+ record?.wireVersion ?? PROTOCOL_WIRE_VERSION,
998
1030
  this.#config.events,
999
1031
  );
1000
1032
  this.#sessions.add(session);
@@ -1079,12 +1111,19 @@ export function createRealtimeHub(config: RealtimeHubConfig): RealtimeHub {
1079
1111
  /** §8.7 failures: the socket has no HTTP status surface, so a
1080
1112
  * request-level failure becomes a minimal RESP_HEADER/ERROR/END
1081
1113
  * response message delivered as the round's response stream. */
1082
- function errorResponseBytes(error: SyncError): Uint8Array {
1114
+ function errorResponseBytes(
1115
+ error: SyncError,
1116
+ wireVersion: number,
1117
+ logEpoch: string,
1118
+ ): Uint8Array {
1083
1119
  return encodeMessage({
1084
- wireVersion: PROTOCOL_WIRE_VERSION,
1120
+ wireVersion,
1085
1121
  msgKind: 'response',
1086
1122
  frames: [
1087
- { type: 'RESP_HEADER' },
1123
+ {
1124
+ type: 'RESP_HEADER',
1125
+ ...(wireVersion >= 2 ? { logEpoch, resetRequired: false } : {}),
1126
+ },
1088
1127
  {
1089
1128
  type: 'ERROR',
1090
1129
  code: error.code,
package/src/restore.ts ADDED
@@ -0,0 +1,28 @@
1
+ import type { PartitionRegistryEntry, ServerStorage } from './storage';
2
+
3
+ export interface RotatePartitionLogEpochOptions {
4
+ readonly storage: ServerStorage;
5
+ readonly partition: string;
6
+ readonly nowMs?: number;
7
+ /** Deterministic operator/test override. Defaults to a random UUID. */
8
+ readonly logEpoch?: string;
9
+ }
10
+
11
+ /**
12
+ * Fence clients from a prior authoritative database timeline after restore.
13
+ * Call while traffic and realtime delivery remain stopped (§2.1).
14
+ */
15
+ export function rotatePartitionLogEpoch(
16
+ options: RotatePartitionLogEpochOptions,
17
+ ): Promise<PartitionRegistryEntry> {
18
+ if (options.partition.length === 0) {
19
+ throw new TypeError('partition must be non-empty');
20
+ }
21
+ const logEpoch = options.logEpoch ?? crypto.randomUUID();
22
+ if (logEpoch.length === 0) throw new TypeError('logEpoch must be non-empty');
23
+ return options.storage.rotatePartitionLogEpoch(
24
+ options.partition,
25
+ logEpoch,
26
+ options.nowMs ?? Date.now(),
27
+ );
28
+ }
@@ -152,7 +152,13 @@ function parseRecordJson(json: string, source: string): SegmentRecord {
152
152
  throw new Error(`S3SegmentStore: corrupt record in ${source}`);
153
153
  }
154
154
  const r = parsed as Record<string, unknown>;
155
- const strings = ['segmentId', 'partition', 'table', 'scopeDigest'] as const;
155
+ const strings = [
156
+ 'segmentId',
157
+ 'partition',
158
+ 'logEpoch',
159
+ 'table',
160
+ 'scopeDigest',
161
+ ] as const;
156
162
  const numbers = [
157
163
  'schemaVersion',
158
164
  'asOfCommitSeq',
@@ -182,6 +188,7 @@ function parseRecordJson(json: string, source: string): SegmentRecord {
182
188
  return {
183
189
  segmentId: r.segmentId as string,
184
190
  partition: r.partition as string,
191
+ logEpoch: r.logEpoch as string,
185
192
  table: r.table as string,
186
193
  schemaVersion: r.schemaVersion as number,
187
194
  mediaType: r.mediaType,
@@ -223,6 +230,7 @@ export class S3SegmentStore implements SegmentStore {
223
230
  async #findKeyFor(key: SegmentFindKey): Promise<string> {
224
231
  const canonical = JSON.stringify([
225
232
  key.partition,
233
+ key.logEpoch,
226
234
  key.table,
227
235
  key.schemaVersion,
228
236
  key.mediaType,
@@ -431,6 +439,7 @@ export class S3SegmentStore implements SegmentStore {
431
439
  const record = parseRecordJson(await response.text(), 'reuse pointer');
432
440
  if (
433
441
  record.partition !== key.partition ||
442
+ record.logEpoch !== key.logEpoch ||
434
443
  record.table !== key.table ||
435
444
  record.schemaVersion !== key.schemaVersion ||
436
445
  record.mediaType !== key.mediaType ||
package/src/seed.ts CHANGED
@@ -186,8 +186,52 @@ export async function seedMutations(
186
186
  };
187
187
  });
188
188
 
189
+ const requestContext = {
190
+ ...config,
191
+ partition: target.partition,
192
+ actorId: target.actorId,
193
+ };
194
+ const acquisition = decodeMessage(
195
+ await handleSyncRequest(
196
+ encodeMessage({
197
+ wireVersion: PROTOCOL_WIRE_VERSION,
198
+ msgKind: 'request',
199
+ frames: [
200
+ {
201
+ type: 'REQ_HEADER',
202
+ clientId,
203
+ schemaVersion: config.schema.version,
204
+ },
205
+ {
206
+ type: 'PULL_HEADER',
207
+ limitCommits: 0,
208
+ limitSnapshotRows: 0,
209
+ maxSnapshotPages: 0,
210
+ accept: 0b0011,
211
+ },
212
+ ],
213
+ }),
214
+ requestContext,
215
+ ),
216
+ );
217
+ const acquisitionHeader = acquisition.frames[0];
218
+ if (
219
+ acquisition.msgKind !== 'response' ||
220
+ acquisitionHeader?.type !== 'RESP_HEADER' ||
221
+ acquisitionHeader.logEpoch === undefined
222
+ ) {
223
+ throw new SyncError(
224
+ 'sync.invalid_request',
225
+ 'seedMutations: epoch acquisition returned an invalid response',
226
+ );
227
+ }
189
228
  const frames: RequestFrame[] = [
190
- { type: 'REQ_HEADER', clientId, schemaVersion: config.schema.version },
229
+ {
230
+ type: 'REQ_HEADER',
231
+ clientId,
232
+ schemaVersion: config.schema.version,
233
+ logEpoch: acquisitionHeader.logEpoch,
234
+ },
191
235
  { type: 'PUSH_COMMIT', clientCommitId, operations },
192
236
  // A pull that asks for nothing: the round exists for its push half.
193
237
  {
@@ -221,9 +265,7 @@ export async function seedMutations(
221
265
  frames,
222
266
  }),
223
267
  {
224
- ...config,
225
- partition: target.partition,
226
- actorId: target.actorId,
268
+ ...requestContext,
227
269
  events:
228
270
  config.events === undefined
229
271
  ? capture
@@ -9,7 +9,11 @@
9
9
  */
10
10
  import type { ScopeMap } from '@syncular/core';
11
11
  import type { SyncRequestContext } from './context';
12
- import { clockOf, RESOLVER_OUTAGE } from './context';
12
+ import {
13
+ clockOf,
14
+ RESOLVER_OUTAGE,
15
+ touchAuthenticatedPartition,
16
+ } from './context';
13
17
  import { SyncError, syncError } from './errors';
14
18
  import { emitEvent } from './events';
15
19
  import { compileSchema } from './schema';
@@ -96,8 +100,13 @@ async function downloadSegment(
96
100
  ctx: SyncRequestContext,
97
101
  request: SegmentDownloadRequest,
98
102
  ): Promise<SegmentDownloadResult> {
103
+ const registry = await touchAuthenticatedPartition(ctx);
99
104
  const entry = await ctx.segments.get(request.segmentId);
100
- if (entry === undefined || entry.record.partition !== ctx.partition) {
105
+ if (
106
+ entry === undefined ||
107
+ entry.record.partition !== ctx.partition ||
108
+ entry.record.logEpoch !== registry.logEpoch
109
+ ) {
101
110
  throw syncError('sync.not_found', 'unknown segment (§5.5)');
102
111
  }
103
112
  if (entry.record.expiresAtMs <= clockOf(ctx)()) {
@@ -12,6 +12,8 @@ export const DEFAULT_SEGMENT_TTL_MS = 24 * 60 * 60 * 1000;
12
12
 
13
13
  export interface SegmentMetadata {
14
14
  readonly partition: string;
15
+ /** Partition log continuity that produced these bytes (§2.1). */
16
+ readonly logEpoch: string;
15
17
  readonly table: string;
16
18
  readonly schemaVersion: number;
17
19
  readonly mediaType: 'rows' | 'sqlite';
@@ -36,6 +38,7 @@ export interface SegmentRecord extends SegmentMetadata {
36
38
  */
37
39
  export interface SegmentFindKey {
38
40
  readonly partition: string;
41
+ readonly logEpoch: string;
39
42
  readonly table: string;
40
43
  readonly schemaVersion: number;
41
44
  readonly mediaType: 'rows' | 'sqlite';
@@ -126,6 +129,7 @@ export class MemorySegmentStore implements SegmentStore {
126
129
  for (const { record } of this.#entries.values()) {
127
130
  if (
128
131
  record.partition === key.partition &&
132
+ record.logEpoch === key.logEpoch &&
129
133
  record.table === key.table &&
130
134
  record.schemaVersion === key.schemaVersion &&
131
135
  record.mediaType === key.mediaType &&
@@ -9,8 +9,12 @@ import type {
9
9
  export class BunSqliteDatabase implements SqliteDatabase {
10
10
  readonly native: Database;
11
11
 
12
- constructor(path = ':memory:') {
13
- this.native = new Database(path);
12
+ constructor(value: string | Database = ':memory:') {
13
+ this.native = typeof value === 'string' ? new Database(value) : value;
14
+ }
15
+
16
+ static deserialize(bytes: Uint8Array): BunSqliteDatabase {
17
+ return new BunSqliteDatabase(Database.deserialize(bytes));
14
18
  }
15
19
 
16
20
  exec(sql: string): void {
package/src/sqlite-bun.ts CHANGED
@@ -40,10 +40,10 @@ export class SqliteLeaseStore extends SharedSqliteLeaseStore {
40
40
  }
41
41
  }
42
42
 
43
- export const buildSqliteImage: SqliteImageBuilder = (input) => {
43
+ export const buildSqliteImage: SqliteImageBuilder = async (input) => {
44
44
  const db = new BunSqliteDatabase();
45
45
  try {
46
- writeSqliteImage(db, input);
46
+ await writeSqliteImage(db, input);
47
47
  return db.serialize();
48
48
  } finally {
49
49
  db.close();
@@ -43,6 +43,12 @@ CREATE TABLE IF NOT EXISTS sync_partitions(
43
43
  max_commit_seq INTEGER NOT NULL DEFAULT 0,
44
44
  horizon_seq INTEGER NOT NULL DEFAULT 0
45
45
  );
46
+ CREATE TABLE IF NOT EXISTS sync_partition_registry(
47
+ partition TEXT PRIMARY KEY,
48
+ log_epoch TEXT NOT NULL,
49
+ epoch_required INTEGER NOT NULL DEFAULT 0,
50
+ last_authenticated_at_ms INTEGER NOT NULL
51
+ );
46
52
  CREATE TABLE IF NOT EXISTS sync_row_scopes(
47
53
  partition TEXT NOT NULL, tbl TEXT NOT NULL,
48
54
  var TEXT NOT NULL, value TEXT NOT NULL, row_id TEXT NOT NULL,
@@ -96,6 +102,7 @@ CREATE INDEX IF NOT EXISTS sync_reactions_dead_letter
96
102
  ON sync_reactions(partition, status, available_at_ms, idempotency_key);
97
103
  CREATE TABLE IF NOT EXISTS sync_clients(
98
104
  partition TEXT NOT NULL, client_id TEXT NOT NULL, actor_id TEXT NOT NULL,
105
+ wire_version INTEGER NOT NULL DEFAULT 1,
99
106
  cursor INTEGER NOT NULL, subscriptions TEXT NOT NULL,
100
107
  updated_at_ms INTEGER NOT NULL,
101
108
  PRIMARY KEY(partition, client_id)
@@ -57,7 +57,9 @@ export interface SqliteImageInput {
57
57
  readonly schemaVersion: number;
58
58
  readonly asOfCommitSeq: number;
59
59
  readonly scopeDigest: string;
60
- readonly rows: readonly StoredRow[];
60
+ readonly rowBatches:
61
+ | AsyncIterable<readonly StoredRow[]>
62
+ | Iterable<readonly StoredRow[]>;
61
63
  }
62
64
 
63
65
  /**
@@ -68,14 +70,16 @@ export interface SqliteImageInput {
68
70
  * on the pull path. A Bun or Node host passes `buildSqliteImage`; a Workers
69
71
  * host omits it and serves the rows lane.
70
72
  */
71
- export type SqliteImageBuilder = (input: SqliteImageInput) => Uint8Array;
73
+ export type SqliteImageBuilder = (
74
+ input: SqliteImageInput,
75
+ ) => Promise<Uint8Array>;
72
76
 
73
77
  /** Populate a §5.3 image database for a whole-table snapshot. */
74
- export function writeSqliteImage(
78
+ export async function writeSqliteImage(
75
79
  db: SqliteDatabase,
76
80
  input: SqliteImageInput,
77
- ): void {
78
- const { table, rows } = input;
81
+ ): Promise<void> {
82
+ const { table, rowBatches } = input;
79
83
  const primaryKey = table.columns[table.primaryKeyIndex]?.name;
80
84
  const columnDefs = table.columns.map((column) => {
81
85
  const notNull = column.nullable ? '' : ' NOT NULL';
@@ -90,13 +94,6 @@ export function writeSqliteImage(
90
94
  "schemaVersion" INTEGER NOT NULL, "asOfCommitSeq" INTEGER NOT NULL,
91
95
  "scopeDigest" TEXT NOT NULL, "rowCount" INTEGER NOT NULL)`,
92
96
  );
93
- db.query(`INSERT INTO ${IMAGE_METADATA_TABLE} VALUES (1, ?, ?, ?, ?, ?)`).run(
94
- table.name,
95
- input.schemaVersion,
96
- input.asOfCommitSeq,
97
- input.scopeDigest,
98
- rows.length,
99
- );
100
97
  const names = [
101
98
  ...table.columns.map((column) => quoteIdent(column.name)),
102
99
  quoteIdent(IMAGE_VERSION_COLUMN),
@@ -107,10 +104,24 @@ export function writeSqliteImage(
107
104
  );
108
105
  db.exec('BEGIN');
109
106
  try {
110
- for (const row of rows) {
111
- const values = decodeRow(table.columns, row.payload);
112
- insert.run(...values.map(toSql), row.serverVersion);
107
+ let rowCount = 0;
108
+ for await (const rows of rowBatches) {
109
+ for (const row of rows) {
110
+ const values = decodeRow(table.columns, row.payload);
111
+ insert.run(...values.map(toSql), row.serverVersion);
112
+ }
113
+ rowCount += rows.length;
113
114
  }
115
+ db.query(
116
+ `INSERT INTO ${IMAGE_METADATA_TABLE} VALUES (1, ?, ?, ?, ?, ?)`,
117
+ ).run(
118
+ table.name,
119
+ input.schemaVersion,
120
+ input.asOfCommitSeq,
121
+ input.scopeDigest,
122
+ rowCount,
123
+ );
124
+
114
125
  db.exec('COMMIT');
115
126
  } catch (error) {
116
127
  db.exec('ROLLBACK');
@@ -43,13 +43,13 @@ export class SqliteLeaseStore extends SharedSqliteLeaseStore {
43
43
  }
44
44
  }
45
45
 
46
- export const buildSqliteImage: SqliteImageBuilder = (input) => {
46
+ export const buildSqliteImage: SqliteImageBuilder = async (input) => {
47
47
  const directory = mkdtempSync(join(tmpdir(), 'syncular-server-image-'));
48
48
  const path = join(directory, 'segment.db');
49
49
  const db = new NodeSqliteDatabase(path);
50
50
  try {
51
51
  try {
52
- writeSqliteImage(db, input);
52
+ await writeSqliteImage(db, input);
53
53
  } finally {
54
54
  db.close();
55
55
  }
@@ -31,6 +31,7 @@ export class SqliteSegmentStore implements SegmentStore {
31
31
  this.db.exec(`
32
32
  CREATE TABLE IF NOT EXISTS sync_segments(
33
33
  segment_id TEXT PRIMARY KEY, partition TEXT NOT NULL,
34
+ log_epoch TEXT NOT NULL,
34
35
  tbl TEXT NOT NULL, schema_version INTEGER NOT NULL,
35
36
  media_type TEXT NOT NULL, scope_digest TEXT NOT NULL,
36
37
  as_of_commit_seq INTEGER NOT NULL, row_count INTEGER NOT NULL,
@@ -39,6 +40,14 @@ export class SqliteSegmentStore implements SegmentStore {
39
40
  expires_at_ms INTEGER NOT NULL, bytes BLOB NOT NULL
40
41
  );
41
42
  `);
43
+ const columns = this.db
44
+ .query<{ name: string }, []>('PRAGMA table_info(sync_segments)')
45
+ .all();
46
+ if (!columns.some((column) => column.name === 'log_epoch')) {
47
+ this.db.exec(
48
+ "ALTER TABLE sync_segments ADD COLUMN log_epoch TEXT NOT NULL DEFAULT ''",
49
+ );
50
+ }
42
51
  }
43
52
 
44
53
  async put(
@@ -57,14 +66,15 @@ export class SqliteSegmentStore implements SegmentStore {
57
66
  this.db
58
67
  .query(
59
68
  `INSERT OR REPLACE INTO sync_segments(
60
- segment_id, partition, tbl, schema_version, media_type,
69
+ segment_id, partition, log_epoch, tbl, schema_version, media_type,
61
70
  scope_digest, as_of_commit_seq, row_count, row_cursor,
62
71
  next_row_cursor, byte_length, created_at_ms, expires_at_ms, bytes
63
- ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
72
+ ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
64
73
  )
65
74
  .run(
66
75
  record.segmentId,
67
76
  record.partition,
77
+ record.logEpoch,
68
78
  record.table,
69
79
  record.schemaVersion,
70
80
  record.mediaType,
@@ -89,6 +99,7 @@ export class SqliteSegmentStore implements SegmentStore {
89
99
  {
90
100
  segment_id: string;
91
101
  partition: string;
102
+ log_epoch: string;
92
103
  tbl: string;
93
104
  schema_version: number;
94
105
  media_type: string;
@@ -110,6 +121,7 @@ export class SqliteSegmentStore implements SegmentStore {
110
121
  record: {
111
122
  segmentId: row.segment_id,
112
123
  partition: row.partition,
124
+ logEpoch: row.log_epoch,
113
125
  table: row.tbl,
114
126
  schemaVersion: row.schema_version,
115
127
  mediaType: row.media_type === 'sqlite' ? 'sqlite' : 'rows',
@@ -140,18 +152,19 @@ export class SqliteSegmentStore implements SegmentStore {
140
152
  created_at_ms: number;
141
153
  expires_at_ms: number;
142
154
  },
143
- [string, string, number, string, string, number, number]
155
+ [string, string, string, number, string, string, number, number]
144
156
  >(
145
157
  `SELECT segment_id, row_count, next_row_cursor, byte_length,
146
158
  created_at_ms, expires_at_ms
147
159
  FROM sync_segments
148
- WHERE partition=? AND tbl=? AND schema_version=? AND media_type=?
160
+ WHERE partition=? AND log_epoch=? AND tbl=? AND schema_version=? AND media_type=?
149
161
  AND scope_digest=? AND as_of_commit_seq=? AND row_cursor IS NULL
150
162
  AND expires_at_ms > ?
151
163
  LIMIT 1`,
152
164
  )
153
165
  .get(
154
166
  key.partition,
167
+ key.logEpoch,
155
168
  key.table,
156
169
  key.schemaVersion,
157
170
  key.mediaType,
@@ -163,6 +176,7 @@ export class SqliteSegmentStore implements SegmentStore {
163
176
  return {
164
177
  segmentId: row.segment_id,
165
178
  partition: key.partition,
179
+ logEpoch: key.logEpoch,
166
180
  table: key.table,
167
181
  schemaVersion: key.schemaVersion,
168
182
  mediaType: key.mediaType,