@syncular/server 0.22.0 → 0.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/README.md +14 -2
  2. package/dist/admin.d.ts +3 -1
  3. package/dist/admin.js +7 -1
  4. package/dist/blob-handlers.js +1 -1
  5. package/dist/context.d.ts +8 -1
  6. package/dist/d1-storage.d.ts +11 -3
  7. package/dist/d1-storage.js +186 -36
  8. package/dist/errors.js +10 -0
  9. package/dist/handler.js +50 -4
  10. package/dist/operations.d.ts +7 -1
  11. package/dist/operations.js +5 -3
  12. package/dist/postgres-storage.d.ts +12 -4
  13. package/dist/postgres-storage.js +310 -59
  14. package/dist/push.js +31 -7
  15. package/dist/readiness.d.ts +9 -2
  16. package/dist/readiness.js +27 -2
  17. package/dist/realtime.js +20 -1
  18. package/dist/relational-rows.d.ts +31 -12
  19. package/dist/relational-rows.js +121 -26
  20. package/dist/segment-download.js +8 -0
  21. package/dist/sqlite-dialect.d.ts +1 -1
  22. package/dist/sqlite-dialect.js +15 -0
  23. package/dist/sqlite-storage.d.ts +18 -3
  24. package/dist/sqlite-storage.js +327 -82
  25. package/dist/storage-errors.d.ts +5 -3
  26. package/dist/storage-errors.js +13 -2
  27. package/dist/storage-query.d.ts +8 -1
  28. package/dist/storage-query.js +12 -0
  29. package/dist/storage.d.ts +153 -14
  30. package/dist/storage.js +40 -1
  31. package/dist/validate.d.ts +33 -15
  32. package/package.json +2 -2
  33. package/src/admin.ts +13 -1
  34. package/src/blob-handlers.ts +1 -1
  35. package/src/context.ts +8 -0
  36. package/src/d1-storage.ts +282 -37
  37. package/src/errors.ts +10 -0
  38. package/src/handler.ts +62 -4
  39. package/src/operations.ts +32 -14
  40. package/src/postgres-storage.ts +515 -68
  41. package/src/push.ts +31 -1
  42. package/src/readiness.ts +39 -4
  43. package/src/realtime.ts +22 -1
  44. package/src/relational-rows.ts +145 -30
  45. package/src/segment-download.ts +16 -0
  46. package/src/sqlite-dialect.ts +15 -0
  47. package/src/sqlite-storage.ts +491 -96
  48. package/src/storage-errors.ts +33 -3
  49. package/src/storage-query.ts +23 -1
  50. package/src/storage.ts +245 -12
  51. package/src/validate.ts +40 -15
package/src/push.ts CHANGED
@@ -52,7 +52,10 @@ import type {
52
52
  StoredPushResult,
53
53
  StoredRow,
54
54
  } from './storage';
55
+ import { serveGateRefusal } from './storage';
55
56
  import { StorageConstraintError } from './storage-errors';
57
+ import { transactionQuery } from './storage-query';
58
+ import { serveNotReadyError } from './readiness';
56
59
  import type {
57
60
  CommitValidationReader,
58
61
  CommitValidator,
@@ -184,6 +187,7 @@ async function runValidator(
184
187
  values: readonly RowValue[] | undefined,
185
188
  storedValues: readonly RowValue[] | undefined,
186
189
  opIndex: number,
190
+ tx: StorageTransaction,
187
191
  partition: string,
188
192
  actorId: string,
189
193
  ): Promise<TerminatingOutcome | undefined> {
@@ -204,7 +208,11 @@ async function runValidator(
204
208
  ? toValidateRow(table.columns, storedValues)
205
209
  : undefined,
206
210
  },
207
- { actorId, partition },
211
+ {
212
+ actorId,
213
+ partition,
214
+ queryAuthoritative: (query) => transactionQuery(tx, query),
215
+ },
208
216
  );
209
217
  } catch (error) {
210
218
  if (error instanceof ValidationRejection) {
@@ -361,6 +369,7 @@ async function applyOperation(
361
369
  undefined,
362
370
  storedValues,
363
371
  opIndex,
372
+ tx,
364
373
  partition,
365
374
  actorId,
366
375
  );
@@ -551,6 +560,7 @@ async function applyOperation(
551
560
  values,
552
561
  storedValues,
553
562
  opIndex,
563
+ tx,
554
564
  partition,
555
565
  actorId,
556
566
  );
@@ -681,6 +691,7 @@ async function applyOperation(
681
691
  values,
682
692
  undefined,
683
693
  opIndex,
694
+ tx,
684
695
  partition,
685
696
  actorId,
686
697
  );
@@ -834,6 +845,7 @@ function commitValidationReader(
834
845
  serverVersion: stored.serverVersion,
835
846
  }));
836
847
  },
848
+ queryAuthoritative: (query) => transactionQuery(tx, query),
837
849
  };
838
850
  }
839
851
 
@@ -1082,6 +1094,7 @@ async function enforceReferences(
1082
1094
  undefined,
1083
1095
  storedValues,
1084
1096
  item.originOpIndex,
1097
+ tx,
1085
1098
  partition,
1086
1099
  actorId,
1087
1100
  );
@@ -1115,6 +1128,7 @@ async function enforceReferences(
1115
1128
  values,
1116
1129
  storedValues,
1117
1130
  item.originOpIndex,
1131
+ tx,
1118
1132
  partition,
1119
1133
  actorId,
1120
1134
  );
@@ -1356,6 +1370,15 @@ export async function processPushOperationsWithTrace(
1356
1370
  );
1357
1371
  }
1358
1372
  await lockPartitionForPush();
1373
+ // RFC 0007: evaluate the gate on this transaction's own connection while
1374
+ // the partition lock is held, so the migration check and the write share
1375
+ // one transaction and no migration can interleave in between.
1376
+ const gate = await tx.readServeGate(schema.version);
1377
+ const refusal = serveGateRefusal(gate, schema.version, ctx.checkpoints);
1378
+ if (refusal !== undefined) {
1379
+ await tx.rollback();
1380
+ throw serveNotReadyError(refusal);
1381
+ }
1359
1382
  // The optimistic lookup above may have raced another delivery. Re-check
1360
1383
  // only after acquiring partition serialization and before any operation
1361
1384
  // read, validation, merge, or staged write. The re-check runs on the
@@ -1551,6 +1574,13 @@ export async function processPushOperationsWithTrace(
1551
1574
  }
1552
1575
  }
1553
1576
  if (ctx.realtime !== undefined && changes.length > 0) {
1577
+ // RFC 0007 fanout decision: the notification carries the durable commit
1578
+ // entry itself (this commit's `changes`), not a projection read or a pull
1579
+ // window, so fanning it out before the request's buffered read-verify can
1580
+ // never serve a mixed result. Each peer's next pull is gated at the
1581
+ // storage seam; a refused mixed push+pull is safe because the push is
1582
+ // durable and replayable under its commit id. This is commit-entry
1583
+ // durability, not gate inheritance.
1554
1584
  await ctx.realtime.notifyCommit(partition, {
1555
1585
  commitSeq,
1556
1586
  createdAtMs,
package/src/readiness.ts CHANGED
@@ -7,12 +7,17 @@
7
7
  * a structured failure that cannot be confused with request authentication.
8
8
  */
9
9
  import type { SyncServerConfig } from './context';
10
+ import { type SyncError, syncError } from './errors';
10
11
  import { type CompiledSchema, compileSchema } from './schema';
12
+ import type { ServeGateRefusal } from './storage';
11
13
 
12
14
  export const SYNC_SERVER_READINESS_ERROR_CODE =
13
15
  'sync.schema_not_ready' as const;
14
16
 
15
- export type SyncServerReadinessPhase = 'schema_compile' | 'storage_migration';
17
+ export type SyncServerReadinessPhase =
18
+ | 'schema_compile'
19
+ | 'storage_migration'
20
+ | 'backfill_checkpoint';
16
21
 
17
22
  export class SyncServerReadinessError extends Error {
18
23
  override readonly name = 'SyncServerReadinessError';
@@ -42,7 +47,7 @@ export class SyncServerReadinessError extends Error {
42
47
  * `cause` locally for the table/column or storage diagnostic.
43
48
  */
44
49
  export async function ensureSyncServerReady(
45
- config: Pick<SyncServerConfig, 'schema' | 'storage'>,
50
+ config: Pick<SyncServerConfig, 'schema' | 'storage' | 'checkpoints'>,
46
51
  ): Promise<void> {
47
52
  let compiled: CompiledSchema;
48
53
  try {
@@ -55,12 +60,42 @@ export async function ensureSyncServerReady(
55
60
  });
56
61
  }
57
62
  try {
58
- await config.storage.ensureSchema(compiled);
63
+ await config.storage.ensureSchema(compiled, config.checkpoints);
59
64
  } catch (cause) {
65
+ // RFC 0007: a declared-but-incomplete checkpoint is a barrier refusal, not
66
+ // an ordinary migration failure — hosts distinguish it by `phase` while the
67
+ // client-visible identity stays `sync.schema_not_ready`.
68
+ const phase: SyncServerReadinessPhase =
69
+ (cause as { code?: unknown } | null | undefined)?.code ===
70
+ 'sync.storage.checkpoint_incomplete'
71
+ ? 'backfill_checkpoint'
72
+ : 'storage_migration';
60
73
  throw new SyncServerReadinessError({
61
- phase: 'storage_migration',
74
+ phase,
62
75
  schemaVersion: config.schema.version,
63
76
  cause,
64
77
  });
65
78
  }
66
79
  }
80
+
81
+ /**
82
+ * RFC 0007: refuse a serve path that observed a closed gate. The projection
83
+ * name travels in structured details, never in the message.
84
+ */
85
+ export function serveNotReadyError(refusal: ServeGateRefusal): SyncError {
86
+ return syncError(
87
+ SYNC_SERVER_READINESS_ERROR_CODE,
88
+ 'server schema is not ready for this request',
89
+ refusal.kind === 'checkpoint'
90
+ ? JSON.stringify({
91
+ partition: refusal.partition,
92
+ projection: refusal.name,
93
+ })
94
+ : refusal.kind === 'epoch'
95
+ ? JSON.stringify({ logEpochChanged: true })
96
+ : JSON.stringify({
97
+ storedSchemaVersion: refusal.stored,
98
+ runningSchemaVersion: refusal.running,
99
+ }),
100
+ );
101
+ }
package/src/realtime.ts CHANGED
@@ -600,7 +600,28 @@ export class RealtimeSession {
600
600
  // begin its next round as soon as END *arrives* (§8.7 rule 3), so
601
601
  // in-flight must end with the send, not with generator cleanup.
602
602
  const iterator = stream[Symbol.asyncIterator]();
603
- let step = await iterator.next();
603
+ let step: IteratorResult<Uint8Array>;
604
+ try {
605
+ step = await iterator.next();
606
+ } catch (error) {
607
+ // The buffered read-verify refusal is thrown on the generator's first
608
+ // `next()`, before any chunk is yielded (the generator builds the whole
609
+ // response, then verifies). Nothing has escaped, so it is delivered as
610
+ // the same §8.4 ERROR frame HTTP returns rather than a silent close. A
611
+ // throw after the first chunk still takes the §8.7 violation path.
612
+ if (error instanceof DecodeError || error instanceof SyncError) {
613
+ const sync =
614
+ error instanceof SyncError
615
+ ? error
616
+ : syncError(error.code, error.message);
617
+ finishRound();
618
+ await this.#sendRoundChunk(
619
+ errorResponseBytes(sync, this.wireVersion, this.logEpoch),
620
+ );
621
+ return;
622
+ }
623
+ throw error;
624
+ }
604
625
  while (!step.done) {
605
626
  const chunk = step.value;
606
627
  step = await iterator.next();
@@ -55,6 +55,7 @@ import {
55
55
  } from '@syncular/core';
56
56
  import type { CompiledSchema, CompiledTable, IndexSchema } from './schema';
57
57
  import type { StoredRow } from './storage';
58
+ import { StorageQueryError } from './storage-errors';
58
59
 
59
60
  export type RelationalDialect = 'sqlite' | 'postgres';
60
61
 
@@ -192,38 +193,112 @@ export function tableColumnNames(table: CompiledTable): string[] {
192
193
  }
193
194
 
194
195
  /**
195
- * The physical column set the row codec and the scope index read for one
196
- * configured table. The persisted marker's layouts describe the codec's app
196
+ * The storage-internal columns of every synced table, in `createTableDdl`
197
+ * order (app columns sit between the row id and the version). The same-version
198
+ * physical check compares a table against exactly these declarations.
199
+ */
200
+ function syncMetaColumns(dialect: RelationalDialect): readonly {
201
+ readonly name: string;
202
+ readonly type: string;
203
+ readonly notNull: boolean;
204
+ }[] {
205
+ const payloadType = dialect === 'postgres' ? 'BYTEA' : 'BLOB';
206
+ return [
207
+ { name: SYNC_PARTITION_COLUMN, type: 'TEXT', notNull: true },
208
+ { name: SYNC_ROW_ID_COLUMN, type: 'TEXT', notNull: true },
209
+ {
210
+ name: SYNC_VERSION_COLUMN,
211
+ type: dialect === 'postgres' ? 'BIGINT' : 'INTEGER',
212
+ notNull: true,
213
+ },
214
+ {
215
+ name: SYNC_SCOPES_COLUMN,
216
+ type: dialect === 'postgres' ? 'JSONB' : 'TEXT',
217
+ notNull: true,
218
+ },
219
+ { name: SYNC_PAYLOAD_COLUMN, type: payloadType, notNull: true },
220
+ { name: SYNC_COLUMN_VERSIONS_COLUMN, type: payloadType, notNull: false },
221
+ ];
222
+ }
223
+
224
+ /** One physical column of a synced table, read from the database catalog. */
225
+ export interface PhysicalColumn {
226
+ readonly name: string;
227
+ /** Declared type (SQLite) or `format_type` (PostgreSQL); compared case-insensitively. */
228
+ readonly type: string;
229
+ readonly notNull: boolean;
230
+ /** 1-based position in the primary key; 0 when the column is not in it. */
231
+ readonly primaryKeyPosition: number;
232
+ }
233
+
234
+ /**
235
+ * Refuse a same-version database whose physical table the row store cannot
236
+ * read or write. The persisted marker's layouts describe the codec's app
197
237
  * columns only and its version number is the schema's, not the database's, so
198
- * a same-version database that is missing a storage-internal meta column (a
199
- * version-only bump whose ALTER never ran) still has to be refused: compare
200
- * the physical table and fail closed without writing a migration.
238
+ * a same-version database whose storage-internal meta columns drifted (a
239
+ * version-only bump whose ALTER never ran, or a table written by another
240
+ * build) is only visible in the catalog. Every configured column must exist;
241
+ * the meta columns must also carry the type, nullability, and primary-key
242
+ * position `createTableDdl` declares. App-column types and nullability stay
243
+ * with the stored layouts: migration-added app columns are nullable by design.
244
+ * Fails closed without writing a migration.
201
245
  *
202
- * `existingColumns` must be resolved the way the unqualified row queries
203
- * resolve the table — on PostgreSQL, through the session `search_path`, not
246
+ * `columns` must be resolved the way the unqualified row queries resolve the
247
+ * table: on PostgreSQL, through the session `search_path`, not
204
248
  * `current_schema()`. A search path whose first schema is not where the
205
249
  * configured tables live is outside the supported contract: DDL is created in
206
250
  * that first schema, so `ensureSchema` already writes shadow tables there.
207
251
  *
208
- * Scope: this checks that the columns EXIST. Stored types, nullability, and
209
- * non-column storage internals (the tombstone bookkeeping) stay outside it,
210
- * because a wrong type or nullability is a codec-compatibility question the
211
- * persisted layouts answer for app columns.
252
+ * Core storage tables (`sync_tombstones`, `sync_commits`, ...) are outside
253
+ * this check: every open re-applies their `CREATE TABLE IF NOT EXISTS` DDL
254
+ * and column additions, independently of the version marker.
212
255
  */
213
256
  export function assertPhysicalColumns(
214
257
  table: CompiledTable,
215
- existingColumns: ReadonlySet<string>,
258
+ columns: readonly PhysicalColumn[],
259
+ dialect: RelationalDialect,
216
260
  ): void {
217
- if (existingColumns.size === 0) {
218
- throw new Error(
219
- `table ${JSON.stringify(table.name)} is missing from the database at the stored schema version — refusing to serve a database whose rows the running code cannot read`,
220
- );
221
- }
261
+ const refuse = (details: Record<string, string>): never => {
262
+ throw new StorageQueryError('sync.storage.physical_layout_mismatch', {
263
+ table: table.name,
264
+ ...details,
265
+ });
266
+ };
267
+ if (columns.length === 0) refuse({ reason: 'missing_table' });
268
+ const byName = new Map(columns.map((column) => [column.name, column]));
222
269
  for (const name of tableColumnNames(table)) {
223
- if (existingColumns.has(name)) continue;
224
- throw new Error(
225
- `table ${JSON.stringify(table.name)} is missing column ${JSON.stringify(name)} at the stored schema version — refusing to serve a database whose rows the running code cannot read`,
226
- );
270
+ if (!byName.has(name)) refuse({ column: name, reason: 'missing_column' });
271
+ }
272
+ for (const expected of syncMetaColumns(dialect)) {
273
+ const actual = byName.get(expected.name);
274
+ if (actual === undefined) continue;
275
+ if (actual.type.toUpperCase() !== expected.type) {
276
+ refuse({
277
+ column: expected.name,
278
+ reason: 'type',
279
+ expected: expected.type,
280
+ actual: actual.type,
281
+ });
282
+ }
283
+ if (actual.notNull !== expected.notNull) {
284
+ refuse({
285
+ column: expected.name,
286
+ reason: 'nullability',
287
+ expected: expected.notNull ? 'not_null' : 'nullable',
288
+ actual: actual.notNull ? 'not_null' : 'nullable',
289
+ });
290
+ }
291
+ }
292
+ for (const column of columns) {
293
+ const expected =
294
+ column.name === SYNC_PARTITION_COLUMN
295
+ ? 1
296
+ : column.name === SYNC_ROW_ID_COLUMN
297
+ ? 2
298
+ : 0;
299
+ if (column.primaryKeyPosition !== expected) {
300
+ refuse({ column: column.name, reason: 'primary_key' });
301
+ }
227
302
  }
228
303
  }
229
304
 
@@ -232,22 +307,20 @@ export function createTableDdl(
232
307
  table: CompiledTable,
233
308
  dialect: RelationalDialect,
234
309
  ): string {
235
- const versionType = dialect === 'postgres' ? 'BIGINT' : 'INTEGER';
236
- const scopesType = dialect === 'postgres' ? 'JSONB' : 'TEXT';
237
- const payloadType = dialect === 'postgres' ? 'BYTEA' : 'BLOB';
310
+ const [partition, rowId, ...trailing] = syncMetaColumns(dialect).map(
311
+ (column) =>
312
+ `${quoteIdent(column.name)} ${column.type}${column.notNull ? ' NOT NULL' : ''}`,
313
+ );
238
314
  const defs = [
239
- `${quoteIdent(SYNC_PARTITION_COLUMN)} TEXT NOT NULL`,
240
- `${quoteIdent(SYNC_ROW_ID_COLUMN)} TEXT NOT NULL`,
315
+ partition,
316
+ rowId,
241
317
  ...(table.materialize
242
318
  ? table.columns.map((column) => {
243
319
  const notNull = column.nullable ? '' : ' NOT NULL';
244
320
  return `${quoteIdent(column.name)} ${columnSqlType(column, dialect)}${notNull}`;
245
321
  })
246
322
  : []),
247
- `${quoteIdent(SYNC_VERSION_COLUMN)} ${versionType} NOT NULL`,
248
- `${quoteIdent(SYNC_SCOPES_COLUMN)} ${scopesType} NOT NULL`,
249
- `${quoteIdent(SYNC_PAYLOAD_COLUMN)} ${payloadType} NOT NULL`,
250
- `${quoteIdent(SYNC_COLUMN_VERSIONS_COLUMN)} ${payloadType}`,
323
+ ...trailing,
251
324
  `PRIMARY KEY (${quoteIdent(SYNC_PARTITION_COLUMN)}, ${quoteIdent(SYNC_ROW_ID_COLUMN)})`,
252
325
  ];
253
326
  return `CREATE TABLE IF NOT EXISTS ${quoteIdent(table.name)} (${defs.join(', ')})`;
@@ -660,6 +733,48 @@ export function parseLayouts(json: string | null | undefined): StoredLayouts {
660
733
  return JSON.parse(json) as StoredLayouts;
661
734
  }
662
735
 
736
+ /**
737
+ * Version equality is not layout equality: a marker written by another build
738
+ * at the same version describes rows the running codec cannot decode. Refuse
739
+ * when the stored layouts disagree with the configured schema, naming the
740
+ * first table (and column) that differs.
741
+ */
742
+ export function assertStoredLayouts(
743
+ schema: CompiledSchema,
744
+ storedJson: string | null | undefined,
745
+ ): void {
746
+ const storedLayouts = parseLayouts(storedJson);
747
+ const configuredLayouts = parseLayouts(layoutsOf(schema));
748
+ for (const [tableName, columns] of Object.entries(configuredLayouts)) {
749
+ const stored = storedLayouts[tableName];
750
+ const columnCount = Math.max(columns.length, stored?.length ?? 0);
751
+ for (let index = 0; index < columnCount; index++) {
752
+ const expected = columns[index];
753
+ const actual = stored?.[index];
754
+ if (
755
+ expected !== undefined &&
756
+ actual !== undefined &&
757
+ actual.name === expected.name &&
758
+ actual.type === expected.type &&
759
+ actual.nullable === expected.nullable
760
+ ) {
761
+ continue;
762
+ }
763
+ throw new StorageQueryError('sync.storage.stored_layout_mismatch', {
764
+ table: tableName,
765
+ column: expected?.name ?? actual?.name ?? '',
766
+ });
767
+ }
768
+ }
769
+ for (const tableName of Object.keys(storedLayouts)) {
770
+ if (!(tableName in configuredLayouts)) {
771
+ throw new StorageQueryError('sync.storage.stored_layout_mismatch', {
772
+ table: tableName,
773
+ });
774
+ }
775
+ }
776
+ }
777
+
663
778
  /**
664
779
  * Tables present in the stored layout but absent from the configured head
665
780
  * schema. A schema-version bump retires their relational current-row tables;
@@ -196,6 +196,22 @@ async function downloadSegment(
196
196
  }
197
197
  const publication = candidates[candidates.length - 1]!;
198
198
 
199
+ // RFC 0007: a descriptor minted before this partition declared a backfill
200
+ // checkpoint is not a complete window once the projection becomes
201
+ // checkpointed. SPEC §9 mints segments per schema version, so refuse and
202
+ // let the client re-pull for a fresh descriptor.
203
+ const checkpoints = await ctx.storage.readCheckpoints(ctx.partition);
204
+ if (
205
+ checkpoints.some(
206
+ (checkpoint) => checkpoint.schemaVersion > publication.schemaVersion,
207
+ )
208
+ ) {
209
+ throw syncError(
210
+ 'sync.segment_expired',
211
+ 'segment predates the partition backfill checkpoint — re-pull (§5.1)',
212
+ );
213
+ }
214
+
199
215
  return {
200
216
  // The returned record is projected onto the selected publication, so its
201
217
  // `scopeDigests`/`publications` compatibility view cannot union a grant
@@ -66,6 +66,7 @@ CREATE TABLE IF NOT EXISTS sync_commits(
66
66
  partition TEXT NOT NULL, commit_seq INTEGER NOT NULL,
67
67
  client_id TEXT NOT NULL, client_commit_id TEXT NOT NULL,
68
68
  actor_id TEXT NOT NULL, created_at_ms INTEGER NOT NULL,
69
+ writer_version INTEGER,
69
70
  PRIMARY KEY(partition, commit_seq)
70
71
  );
71
72
  CREATE INDEX IF NOT EXISTS sync_commits_by_time
@@ -127,6 +128,20 @@ CREATE TABLE IF NOT EXISTS sync_tombstones(
127
128
  commit_seq INTEGER NOT NULL,
128
129
  PRIMARY KEY(partition, tbl, row_id)
129
130
  );
131
+ CREATE TABLE IF NOT EXISTS sync_backfill_checkpoints(
132
+ partition TEXT NOT NULL, name TEXT NOT NULL,
133
+ schema_version INTEGER NOT NULL,
134
+ state TEXT NOT NULL CHECK(state IN ('declared','backfilling','activated')),
135
+ watermark INTEGER NOT NULL DEFAULT 0,
136
+ owner_epoch INTEGER NOT NULL DEFAULT 0,
137
+ observed_rows INTEGER NOT NULL DEFAULT 0,
138
+ updated_at_ms INTEGER NOT NULL,
139
+ PRIMARY KEY(partition, name)
140
+ );
141
+ CREATE TABLE IF NOT EXISTS sync_writer_fence(
142
+ partition TEXT PRIMARY KEY,
143
+ required_writer_version INTEGER NOT NULL
144
+ );
130
145
  `;
131
146
 
132
147
  /** Split the DDL into individual statements (D1 applies them one by one). */