@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/dist/push.js CHANGED
@@ -24,7 +24,10 @@ import { emitEvent } from './events.js';
24
24
  import { prepareReactions, toNewReactions, } from './reactions.js';
25
25
  import { authorizeWrite, renderScopeValue, storedScopesForRow } from './scopes.js';
26
26
  import { decodeColumnVersions, encodeColumnVersions } from './relational-rows.js';
27
+ import { serveGateRefusal } from './storage.js';
27
28
  import { StorageConstraintError } from './storage-errors.js';
29
+ import { transactionQuery } from './storage-query.js';
30
+ import { serveNotReadyError } from './readiness.js';
28
31
  import { CommitValidationRejection, toValidateRow, ValidationRejection, } from './validate.js';
29
32
  /**
30
33
  * Extract the blobIds a decoded row references through its `blob_ref`
@@ -84,7 +87,7 @@ function conflictColumnsBitmap(columnCount, marked) {
84
87
  * A no-op for tables with no validator — the `undefined` short-circuit
85
88
  * keeps the feature zero-cost when off.
86
89
  */
87
- async function runValidator(validators, table, op, rowId, values, storedValues, opIndex, partition, actorId) {
90
+ async function runValidator(validators, table, op, rowId, values, storedValues, opIndex, tx, partition, actorId) {
88
91
  const validator = validators?.[table.name];
89
92
  if (validator === undefined)
90
93
  return undefined;
@@ -99,7 +102,11 @@ async function runValidator(validators, table, op, rowId, values, storedValues,
99
102
  stored: storedValues !== undefined
100
103
  ? toValidateRow(table.columns, storedValues)
101
104
  : undefined,
102
- }, { actorId, partition });
105
+ }, {
106
+ actorId,
107
+ partition,
108
+ queryAuthoritative: (query) => transactionQuery(tx, query),
109
+ });
103
110
  }
104
111
  catch (error) {
105
112
  if (error instanceof ValidationRejection) {
@@ -197,7 +204,7 @@ async function applyOperation(tx, schema, resolved, op, opIndex, blobCtx, crdtMe
197
204
  // stored = the row about to be removed). Only reached for an existing
198
205
  // row — an absent-row delete is an idempotent no-op above.
199
206
  const storedValues = decodeRow(table.columns, stored.payload);
200
- const deleteReject = await runValidator(validators, table, 'delete', op.rowId, undefined, storedValues, opIndex, partition, actorId);
207
+ const deleteReject = await runValidator(validators, table, 'delete', op.rowId, undefined, storedValues, opIndex, tx, partition, actorId);
201
208
  if (deleteReject !== undefined)
202
209
  return deleteReject;
203
210
  await tx.deleteRow(op.table, op.rowId);
@@ -328,7 +335,7 @@ async function applyOperation(tx, schema, resolved, op, opIndex, blobCtx, crdtMe
328
335
  // §6.7: validate the merged row that will persist — for a crdt column
329
336
  // the validator sees the MERGED value (§5.10.3), the state the store
330
337
  // holds, not the raw pushed update.
331
- const updateReject = await runValidator(validators, table, 'upsert', op.rowId, values, storedValues, opIndex, partition, actorId);
338
+ const updateReject = await runValidator(validators, table, 'upsert', op.rowId, values, storedValues, opIndex, tx, partition, actorId);
332
339
  if (updateReject !== undefined)
333
340
  return updateReject;
334
341
  const columnVersions = encodeColumnVersions(nextVersions);
@@ -410,7 +417,7 @@ async function applyOperation(tx, schema, resolved, op, opIndex, blobCtx, crdtMe
410
417
  // §6.7: validate the insert row (stored = undefined, so a validator can
411
418
  // distinguish create from update); crdt columns are already merged
412
419
  // against the empty document.
413
- const insertReject = await runValidator(validators, table, 'upsert', op.rowId, values, undefined, opIndex, partition, actorId);
420
+ const insertReject = await runValidator(validators, table, 'upsert', op.rowId, values, undefined, opIndex, tx, partition, actorId);
414
421
  if (insertReject !== undefined)
415
422
  return insertReject;
416
423
  // §5.2: an explicit insert recreates — the tombstone goes with it.
@@ -524,6 +531,7 @@ function commitValidationReader(tx, schema) {
524
531
  serverVersion: stored.serverVersion,
525
532
  }));
526
533
  },
534
+ queryAuthoritative: (query) => transactionQuery(tx, query),
527
535
  };
528
536
  }
529
537
  async function runCommitValidator(validator, tx, schema, clientId, clientCommitId, actorId, partition, operations) {
@@ -680,7 +688,7 @@ async function enforceReferences(tx, schema, staged, clientOpCount, cascadeLimit
680
688
  const syntheticIndex = clientOpCount + originByIndex.length;
681
689
  const storedValues = decodeRow(child.columns, childRow.payload);
682
690
  if (reference.onDelete === 'CASCADE') {
683
- const reject = await runValidator(validators, child, 'delete', childRow.rowId, undefined, storedValues, item.originOpIndex, partition, actorId);
691
+ const reject = await runValidator(validators, child, 'delete', childRow.rowId, undefined, storedValues, item.originOpIndex, tx, partition, actorId);
684
692
  if (reject !== undefined) {
685
693
  return { kind: 'terminate', record: reject.record };
686
694
  }
@@ -704,7 +712,7 @@ async function enforceReferences(tx, schema, staged, clientOpCount, cascadeLimit
704
712
  else {
705
713
  const values = [...storedValues];
706
714
  values[reference.columnIndex] = null;
707
- const reject = await runValidator(validators, child, 'upsert', childRow.rowId, values, storedValues, item.originOpIndex, partition, actorId);
715
+ const reject = await runValidator(validators, child, 'upsert', childRow.rowId, values, storedValues, item.originOpIndex, tx, partition, actorId);
708
716
  if (reject !== undefined) {
709
717
  return { kind: 'terminate', record: reject.record };
710
718
  }
@@ -864,6 +872,15 @@ export async function processPushOperationsWithTrace(ctx, schema, resolved, clie
864
872
  throw new Error('storage transaction does not support serialized push apply, atomic rejection finalization, and configured durable reactions');
865
873
  }
866
874
  await lockPartitionForPush();
875
+ // RFC 0007: evaluate the gate on this transaction's own connection while
876
+ // the partition lock is held, so the migration check and the write share
877
+ // one transaction and no migration can interleave in between.
878
+ const gate = await tx.readServeGate(schema.version);
879
+ const refusal = serveGateRefusal(gate, schema.version, ctx.checkpoints);
880
+ if (refusal !== undefined) {
881
+ await tx.rollback();
882
+ throw serveNotReadyError(refusal);
883
+ }
867
884
  // The optimistic lookup above may have raced another delivery. Re-check
868
885
  // only after acquiring partition serialization and before any operation
869
886
  // read, validation, merge, or staged write. The re-check runs on the
@@ -1015,6 +1032,13 @@ export async function processPushOperationsWithTrace(ctx, schema, resolved, clie
1015
1032
  }
1016
1033
  }
1017
1034
  if (ctx.realtime !== undefined && changes.length > 0) {
1035
+ // RFC 0007 fanout decision: the notification carries the durable commit
1036
+ // entry itself (this commit's `changes`), not a projection read or a pull
1037
+ // window, so fanning it out before the request's buffered read-verify can
1038
+ // never serve a mixed result. Each peer's next pull is gated at the
1039
+ // storage seam; a refused mixed push+pull is safe because the push is
1040
+ // durable and replayable under its commit id. This is commit-entry
1041
+ // durability, not gate inheritance.
1018
1042
  await ctx.realtime.notifyCommit(partition, {
1019
1043
  commitSeq,
1020
1044
  createdAtMs,
@@ -7,8 +7,10 @@
7
7
  * a structured failure that cannot be confused with request authentication.
8
8
  */
9
9
  import type { SyncServerConfig } from './context.js';
10
+ import { type SyncError } from './errors.js';
11
+ import type { ServeGateRefusal } from './storage.js';
10
12
  export declare const SYNC_SERVER_READINESS_ERROR_CODE: "sync.schema_not_ready";
11
- export type SyncServerReadinessPhase = 'schema_compile' | 'storage_migration';
13
+ export type SyncServerReadinessPhase = 'schema_compile' | 'storage_migration' | 'backfill_checkpoint';
12
14
  export declare class SyncServerReadinessError extends Error {
13
15
  readonly name = "SyncServerReadinessError";
14
16
  readonly code: "sync.schema_not_ready";
@@ -27,4 +29,9 @@ export declare class SyncServerReadinessError extends Error {
27
29
  * exposes only a stable code, phase, and schema version; operators can inspect
28
30
  * `cause` locally for the table/column or storage diagnostic.
29
31
  */
30
- export declare function ensureSyncServerReady(config: Pick<SyncServerConfig, 'schema' | 'storage'>): Promise<void>;
32
+ export declare function ensureSyncServerReady(config: Pick<SyncServerConfig, 'schema' | 'storage' | 'checkpoints'>): Promise<void>;
33
+ /**
34
+ * RFC 0007: refuse a serve path that observed a closed gate. The projection
35
+ * name travels in structured details, never in the message.
36
+ */
37
+ export declare function serveNotReadyError(refusal: ServeGateRefusal): SyncError;
package/dist/readiness.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { syncError } from './errors.js';
1
2
  import { compileSchema } from './schema.js';
2
3
  export const SYNC_SERVER_READINESS_ERROR_CODE = 'sync.schema_not_ready';
3
4
  export class SyncServerReadinessError extends Error {
@@ -31,13 +32,37 @@ export async function ensureSyncServerReady(config) {
31
32
  });
32
33
  }
33
34
  try {
34
- await config.storage.ensureSchema(compiled);
35
+ await config.storage.ensureSchema(compiled, config.checkpoints);
35
36
  }
36
37
  catch (cause) {
38
+ // RFC 0007: a declared-but-incomplete checkpoint is a barrier refusal, not
39
+ // an ordinary migration failure — hosts distinguish it by `phase` while the
40
+ // client-visible identity stays `sync.schema_not_ready`.
41
+ const phase = cause?.code ===
42
+ 'sync.storage.checkpoint_incomplete'
43
+ ? 'backfill_checkpoint'
44
+ : 'storage_migration';
37
45
  throw new SyncServerReadinessError({
38
- phase: 'storage_migration',
46
+ phase,
39
47
  schemaVersion: config.schema.version,
40
48
  cause,
41
49
  });
42
50
  }
43
51
  }
52
+ /**
53
+ * RFC 0007: refuse a serve path that observed a closed gate. The projection
54
+ * name travels in structured details, never in the message.
55
+ */
56
+ export function serveNotReadyError(refusal) {
57
+ return syncError(SYNC_SERVER_READINESS_ERROR_CODE, 'server schema is not ready for this request', refusal.kind === 'checkpoint'
58
+ ? JSON.stringify({
59
+ partition: refusal.partition,
60
+ projection: refusal.name,
61
+ })
62
+ : refusal.kind === 'epoch'
63
+ ? JSON.stringify({ logEpochChanged: true })
64
+ : JSON.stringify({
65
+ storedSchemaVersion: refusal.stored,
66
+ runningSchemaVersion: refusal.running,
67
+ }));
68
+ }
package/dist/realtime.js CHANGED
@@ -446,7 +446,26 @@ export class RealtimeSession {
446
446
  // begin its next round as soon as END *arrives* (§8.7 rule 3), so
447
447
  // in-flight must end with the send, not with generator cleanup.
448
448
  const iterator = stream[Symbol.asyncIterator]();
449
- let step = await iterator.next();
449
+ let step;
450
+ try {
451
+ step = await iterator.next();
452
+ }
453
+ catch (error) {
454
+ // The buffered read-verify refusal is thrown on the generator's first
455
+ // `next()`, before any chunk is yielded (the generator builds the whole
456
+ // response, then verifies). Nothing has escaped, so it is delivered as
457
+ // the same §8.4 ERROR frame HTTP returns rather than a silent close. A
458
+ // throw after the first chunk still takes the §8.7 violation path.
459
+ if (error instanceof DecodeError || error instanceof SyncError) {
460
+ const sync = error instanceof SyncError
461
+ ? error
462
+ : syncError(error.code, error.message);
463
+ finishRound();
464
+ await this.#sendRoundChunk(errorResponseBytes(sync, this.wireVersion, this.logEpoch));
465
+ return;
466
+ }
467
+ throw error;
468
+ }
450
469
  while (!step.done) {
451
470
  const chunk = step.value;
452
471
  step = await iterator.next();
@@ -75,26 +75,38 @@ export declare function columnSqlType(column: RowColumn, dialect: RelationalDial
75
75
  * materialized), version, scopes, payload.
76
76
  */
77
77
  export declare function tableColumnNames(table: CompiledTable): string[];
78
+ /** One physical column of a synced table, read from the database catalog. */
79
+ export interface PhysicalColumn {
80
+ readonly name: string;
81
+ /** Declared type (SQLite) or `format_type` (PostgreSQL); compared case-insensitively. */
82
+ readonly type: string;
83
+ readonly notNull: boolean;
84
+ /** 1-based position in the primary key; 0 when the column is not in it. */
85
+ readonly primaryKeyPosition: number;
86
+ }
78
87
  /**
79
- * The physical column set the row codec and the scope index read for one
80
- * configured table. The persisted marker's layouts describe the codec's app
88
+ * Refuse a same-version database whose physical table the row store cannot
89
+ * read or write. The persisted marker's layouts describe the codec's app
81
90
  * columns only and its version number is the schema's, not the database's, so
82
- * a same-version database that is missing a storage-internal meta column (a
83
- * version-only bump whose ALTER never ran) still has to be refused: compare
84
- * the physical table and fail closed without writing a migration.
91
+ * a same-version database whose storage-internal meta columns drifted (a
92
+ * version-only bump whose ALTER never ran, or a table written by another
93
+ * build) is only visible in the catalog. Every configured column must exist;
94
+ * the meta columns must also carry the type, nullability, and primary-key
95
+ * position `createTableDdl` declares. App-column types and nullability stay
96
+ * with the stored layouts: migration-added app columns are nullable by design.
97
+ * Fails closed without writing a migration.
85
98
  *
86
- * `existingColumns` must be resolved the way the unqualified row queries
87
- * resolve the table — on PostgreSQL, through the session `search_path`, not
99
+ * `columns` must be resolved the way the unqualified row queries resolve the
100
+ * table: on PostgreSQL, through the session `search_path`, not
88
101
  * `current_schema()`. A search path whose first schema is not where the
89
102
  * configured tables live is outside the supported contract: DDL is created in
90
103
  * that first schema, so `ensureSchema` already writes shadow tables there.
91
104
  *
92
- * Scope: this checks that the columns EXIST. Stored types, nullability, and
93
- * non-column storage internals (the tombstone bookkeeping) stay outside it,
94
- * because a wrong type or nullability is a codec-compatibility question the
95
- * persisted layouts answer for app columns.
105
+ * Core storage tables (`sync_tombstones`, `sync_commits`, ...) are outside
106
+ * this check: every open re-applies their `CREATE TABLE IF NOT EXISTS` DDL
107
+ * and column additions, independently of the version marker.
96
108
  */
97
- export declare function assertPhysicalColumns(table: CompiledTable, existingColumns: ReadonlySet<string>): void;
109
+ export declare function assertPhysicalColumns(table: CompiledTable, columns: readonly PhysicalColumn[], dialect: RelationalDialect): void;
98
110
  /** CREATE TABLE IF NOT EXISTS for one app table. */
99
111
  export declare function createTableDdl(table: CompiledTable, dialect: RelationalDialect): string;
100
112
  /**
@@ -249,6 +261,13 @@ export type StoredLayouts = Record<string, readonly StoredColumnLayout[]>;
249
261
  /** The layouts JSON persisted alongside the schema version marker. */
250
262
  export declare function layoutsOf(schema: CompiledSchema): string;
251
263
  export declare function parseLayouts(json: string | null | undefined): StoredLayouts;
264
+ /**
265
+ * Version equality is not layout equality: a marker written by another build
266
+ * at the same version describes rows the running codec cannot decode. Refuse
267
+ * when the stored layouts disagree with the configured schema, naming the
268
+ * first table (and column) that differs.
269
+ */
270
+ export declare function assertStoredLayouts(schema: CompiledSchema, storedJson: string | null | undefined): void;
252
271
  /**
253
272
  * Tables present in the stored layout but absent from the configured head
254
273
  * schema. A schema-version bump retires their relational current-row tables;
@@ -48,6 +48,7 @@
48
48
  * NOT NULL per the schema.
49
49
  */
50
50
  import { decodeRow, encodeRow, } from '@syncular/core';
51
+ import { StorageQueryError } from './storage-errors.js';
51
52
  export const SYNC_PARTITION_COLUMN = '_sync_partition';
52
53
  export const SYNC_ROW_ID_COLUMN = '_sync_row_id';
53
54
  export const SYNC_VERSION_COLUMN = '_sync_server_version';
@@ -170,52 +171,110 @@ export function tableColumnNames(table) {
170
171
  ];
171
172
  }
172
173
  /**
173
- * The physical column set the row codec and the scope index read for one
174
- * configured table. The persisted marker's layouts describe the codec's app
174
+ * The storage-internal columns of every synced table, in `createTableDdl`
175
+ * order (app columns sit between the row id and the version). The same-version
176
+ * physical check compares a table against exactly these declarations.
177
+ */
178
+ function syncMetaColumns(dialect) {
179
+ const payloadType = dialect === 'postgres' ? 'BYTEA' : 'BLOB';
180
+ return [
181
+ { name: SYNC_PARTITION_COLUMN, type: 'TEXT', notNull: true },
182
+ { name: SYNC_ROW_ID_COLUMN, type: 'TEXT', notNull: true },
183
+ {
184
+ name: SYNC_VERSION_COLUMN,
185
+ type: dialect === 'postgres' ? 'BIGINT' : 'INTEGER',
186
+ notNull: true,
187
+ },
188
+ {
189
+ name: SYNC_SCOPES_COLUMN,
190
+ type: dialect === 'postgres' ? 'JSONB' : 'TEXT',
191
+ notNull: true,
192
+ },
193
+ { name: SYNC_PAYLOAD_COLUMN, type: payloadType, notNull: true },
194
+ { name: SYNC_COLUMN_VERSIONS_COLUMN, type: payloadType, notNull: false },
195
+ ];
196
+ }
197
+ /**
198
+ * Refuse a same-version database whose physical table the row store cannot
199
+ * read or write. The persisted marker's layouts describe the codec's app
175
200
  * columns only and its version number is the schema's, not the database's, so
176
- * a same-version database that is missing a storage-internal meta column (a
177
- * version-only bump whose ALTER never ran) still has to be refused: compare
178
- * the physical table and fail closed without writing a migration.
201
+ * a same-version database whose storage-internal meta columns drifted (a
202
+ * version-only bump whose ALTER never ran, or a table written by another
203
+ * build) is only visible in the catalog. Every configured column must exist;
204
+ * the meta columns must also carry the type, nullability, and primary-key
205
+ * position `createTableDdl` declares. App-column types and nullability stay
206
+ * with the stored layouts: migration-added app columns are nullable by design.
207
+ * Fails closed without writing a migration.
179
208
  *
180
- * `existingColumns` must be resolved the way the unqualified row queries
181
- * resolve the table — on PostgreSQL, through the session `search_path`, not
209
+ * `columns` must be resolved the way the unqualified row queries resolve the
210
+ * table: on PostgreSQL, through the session `search_path`, not
182
211
  * `current_schema()`. A search path whose first schema is not where the
183
212
  * configured tables live is outside the supported contract: DDL is created in
184
213
  * that first schema, so `ensureSchema` already writes shadow tables there.
185
214
  *
186
- * Scope: this checks that the columns EXIST. Stored types, nullability, and
187
- * non-column storage internals (the tombstone bookkeeping) stay outside it,
188
- * because a wrong type or nullability is a codec-compatibility question the
189
- * persisted layouts answer for app columns.
215
+ * Core storage tables (`sync_tombstones`, `sync_commits`, ...) are outside
216
+ * this check: every open re-applies their `CREATE TABLE IF NOT EXISTS` DDL
217
+ * and column additions, independently of the version marker.
190
218
  */
191
- export function assertPhysicalColumns(table, existingColumns) {
192
- if (existingColumns.size === 0) {
193
- throw new Error(`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`);
194
- }
219
+ export function assertPhysicalColumns(table, columns, dialect) {
220
+ const refuse = (details) => {
221
+ throw new StorageQueryError('sync.storage.physical_layout_mismatch', {
222
+ table: table.name,
223
+ ...details,
224
+ });
225
+ };
226
+ if (columns.length === 0)
227
+ refuse({ reason: 'missing_table' });
228
+ const byName = new Map(columns.map((column) => [column.name, column]));
195
229
  for (const name of tableColumnNames(table)) {
196
- if (existingColumns.has(name))
230
+ if (!byName.has(name))
231
+ refuse({ column: name, reason: 'missing_column' });
232
+ }
233
+ for (const expected of syncMetaColumns(dialect)) {
234
+ const actual = byName.get(expected.name);
235
+ if (actual === undefined)
197
236
  continue;
198
- throw new Error(`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`);
237
+ if (actual.type.toUpperCase() !== expected.type) {
238
+ refuse({
239
+ column: expected.name,
240
+ reason: 'type',
241
+ expected: expected.type,
242
+ actual: actual.type,
243
+ });
244
+ }
245
+ if (actual.notNull !== expected.notNull) {
246
+ refuse({
247
+ column: expected.name,
248
+ reason: 'nullability',
249
+ expected: expected.notNull ? 'not_null' : 'nullable',
250
+ actual: actual.notNull ? 'not_null' : 'nullable',
251
+ });
252
+ }
253
+ }
254
+ for (const column of columns) {
255
+ const expected = column.name === SYNC_PARTITION_COLUMN
256
+ ? 1
257
+ : column.name === SYNC_ROW_ID_COLUMN
258
+ ? 2
259
+ : 0;
260
+ if (column.primaryKeyPosition !== expected) {
261
+ refuse({ column: column.name, reason: 'primary_key' });
262
+ }
199
263
  }
200
264
  }
201
265
  /** CREATE TABLE IF NOT EXISTS for one app table. */
202
266
  export function createTableDdl(table, dialect) {
203
- const versionType = dialect === 'postgres' ? 'BIGINT' : 'INTEGER';
204
- const scopesType = dialect === 'postgres' ? 'JSONB' : 'TEXT';
205
- const payloadType = dialect === 'postgres' ? 'BYTEA' : 'BLOB';
267
+ const [partition, rowId, ...trailing] = syncMetaColumns(dialect).map((column) => `${quoteIdent(column.name)} ${column.type}${column.notNull ? ' NOT NULL' : ''}`);
206
268
  const defs = [
207
- `${quoteIdent(SYNC_PARTITION_COLUMN)} TEXT NOT NULL`,
208
- `${quoteIdent(SYNC_ROW_ID_COLUMN)} TEXT NOT NULL`,
269
+ partition,
270
+ rowId,
209
271
  ...(table.materialize
210
272
  ? table.columns.map((column) => {
211
273
  const notNull = column.nullable ? '' : ' NOT NULL';
212
274
  return `${quoteIdent(column.name)} ${columnSqlType(column, dialect)}${notNull}`;
213
275
  })
214
276
  : []),
215
- `${quoteIdent(SYNC_VERSION_COLUMN)} ${versionType} NOT NULL`,
216
- `${quoteIdent(SYNC_SCOPES_COLUMN)} ${scopesType} NOT NULL`,
217
- `${quoteIdent(SYNC_PAYLOAD_COLUMN)} ${payloadType} NOT NULL`,
218
- `${quoteIdent(SYNC_COLUMN_VERSIONS_COLUMN)} ${payloadType}`,
277
+ ...trailing,
219
278
  `PRIMARY KEY (${quoteIdent(SYNC_PARTITION_COLUMN)}, ${quoteIdent(SYNC_ROW_ID_COLUMN)})`,
220
279
  ];
221
280
  return `CREATE TABLE IF NOT EXISTS ${quoteIdent(table.name)} (${defs.join(', ')})`;
@@ -555,6 +614,42 @@ export function parseLayouts(json) {
555
614
  return {};
556
615
  return JSON.parse(json);
557
616
  }
617
+ /**
618
+ * Version equality is not layout equality: a marker written by another build
619
+ * at the same version describes rows the running codec cannot decode. Refuse
620
+ * when the stored layouts disagree with the configured schema, naming the
621
+ * first table (and column) that differs.
622
+ */
623
+ export function assertStoredLayouts(schema, storedJson) {
624
+ const storedLayouts = parseLayouts(storedJson);
625
+ const configuredLayouts = parseLayouts(layoutsOf(schema));
626
+ for (const [tableName, columns] of Object.entries(configuredLayouts)) {
627
+ const stored = storedLayouts[tableName];
628
+ const columnCount = Math.max(columns.length, stored?.length ?? 0);
629
+ for (let index = 0; index < columnCount; index++) {
630
+ const expected = columns[index];
631
+ const actual = stored?.[index];
632
+ if (expected !== undefined &&
633
+ actual !== undefined &&
634
+ actual.name === expected.name &&
635
+ actual.type === expected.type &&
636
+ actual.nullable === expected.nullable) {
637
+ continue;
638
+ }
639
+ throw new StorageQueryError('sync.storage.stored_layout_mismatch', {
640
+ table: tableName,
641
+ column: expected?.name ?? actual?.name ?? '',
642
+ });
643
+ }
644
+ }
645
+ for (const tableName of Object.keys(storedLayouts)) {
646
+ if (!(tableName in configuredLayouts)) {
647
+ throw new StorageQueryError('sync.storage.stored_layout_mismatch', {
648
+ table: tableName,
649
+ });
650
+ }
651
+ }
652
+ }
558
653
  /**
559
654
  * Tables present in the stored layout but absent from the configured head
560
655
  * schema. A schema-version bump retires their relational current-row tables;
@@ -136,6 +136,14 @@ async function downloadSegment(ctx, request) {
136
136
  throw syncError('sync.invalid_subscription', 'invalid requested scopes (§3.2)');
137
137
  }
138
138
  const publication = candidates[candidates.length - 1];
139
+ // RFC 0007: a descriptor minted before this partition declared a backfill
140
+ // checkpoint is not a complete window once the projection becomes
141
+ // checkpointed. SPEC §9 mints segments per schema version, so refuse and
142
+ // let the client re-pull for a fresh descriptor.
143
+ const checkpoints = await ctx.storage.readCheckpoints(ctx.partition);
144
+ if (checkpoints.some((checkpoint) => checkpoint.schemaVersion > publication.schemaVersion)) {
145
+ throw syncError('sync.segment_expired', 'segment predates the partition backfill checkpoint — re-pull (§5.1)');
146
+ }
139
147
  return {
140
148
  // The returned record is projected onto the selected publication, so its
141
149
  // `scopeDigests`/`publications` compatibility view cannot union a grant
@@ -36,7 +36,7 @@ import type { StoredChange, StoredCommit, StoredPushResult, StoredRow } from './
36
36
  * inverted index. Declared here so every SQLite-family storage (and D1,
37
37
  * which applies this same DDL) keeps the same plan shape as Postgres.
38
38
  */
39
- 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 INDEX IF NOT EXISTS sync_row_scopes_by_row\n ON sync_row_scopes(partition, tbl, row_id);\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);\nCREATE TABLE IF NOT EXISTS sync_tombstones(\n partition TEXT NOT NULL, tbl TEXT NOT NULL, row_id TEXT NOT NULL,\n commit_seq INTEGER NOT NULL,\n PRIMARY KEY(partition, tbl, row_id)\n);\n";
39
+ 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 INDEX IF NOT EXISTS sync_row_scopes_by_row\n ON sync_row_scopes(partition, tbl, row_id);\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 writer_version INTEGER,\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);\nCREATE TABLE IF NOT EXISTS sync_tombstones(\n partition TEXT NOT NULL, tbl TEXT NOT NULL, row_id TEXT NOT NULL,\n commit_seq INTEGER NOT NULL,\n PRIMARY KEY(partition, tbl, row_id)\n);\nCREATE TABLE IF NOT EXISTS sync_backfill_checkpoints(\n partition TEXT NOT NULL, name TEXT NOT NULL,\n schema_version INTEGER NOT NULL,\n state TEXT NOT NULL CHECK(state IN ('declared','backfilling','activated')),\n watermark INTEGER NOT NULL DEFAULT 0,\n owner_epoch INTEGER NOT NULL DEFAULT 0,\n observed_rows INTEGER NOT NULL DEFAULT 0,\n updated_at_ms INTEGER NOT NULL,\n PRIMARY KEY(partition, name)\n);\nCREATE TABLE IF NOT EXISTS sync_writer_fence(\n partition TEXT PRIMARY KEY,\n required_writer_version INTEGER NOT NULL\n);\n";
40
40
  /** Split the DDL into individual statements (D1 applies them one by one). */
41
41
  export declare function sqliteDdlStatements(): string[];
42
42
  /** `?,?,…` for an `IN (…)` clause of `count` positional parameters. */
@@ -41,6 +41,7 @@ CREATE TABLE IF NOT EXISTS sync_commits(
41
41
  partition TEXT NOT NULL, commit_seq INTEGER NOT NULL,
42
42
  client_id TEXT NOT NULL, client_commit_id TEXT NOT NULL,
43
43
  actor_id TEXT NOT NULL, created_at_ms INTEGER NOT NULL,
44
+ writer_version INTEGER,
44
45
  PRIMARY KEY(partition, commit_seq)
45
46
  );
46
47
  CREATE INDEX IF NOT EXISTS sync_commits_by_time
@@ -102,6 +103,20 @@ CREATE TABLE IF NOT EXISTS sync_tombstones(
102
103
  commit_seq INTEGER NOT NULL,
103
104
  PRIMARY KEY(partition, tbl, row_id)
104
105
  );
106
+ CREATE TABLE IF NOT EXISTS sync_backfill_checkpoints(
107
+ partition TEXT NOT NULL, name TEXT NOT NULL,
108
+ schema_version INTEGER NOT NULL,
109
+ state TEXT NOT NULL CHECK(state IN ('declared','backfilling','activated')),
110
+ watermark INTEGER NOT NULL DEFAULT 0,
111
+ owner_epoch INTEGER NOT NULL DEFAULT 0,
112
+ observed_rows INTEGER NOT NULL DEFAULT 0,
113
+ updated_at_ms INTEGER NOT NULL,
114
+ PRIMARY KEY(partition, name)
115
+ );
116
+ CREATE TABLE IF NOT EXISTS sync_writer_fence(
117
+ partition TEXT PRIMARY KEY,
118
+ required_writer_version INTEGER NOT NULL
119
+ );
105
120
  `;
106
121
  /** Split the DDL into individual statements (D1 applies them one by one). */
107
122
  export function sqliteDdlStatements() {
@@ -1,14 +1,14 @@
1
1
  import type { CommitPruneQuery, CommitPruneResult } from './storage.js';
2
2
  import type { CompiledSchema, CompiledTable } from './schema.js';
3
3
  import { type SqliteDatabase } from './sqlite-driver.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
+ import type { AuthoritativeQueryRequest, AuthoritativeQueryResult, CheckpointDeclaration, ClientCursorInfo, ClientRecord, CommitMetadata, CommitMetadataQuery, CommitWindowQuery, IndexRowScanQuery, PartitionRegistryEntry, PrunedReactionCounts, ReactionClaimQuery, ReactionFailureUpdate, ReactionListQuery, ReactionPruneQuery, RowScanQuery, ScopeActivityQuery, ScopeCommitActivity, ServerStorage, ServeGate, StorageTransaction, StoredCommit, StoredCheckpoint, StoredPushResult, StoredReaction, StoredRow } from './storage.js';
5
5
  export declare class SqliteServerStorage implements ServerStorage {
6
6
  #private;
7
7
  readonly db: SqliteDatabase;
8
8
  constructor(db?: SqliteDatabase | string);
9
9
  /** Resolve a table's compiled schema; row operations require `ensureSchema`. */
10
10
  table(name: string): CompiledTable;
11
- ensureSchema(schema: CompiledSchema): Promise<void>;
11
+ ensureSchema(schema: CompiledSchema, checkpoints?: readonly CheckpointDeclaration[]): Promise<void>;
12
12
  touchPartition(partition: string, authenticatedAtMs: number, initialLogEpoch: string): Promise<PartitionRegistryEntry>;
13
13
  rotatePartitionLogEpoch(partition: string, logEpoch: string, authenticatedAtMs: number): Promise<PartitionRegistryEntry>;
14
14
  listPartitionRegistry(): Promise<PartitionRegistryEntry[]>;
@@ -16,12 +16,27 @@ export declare class SqliteServerStorage implements ServerStorage {
16
16
  /** Internal: write a row + refresh its scope-index entries. */
17
17
  writeRow(partition: string, table: string, row: StoredRow): void;
18
18
  getMaxCommitSeq(partition: string): Promise<number>;
19
- queryAuthoritative(partition: string, query: AuthoritativeQueryRequest): Promise<AuthoritativeQueryResult>;
19
+ /**
20
+ * Internal: rewrite and run one registered query on the shared connection.
21
+ * The caller owns the transaction: `queryAuthoritative` opens a read
22
+ * transaction behind the FIFO; `SqliteTransaction.queryAuthoritative` runs
23
+ * inside the push transaction's `BEGIN IMMEDIATE`.
24
+ */
25
+ runAuthoritativeQuery(partition: string, query: AuthoritativeQueryRequest): AuthoritativeQueryResult;
26
+ queryAuthoritative(partition: string, query: AuthoritativeQueryRequest, checkpoints?: readonly CheckpointDeclaration[]): Promise<AuthoritativeQueryResult>;
20
27
  getPartitionLogEpoch(partition: string): Promise<string | undefined>;
21
28
  getHorizonSeq(partition: string): Promise<number>;
22
29
  setHorizonSeq(partition: string, seq: number): Promise<void>;
23
30
  pruneCommitsThrough(partition: string, query: CommitPruneQuery): Promise<CommitPruneResult>;
24
31
  getCommitSeqBefore(partition: string, createdBeforeMs: number): Promise<number>;
32
+ readServeGate(partition: string, runningSchemaVersion: number): Promise<ServeGate>;
33
+ readCheckpoints(partition: string): Promise<StoredCheckpoint[]>;
34
+ declareCheckpoint(partition: string, name: string, schemaVersion: number, nowMs: number): Promise<StoredCheckpoint>;
35
+ activateCheckpoint(partition: string, name: string, ownerEpoch: number, watermark: number, sources: readonly string[], nowMs: number): Promise<'activated' | 'stale' | 'unverifiable'>;
36
+ claimCheckpoint(partition: string, name: string, schemaVersion: number, nowMs: number): Promise<StoredCheckpoint>;
37
+ advanceCheckpoint(partition: string, name: string, ownerEpoch: number, watermark: number, observedRows: number, nowMs: number): Promise<boolean>;
38
+ sourceCoverageSeq(partition: string, tables: readonly string[]): Promise<number>;
39
+ hasSourceChangesAbove(partition: string, tables: readonly string[], seq: number): Promise<'clean' | 'changed' | 'unverifiable'>;
25
40
  getRow(partition: string, table: string, rowId: string): Promise<StoredRow | undefined>;
26
41
  getPushResult(partition: string, clientId: string, clientCommitId: string): Promise<StoredPushResult | undefined>;
27
42
  claimReactions(partition: string, query: ReactionClaimQuery): Promise<StoredReaction[]>;