@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/README.md CHANGED
@@ -168,6 +168,18 @@ the stable code/details to localized copy instead of displaying that message.
168
168
 
169
169
  Validators are per-operation. A validator must not mutate the row it receives.
170
170
 
171
+ A validator that decides from other rows (a membership, a role, a grant) calls
172
+ `context.queryAuthoritative` with the request `storage.queryAuthoritative`
173
+ takes, built from a generated query descriptor. The storage binds every
174
+ relation to the commit's partition and runs the statement on the push
175
+ transaction's connection, so the read sees the operations staged before this
176
+ one and needs no second pool connection. Never call
177
+ `storage.queryAuthoritative` from a validator: on SQLite and PGlite it waits
178
+ for the push transaction and the push never completes, and on a pool it reads
179
+ committed state. The whole-commit reader, the reaction planner reader, and the
180
+ remote command context expose the same method. See
181
+ [Read a generated query inside a push transaction](https://syncular.dev/guide-remote-operations/#read-a-generated-query-inside-a-push-transaction).
182
+
171
183
  For a multi-row or multi-table invariant, install `commitValidator`. It runs
172
184
  once after every operation is staged in the same transaction, and can inspect
173
185
  both the decoded sibling operations and final candidate state:
@@ -215,8 +227,8 @@ const config: SyncServerConfig = {
215
227
  };
216
228
  ```
217
229
 
218
- The callback also receives `read.getRow()` and bounded `read.scanRows()` APIs.
219
- Those reads see the final candidate state, including staged sibling upserts and
230
+ The callback also receives `read.getRow()`, bounded `read.scanRows()`, and
231
+ `read.queryAuthoritative()` APIs. Those reads see the final candidate state, including staged sibling upserts and
220
232
  deletes. Syncular serializes the partition before any operation read so two
221
233
  aggregate validators cannot both accept mutually invalid candidates. It also
222
234
  re-checks idempotency after taking that lock and persists a rejected outcome
package/dist/admin.d.ts CHANGED
@@ -21,7 +21,7 @@ import type { LeaseRecord, LeaseStore } from './lease-store.js';
21
21
  import { type RetentionPolicy } from './prune.js';
22
22
  import { type ServerSchema } from './schema.js';
23
23
  import type { SegmentStore, SegmentStoreStats } from './segment-store.js';
24
- import type { CommitMetadata, ReactionStatus, ScopeCommitActivity, ServerStorage, StoredReaction } from './storage.js';
24
+ import type { CheckpointDeclaration, CommitMetadata, ReactionStatus, ScopeCommitActivity, ServerStorage, StoredReaction } from './storage.js';
25
25
  /** A connected/known client as the console sees it (§4.5, §8.1). */
26
26
  export interface AdminClient {
27
27
  readonly clientId: string;
@@ -139,6 +139,8 @@ export interface SyncularAdminOptions {
139
139
  * a storage instance that has not served a sync request yet.
140
140
  */
141
141
  readonly schema?: ServerSchema;
142
+ /** RFC 0007: declarations `inspectRow` carries into `ensureSchema`. */
143
+ readonly checkpoints?: readonly CheckpointDeclaration[];
142
144
  /** The event ring feeding the event tail. Absent ⇒ `events()` is empty. */
143
145
  readonly ring?: RingBufferEvents;
144
146
  readonly segments?: SegmentStore;
package/dist/admin.js CHANGED
@@ -41,6 +41,7 @@ function toAdminClient(record, active, maxCommitSeq) {
41
41
  export class SyncularAdmin {
42
42
  #storage;
43
43
  #schema;
44
+ #checkpoints;
44
45
  #ring;
45
46
  #segments;
46
47
  #blobs;
@@ -51,6 +52,8 @@ export class SyncularAdmin {
51
52
  this.#storage = options.storage;
52
53
  if (options.schema !== undefined)
53
54
  this.#schema = options.schema;
55
+ if (options.checkpoints !== undefined)
56
+ this.#checkpoints = options.checkpoints;
54
57
  if (options.ring !== undefined)
55
58
  this.#ring = options.ring;
56
59
  if (options.segments !== undefined)
@@ -71,6 +74,9 @@ export class SyncularAdmin {
71
74
  return new SyncularAdmin({
72
75
  storage: config.storage,
73
76
  schema: config.schema,
77
+ ...(config.checkpoints !== undefined
78
+ ? { checkpoints: config.checkpoints }
79
+ : {}),
74
80
  segments: config.segments,
75
81
  ...(config.blobs !== undefined ? { blobs: config.blobs } : {}),
76
82
  ...(config.leases !== undefined ? { leases: config.leases.store } : {}),
@@ -138,7 +144,7 @@ export class SyncularAdmin {
138
144
  async inspectRow(partition, table, rowId) {
139
145
  const read = required(this.#storage.getRowScopes?.bind(this.#storage), 'storage');
140
146
  if (this.#schema !== undefined) {
141
- await this.#storage.ensureSchema(compileSchema(this.#schema));
147
+ await this.#storage.ensureSchema(compileSchema(this.#schema), this.#checkpoints);
142
148
  }
143
149
  const row = await read(partition, table, rowId);
144
150
  if (row === undefined) {
@@ -149,7 +149,7 @@ async function downloadBlob(ctx, blobId) {
149
149
  const schema = compileSchema(ctx.schema);
150
150
  // A blob download can hit a fresh storage instance before any sync
151
151
  // request has run (Workers): make sure the row tables exist first.
152
- await ctx.storage.ensureSchema(schema);
152
+ await ctx.storage.ensureSchema(schema, ctx.checkpoints);
153
153
  const rows = await listRows.call(ctx.storage, ctx.partition, blobId);
154
154
  let authorized = false;
155
155
  for (const row of rows) {
package/dist/context.d.ts CHANGED
@@ -14,7 +14,7 @@ import type { ServerSchema } from './schema.js';
14
14
  import type { SegmentStore } from './segment-store.js';
15
15
  import type { BlobPresignConfig, BlobUploadPresignConfig, SegmentUrlConfig } from './signed-url.js';
16
16
  import type { SqliteImageBuilder } from './sqlite-image.js';
17
- import type { PartitionRegistryEntry, ServerStorage, StoredCommit } from './storage.js';
17
+ import type { CheckpointDeclaration, PartitionRegistryEntry, ServerStorage, StoredCommit } from './storage.js';
18
18
  import type { CommitValidator, ValidatorRegistry } from './validate.js';
19
19
  /** SSP2 body content type (§1.1). */
20
20
  export declare const SSP2_CONTENT_TYPE = "application/vnd.syncular.sync.v2";
@@ -72,6 +72,13 @@ export interface RealtimeNotifier {
72
72
  export interface SyncServerConfig {
73
73
  readonly schema: ServerSchema;
74
74
  readonly storage: ServerStorage;
75
+ /**
76
+ * RFC 0007: the backfill checkpoints this host installs. Every readiness
77
+ * path carries this set into `ensureSchema`. A process that omits it
78
+ * declares nothing, and the storage refuses to serve a partition whose
79
+ * stored state shows a checkpoint that is not activated.
80
+ */
81
+ readonly checkpoints?: readonly CheckpointDeclaration[];
75
82
  readonly segments: SegmentStore;
76
83
  /**
77
84
  * Blob store (§5.9). Absent ⇒ blobs unsupported: a table with a
@@ -1,6 +1,6 @@
1
1
  import type { CommitPruneQuery, CommitPruneResult } from './storage.js';
2
2
  import type { CompiledSchema, CompiledTable } from './schema.js';
3
- import type { AuthoritativeQueryRequest, AuthoritativeQueryResult, ClientCursorInfo, ClientRecord, CommitMetadata, CommitMetadataQuery, CommitWindowQuery, IndexRowScanQuery, PartitionRegistryEntry, PrunedReactionCounts, ReactionClaimQuery, ReactionFailureUpdate, ReactionListQuery, ReactionPruneQuery, RowScanQuery, ScopeActivityQuery, ScopeCommitActivity, ServerStorage, StorageTransaction, StoredCommit, StoredPushResult, StoredReaction, StoredRow } from './storage.js';
3
+ 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';
4
4
  export interface D1PreparedStatement {
5
5
  bind(...values: unknown[]): D1PreparedStatement;
6
6
  first<T = Record<string, unknown>>(): Promise<T | null>;
@@ -36,7 +36,7 @@ export declare class D1ServerStorage implements ServerStorage {
36
36
  migrate(): Promise<void>;
37
37
  /** Resolve a table's compiled schema; row operations require `ensureSchema`. */
38
38
  table(name: string): CompiledTable;
39
- ensureSchema(schema: CompiledSchema): Promise<void>;
39
+ ensureSchema(schema: CompiledSchema, checkpoints?: readonly CheckpointDeclaration[]): Promise<void>;
40
40
  /** Run once per Worker invocation. Resume an incomplete result in another invocation. */
41
41
  migrateSchema(schema: CompiledSchema, options?: {
42
42
  readonly maxStatements?: number;
@@ -49,12 +49,20 @@ export declare class D1ServerStorage implements ServerStorage {
49
49
  listPartitionRegistry(): Promise<PartitionRegistryEntry[]>;
50
50
  begin(partition: string): Promise<StorageTransaction>;
51
51
  getMaxCommitSeq(partition: string): Promise<number>;
52
- queryAuthoritative(partition: string, query: AuthoritativeQueryRequest): Promise<AuthoritativeQueryResult>;
52
+ queryAuthoritative(partition: string, query: AuthoritativeQueryRequest, _checkpoints?: readonly CheckpointDeclaration[]): Promise<AuthoritativeQueryResult>;
53
53
  getPartitionLogEpoch(partition: string): Promise<string | undefined>;
54
54
  getHorizonSeq(partition: string): Promise<number>;
55
55
  setHorizonSeq(partition: string, seq: number): Promise<void>;
56
56
  pruneCommitsThrough(partition: string, query: CommitPruneQuery): Promise<CommitPruneResult>;
57
57
  getCommitSeqBefore(partition: string, createdBeforeMs: number): Promise<number>;
58
+ readCheckpoints(_partition: string): Promise<StoredCheckpoint[]>;
59
+ readServeGate(partition: string, _runningSchemaVersion: number): Promise<ServeGate>;
60
+ declareCheckpoint(_partition: string, _name: string, _schemaVersion: number, _nowMs: number): Promise<StoredCheckpoint>;
61
+ activateCheckpoint(_partition: string, _name: string, _ownerEpoch: number, _watermark: number, _sources: readonly string[], _nowMs: number): Promise<'activated' | 'stale' | 'unverifiable'>;
62
+ claimCheckpoint(_partition: string, _name: string, _schemaVersion: number, _nowMs: number): Promise<StoredCheckpoint>;
63
+ advanceCheckpoint(_partition: string, _name: string, _ownerEpoch: number, _watermark: number, _observedRows: number, _nowMs: number): Promise<boolean>;
64
+ sourceCoverageSeq(partition: string, tables: readonly string[]): Promise<number>;
65
+ hasSourceChangesAbove(partition: string, tables: readonly string[], seq: number): Promise<'clean' | 'changed' | 'unverifiable'>;
58
66
  getRow(partition: string, table: string, rowId: string): Promise<StoredRow | undefined>;
59
67
  getPushResult(partition: string, clientId: string, clientCommitId: string): Promise<StoredPushResult | undefined>;
60
68
  claimReactions(partition: string, query: ReactionClaimQuery): Promise<StoredReaction[]>;
@@ -45,9 +45,9 @@ import { StorageQueryError } from './storage-errors.js';
45
45
  import { decodeRow } from '@syncular/core';
46
46
  import { bindAuthoritativePartition, prepareAuthoritativeQuery, } from './authoritative-query.js';
47
47
  import { syncError } from './errors.js';
48
- import { assertAppendOnlyMigration, commitWindowPageSql, deleteRowSql, deleteSqliteRowScopesSql, dropTableDdl, indexRowPageStatement, layoutsOf, migratePayload, parseLayouts, quoteIdent, retiredTableNames, rewritePlan, rewriteRowSql, rewriteValues, SCHEMA_META_DDL_SQLITE, scanRowPageSql, schemaDdl, selectRowScopesSql, selectRowSql, selectRowsForRewriteSql, tableColumnNames, toSqlValue, upsertSql, upsertValues, } from './relational-rows.js';
48
+ import { assertAppendOnlyMigration, assertPhysicalColumns, assertStoredLayouts, commitWindowPageSql, deleteRowSql, deleteSqliteRowScopesSql, dropTableDdl, indexRowPageStatement, layoutsOf, migratePayload, parseLayouts, quoteIdent, retiredTableNames, rewritePlan, rewriteRowSql, rewriteValues, SCHEMA_META_DDL_SQLITE, scanRowPageSql, schemaDdl, selectRowScopesSql, selectRowSql, selectRowsForRewriteSql, tableColumnNames, toSqlValue, upsertSql, upsertValues, } from './relational-rows.js';
49
49
  import { matchesEffective } from './scopes.js';
50
- import { asUint8Array, collectCommitWindowPage, deserializePushResult, serializePushResult, sqliteDdlStatements, toStoredRow, } from './sqlite-dialect.js';
50
+ import { asUint8Array, collectCommitWindowPage, deserializePushResult, placeholders, serializePushResult, sqliteDdlStatements, toStoredRow, } from './sqlite-dialect.js';
51
51
  import { isD1ConstraintError, StorageConstraintError } from './storage-errors.js';
52
52
  import { assertScopeIndexedScan, resolveIndexRowScan } from './storage-query.js';
53
53
  function toStoredReaction(record) {
@@ -83,10 +83,44 @@ function relationalValuesEqual(left, right) {
83
83
  }
84
84
  return Object.is(left, right);
85
85
  }
86
+ /** Run one partition-bound registered query and the cursor read as one batch. */
87
+ async function queryAuthoritativeOn(db, partition, prepared) {
88
+ const results = await db.batch([
89
+ db.prepare(prepared.sql).bind(...prepared.params),
90
+ db
91
+ .prepare('SELECT max_commit_seq FROM sync_partitions WHERE partition=?')
92
+ .bind(partition),
93
+ ]);
94
+ const rowsResult = results[0];
95
+ const cursorResult = results[1];
96
+ if (typeof rowsResult !== 'object' ||
97
+ rowsResult === null ||
98
+ !('results' in rowsResult) ||
99
+ !Array.isArray(rowsResult.results) ||
100
+ typeof cursorResult !== 'object' ||
101
+ cursorResult === null ||
102
+ !('results' in cursorResult) ||
103
+ !Array.isArray(cursorResult.results)) {
104
+ throw new Error('D1 registered query returned an invalid batch result');
105
+ }
106
+ const cursor = cursorResult.results[0];
107
+ const maxCommitSeq = typeof cursor === 'object' &&
108
+ cursor !== null &&
109
+ 'max_commit_seq' in cursor &&
110
+ typeof cursor.max_commit_seq === 'number'
111
+ ? cursor.max_commit_seq
112
+ : 0;
113
+ return {
114
+ rows: rowsResult.results.filter((row) => typeof row === 'object' && row !== null),
115
+ maxCommitSeq,
116
+ };
117
+ }
86
118
  class D1Transaction {
87
119
  #db;
88
120
  #partition;
89
121
  #resolveTable;
122
+ /** Storage-owned relation rewrite, bound to this transaction's partition. */
123
+ #prepareQuery;
90
124
  #pushApplySerialized;
91
125
  #buffer = [];
92
126
  #open = true;
@@ -110,10 +144,11 @@ class D1Transaction {
110
144
  * `tbl\u0000rowId` → the pending state, consulted by `getRow` before D1.
111
145
  */
112
146
  #pending = new Map();
113
- constructor(db, partition, resolveTable, pushApplySerialized) {
147
+ constructor(db, partition, resolveTable, prepareQuery, pushApplySerialized) {
114
148
  this.#db = db;
115
149
  this.#partition = partition;
116
150
  this.#resolveTable = resolveTable;
151
+ this.#prepareQuery = prepareQuery;
117
152
  this.#pushApplySerialized = pushApplySerialized;
118
153
  }
119
154
  #assertOpen() {
@@ -255,6 +290,18 @@ class D1Transaction {
255
290
  .sort((left, right) => left.rowId.localeCompare(right.rowId))
256
291
  .slice(0, query.limit);
257
292
  }
293
+ async queryAuthoritative(query) {
294
+ this.#assertOpen();
295
+ // D1 buffers writes until commit() and cannot overlay them on generated
296
+ // SQL, so a read over a written table would return committed state
297
+ // without this transaction's staged rows. Refuse it instead.
298
+ for (const key of this.#pending.keys()) {
299
+ if (query.tables.includes(key.slice(0, key.indexOf('\u0000')))) {
300
+ throw new StorageQueryError('sync.storage.query_over_staged_writes');
301
+ }
302
+ }
303
+ return queryAuthoritativeOn(this.#db, this.#partition, this.#prepareQuery(query));
304
+ }
258
305
  async lockPartitionForPush() {
259
306
  this.#assertOpen();
260
307
  if (!this.#pushApplySerialized) {
@@ -264,6 +311,21 @@ class D1Transaction {
264
311
  // write for this partition is already serialized (normally by its DO).
265
312
  this.#pushApplyCheckpoint = this.#buffer.length;
266
313
  }
314
+ advanceCheckpoint(_name, _ownerEpoch, _watermark, _observedRows, _nowMs) {
315
+ // D1 installs no fence and declares no checkpoints in this release.
316
+ return Promise.reject(new StorageQueryError('sync.storage.checkpoint_unsupported'));
317
+ }
318
+ async readServeGate(_runningSchemaVersion) {
319
+ this.#assertOpen();
320
+ const marker = await this.#db
321
+ .prepare('SELECT schema_version FROM sync_schema_meta WHERE id=1')
322
+ .first();
323
+ return {
324
+ storedSchemaVersion: marker?.schema_version ?? 0,
325
+ logEpoch: undefined,
326
+ pending: [],
327
+ };
328
+ }
267
329
  async commitRejectedPushResult(clientId, clientCommitId, result) {
268
330
  this.#assertOpen();
269
331
  const checkpoint = this.#pushApplyCheckpoint;
@@ -495,6 +557,26 @@ _a = D1Transaction;
495
557
  * binds one per app column plus the five `_sync_*` meta columns.
496
558
  */
497
559
  const D1_MAX_BIND_PARAMS = 100;
560
+ /**
561
+ * The core tables `D1ServerStorage` reads or writes outside schema migration.
562
+ * `migrateSchema` creates them in its core phase, which runs only when the
563
+ * version marker advances, so a same-version open refuses a database missing
564
+ * one. `sync_backfill_checkpoints` and `sync_writer_fence` stay out: D1
565
+ * declares no checkpoints and installs no fence.
566
+ */
567
+ const D1_CORE_TABLES = [
568
+ 'sync_partitions',
569
+ 'sync_partition_registry',
570
+ 'sync_row_scopes',
571
+ 'sync_commits',
572
+ 'sync_changes',
573
+ 'sync_change_scopes',
574
+ 'sync_push_results',
575
+ 'sync_reactions',
576
+ 'sync_clients',
577
+ 'sync_blob_refs',
578
+ 'sync_tombstones',
579
+ ];
498
580
  export class D1ServerStorage {
499
581
  #db;
500
582
  #pushApplySerialized;
@@ -527,7 +609,12 @@ export class D1ServerStorage {
527
609
  }
528
610
  return table;
529
611
  }
530
- async ensureSchema(schema) {
612
+ async ensureSchema(schema, checkpoints) {
613
+ if (checkpoints !== undefined && checkpoints.length > 0) {
614
+ // No database-side fence can be installed atomically with schema
615
+ // visibility here; refuse instead of accepting a weaker guarantee.
616
+ throw new StorageQueryError('sync.storage.checkpoint_unsupported');
617
+ }
531
618
  if (this.#schemaVersion === schema.version) {
532
619
  await this.#schemaDatabase().batch([]);
533
620
  return;
@@ -582,6 +669,33 @@ export class D1ServerStorage {
582
669
  const marker = await read(this.#db.prepare('SELECT schema_version, layouts FROM sync_schema_meta WHERE id=1'));
583
670
  let claim = await read(this.#db.prepare('SELECT * FROM sync_schema_migration WHERE id=1'));
584
671
  if (claim === null && marker?.schema_version === schema.version) {
672
+ assertStoredLayouts(schema, marker.layouts);
673
+ statementsExecuted++;
674
+ const present = await this.#db
675
+ .prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name IN (${placeholders(D1_CORE_TABLES.length)})`)
676
+ .bind(...D1_CORE_TABLES)
677
+ .all();
678
+ const presentNames = new Set(present.results.map((row) => row.name));
679
+ for (const name of D1_CORE_TABLES) {
680
+ if (!presentNames.has(name)) {
681
+ throw new StorageQueryError('sync.storage.physical_layout_mismatch', {
682
+ table: name,
683
+ reason: 'missing_table',
684
+ });
685
+ }
686
+ }
687
+ for (const table of tables) {
688
+ statementsExecuted++;
689
+ const { results } = await this.#db
690
+ .prepare(`PRAGMA table_info(${quoteIdent(table.name)})`)
691
+ .all();
692
+ assertPhysicalColumns(table, results.map((column) => ({
693
+ name: column.name,
694
+ type: column.type,
695
+ notNull: column.notnull === 1,
696
+ primaryKeyPosition: column.pk,
697
+ })), 'sqlite');
698
+ }
585
699
  this.#tables = schema.tables;
586
700
  this.#schemaVersion = schema.version;
587
701
  return { complete: true, statementsExecuted };
@@ -963,7 +1077,7 @@ export class D1ServerStorage {
963
1077
  if (table === undefined)
964
1078
  throw new StorageQueryError('sync.storage.schema_changed');
965
1079
  return table;
966
- }, this.#pushApplySerialized);
1080
+ }, (query) => this.#prepareAuthoritativeQuery(partition, query), this.#pushApplySerialized);
967
1081
  }
968
1082
  async getMaxCommitSeq(partition) {
969
1083
  const row = await this.#db
@@ -972,41 +1086,18 @@ export class D1ServerStorage {
972
1086
  .first();
973
1087
  return row?.max_commit_seq ?? 0;
974
1088
  }
975
- async queryAuthoritative(partition, query) {
1089
+ async queryAuthoritative(partition, query, _checkpoints) {
1090
+ // D1 declares no checkpoints. `#schemaDatabase` prepends a guard that
1091
+ // re-checks the published marker inside this same batch, so condition 2
1092
+ // is already evaluated with the query (§2.4 D1 schema readiness).
976
1093
  const db = this.#schemaDatabase();
1094
+ return queryAuthoritativeOn(db, partition, this.#prepareAuthoritativeQuery(partition, query));
1095
+ }
1096
+ #prepareAuthoritativeQuery(partition, query) {
977
1097
  if (this.#tables === undefined) {
978
1098
  throw new Error('ensureSchema(schema) must run before registered queries');
979
1099
  }
980
- const prepared = bindAuthoritativePartition(prepareAuthoritativeQuery(query.plan, query.params, query.tables, this.#tables), partition);
981
- const results = await db.batch([
982
- db.prepare(prepared.sql).bind(...prepared.params),
983
- db
984
- .prepare('SELECT max_commit_seq FROM sync_partitions WHERE partition=?')
985
- .bind(partition),
986
- ]);
987
- const rowsResult = results[0];
988
- const cursorResult = results[1];
989
- if (typeof rowsResult !== 'object' ||
990
- rowsResult === null ||
991
- !('results' in rowsResult) ||
992
- !Array.isArray(rowsResult.results) ||
993
- typeof cursorResult !== 'object' ||
994
- cursorResult === null ||
995
- !('results' in cursorResult) ||
996
- !Array.isArray(cursorResult.results)) {
997
- throw new Error('D1 registered query returned an invalid batch result');
998
- }
999
- const cursor = cursorResult.results[0];
1000
- const maxCommitSeq = typeof cursor === 'object' &&
1001
- cursor !== null &&
1002
- 'max_commit_seq' in cursor &&
1003
- typeof cursor.max_commit_seq === 'number'
1004
- ? cursor.max_commit_seq
1005
- : 0;
1006
- return {
1007
- rows: rowsResult.results.filter((row) => typeof row === 'object' && row !== null),
1008
- maxCommitSeq,
1009
- };
1100
+ return bindAuthoritativePartition(prepareAuthoritativeQuery(query.plan, query.params, query.tables, this.#tables), partition);
1010
1101
  }
1011
1102
  async getPartitionLogEpoch(partition) {
1012
1103
  const row = await this.#db
@@ -1086,6 +1177,65 @@ export class D1ServerStorage {
1086
1177
  .first();
1087
1178
  return row?.seq ?? 0;
1088
1179
  }
1180
+ async readCheckpoints(_partition) {
1181
+ // D1 supports no declared backfill checkpoints in this release: migrations
1182
+ // run out of band and no database-side fence can be installed atomically
1183
+ // with schema visibility. No checkpoint row can exist.
1184
+ return [];
1185
+ }
1186
+ async readServeGate(partition, _runningSchemaVersion) {
1187
+ // D1 has no checkpoints. The published marker is the only gate input;
1188
+ // `#schemaDatabase` already re-checks it per protected batch.
1189
+ const marker = await this.#db
1190
+ .prepare('SELECT schema_version FROM sync_schema_meta WHERE id=1')
1191
+ .first();
1192
+ return {
1193
+ storedSchemaVersion: marker?.schema_version ?? 0,
1194
+ logEpoch: await this.getPartitionLogEpoch(partition),
1195
+ pending: [],
1196
+ };
1197
+ }
1198
+ async declareCheckpoint(_partition, _name, _schemaVersion, _nowMs) {
1199
+ throw new StorageQueryError('sync.storage.checkpoint_unsupported');
1200
+ }
1201
+ async activateCheckpoint(_partition, _name, _ownerEpoch, _watermark, _sources, _nowMs) {
1202
+ throw new StorageQueryError('sync.storage.checkpoint_unsupported');
1203
+ }
1204
+ async claimCheckpoint(_partition, _name, _schemaVersion, _nowMs) {
1205
+ throw new StorageQueryError('sync.storage.checkpoint_unsupported');
1206
+ }
1207
+ async advanceCheckpoint(_partition, _name, _ownerEpoch, _watermark, _observedRows, _nowMs) {
1208
+ throw new StorageQueryError('sync.storage.checkpoint_unsupported');
1209
+ }
1210
+ async sourceCoverageSeq(partition, tables) {
1211
+ if (tables.length === 0)
1212
+ return 0;
1213
+ const tableParams = tables.map(() => '?').join(',');
1214
+ const row = await this.#db
1215
+ .prepare(`SELECT max(commit_seq) AS seq FROM sync_changes
1216
+ WHERE partition=? AND tbl IN (${tableParams})`)
1217
+ .bind(partition, ...tables)
1218
+ .first();
1219
+ return row?.seq ?? 0;
1220
+ }
1221
+ async hasSourceChangesAbove(partition, tables, seq) {
1222
+ // Read the horizon before the window scan: a horizon past `seq` means the
1223
+ // history that would answer the question is gone, so an empty scan proves
1224
+ // nothing and must not be reported as `clean`.
1225
+ const horizon = await this.getHorizonSeq(partition);
1226
+ if (tables.length === 0)
1227
+ return 'clean';
1228
+ const tableParams = tables.map(() => '?').join(',');
1229
+ const hit = await this.#db
1230
+ .prepare(`SELECT 1 AS hit FROM sync_changes
1231
+ WHERE partition=? AND tbl IN (${tableParams}) AND commit_seq>?
1232
+ LIMIT 1`)
1233
+ .bind(partition, ...tables, seq)
1234
+ .first();
1235
+ if (hit !== null)
1236
+ return 'changed';
1237
+ return horizon > seq ? 'unverifiable' : 'clean';
1238
+ }
1089
1239
  async getRow(partition, table, rowId) {
1090
1240
  const db = this.#schemaDatabase();
1091
1241
  const record = await db
package/dist/errors.js CHANGED
@@ -159,6 +159,16 @@ export const ERROR_CATALOG = {
159
159
  recommendedAction: 'retryLater',
160
160
  httpStatus: 500,
161
161
  },
162
+ // §2.4 on a non-D1 backend: the server refuses while a declared backfill
163
+ // checkpoint is incomplete or the database is newer than the running build.
164
+ // Formerly host-only; promoted to the §10.2 catalogue so an in-flight
165
+ // request can be refused with a distinguishable identity.
166
+ 'sync.schema_not_ready': {
167
+ category: 'internal',
168
+ retryable: true,
169
+ recommendedAction: 'retryLater',
170
+ httpStatus: 503,
171
+ },
162
172
  'sync.too_many_operations': {
163
173
  category: 'invalid-request',
164
174
  retryable: false,
package/dist/handler.js CHANGED
@@ -16,8 +16,11 @@ import { emitEvent, } from './events.js';
16
16
  import { END_FRAME_BYTES, encodeResponseFrame, responseEnvelopeHeader, } from './frame-bytes.js';
17
17
  import { ACCEPT_EXTERNAL_ROWS, ACCEPT_INLINE_ROWS, clampPullLimits, subscriptionSection, } from './pull.js';
18
18
  import { processPushCommitWithTrace } from './push.js';
19
+ import { serveNotReadyError } from './readiness.js';
19
20
  import { compileSchema } from './schema.js';
20
21
  import { computeEffective } from './scopes.js';
22
+ import { StorageQueryError } from './storage-errors.js';
23
+ import { serveGateRefusal, serveGateTokenChanged, } from './storage.js';
21
24
  function validateResolvedKeys(allowed, schema) {
22
25
  for (const key of Object.keys(allowed)) {
23
26
  if (!schema.declaredVariables.has(key)) {
@@ -500,13 +503,56 @@ async function createStreamCore(bytes, ctx, events, startedAtMs = 0) {
500
503
  const schema = compileSchema(ctx.schema);
501
504
  // Relational row tables: create/
502
505
  // migrate on first contact; memoized per storage instance thereafter.
503
- await ctx.storage.ensureSchema(schema);
506
+ try {
507
+ await ctx.storage.ensureSchema(schema, ctx.checkpoints);
508
+ }
509
+ catch (error) {
510
+ if (error instanceof StorageQueryError &&
511
+ error.code === 'sync.storage.checkpoint_incomplete') {
512
+ const refusal = serveGateRefusal(await ctx.storage.readServeGate(ctx.partition, schema.version), schema.version, ctx.checkpoints);
513
+ throw refusal !== undefined ? serveNotReadyError(refusal) : error;
514
+ }
515
+ throw error;
516
+ }
517
+ // RFC 0007 serve gate: re-read stored state on every request so an
518
+ // already-serving process notices a migration or a newly declared
519
+ // checkpoint. Read after `touchAuthenticatedPartition` establishes the
520
+ // partition's log epoch, so the entry token is the epoch this request
521
+ // serves under. Nothing has escaped at this point; the push transaction
522
+ // re-evaluates the gate under the partition lock.
504
523
  const registry = await touchAuthenticatedPartition(ctx);
524
+ const gate = await ctx.storage.readServeGate(ctx.partition, schema.version);
525
+ const refusal = serveGateRefusal(gate, schema.version, ctx.checkpoints);
526
+ if (refusal !== undefined)
527
+ throw serveNotReadyError(refusal);
505
528
  const plan = await planRequest(request, ctx, schema, registry);
506
- if (events === undefined)
507
- return streamResponse(plan, ctx, schema);
508
529
  const report = { outcome: 'ok' };
509
- return instrumentedStream(streamResponse(plan, ctx, schema, report), ctx, events, plan, report, bytes.length, startedAtMs);
530
+ // RFC 0007 read-verify-refuse for the streamed read path: the generator
531
+ // builds the whole response (its data reads) before yielding the first
532
+ // frame, then compares the gate token across those reads. Nothing escapes
533
+ // before the verification, and the buffered size is bounded by the request's
534
+ // pull limits and the server's inline segment cap. Every request verifies,
535
+ // including a mixed push+pull: a write that already applied when the token
536
+ // changed is durable and replayable under the same commit id / idempotency
537
+ // key, so refusing the request is safe (proved in the serve-gate lane).
538
+ const verified = async function* () {
539
+ const buffered = [];
540
+ for await (const chunk of streamResponse(plan, ctx, schema, report)) {
541
+ buffered.push(chunk);
542
+ }
543
+ const after = await ctx.storage.readServeGate(ctx.partition, schema.version);
544
+ const changed = serveGateTokenChanged(gate, after);
545
+ const afterRefusal = serveGateRefusal(after, schema.version, ctx.checkpoints);
546
+ if (afterRefusal !== undefined)
547
+ throw serveNotReadyError(afterRefusal);
548
+ if (changed !== undefined)
549
+ throw serveNotReadyError(changed);
550
+ for (const chunk of buffered)
551
+ yield chunk;
552
+ };
553
+ if (events === undefined)
554
+ return verified();
555
+ return instrumentedStream(verified(), ctx, events, plan, report, bytes.length, startedAtMs);
510
556
  }
511
557
  /** Counts response bytes and emits `request.handled` when the stream
512
558
  * finishes — including a thrown host failure mid-stream (fail loud). */
@@ -1,7 +1,7 @@
1
1
  import { type AuthoritativeRelationPlan } from './authoritative-query.js';
2
2
  import { type RemoteOperationResponse, type RowValue } from '@syncular/core';
3
3
  import type { SyncRequestContext } from './context.js';
4
- import type { AuthoritativeQueryValue } from './storage.js';
4
+ import type { AuthoritativeQueryRequest, AuthoritativeQueryResult, AuthoritativeQueryValue } from './storage.js';
5
5
  import { type ValidateRow } from './validate.js';
6
6
  export interface RemoteQueryDependency {
7
7
  readonly table: string;
@@ -63,6 +63,12 @@ export interface RemoteCommandContext {
63
63
  readonly operationId: string;
64
64
  readonly requestId: string;
65
65
  getRow(table: string, rowId: string): Promise<ValidateRow | undefined>;
66
+ /**
67
+ * Run a generated registered query on the command's push transaction
68
+ * (§6.7). The storage binds every relation to the partition; unlike
69
+ * `getRow`, the rows are not filtered by the actor's scopes.
70
+ */
71
+ queryAuthoritative(query: AuthoritativeQueryRequest): Promise<AuthoritativeQueryResult>;
66
72
  }
67
73
  export interface RemoteCommandOptions<Input> {
68
74
  readonly authorize: (context: RemoteOperationAuthContext, input: Input) => boolean | Promise<boolean>;
@@ -5,6 +5,7 @@ import { SyncError, syncError } from './errors.js';
5
5
  import { processPushOperationsWithTrace } from './push.js';
6
6
  import { compileSchema } from './schema.js';
7
7
  import { authorizeWrite } from './scopes.js';
8
+ import { transactionQuery } from './storage-query.js';
8
9
  import { toValidateRow } from './validate.js';
9
10
  function scopeAllowed(allowed, variable, value) {
10
11
  const values = allowed[variable];
@@ -185,7 +186,7 @@ export function registerRemoteQuery(descriptor, options) {
185
186
  if (plan === undefined) {
186
187
  throw syncError('operation.invalid_request', 'selected SQL has no generated relation plan; regenerate queries');
187
188
  }
188
- await ctx.storage.ensureSchema(schema);
189
+ await ctx.storage.ensureSchema(schema, ctx.checkpoints);
189
190
  const prefix = 'SELECT * FROM (';
190
191
  let result;
191
192
  try {
@@ -200,7 +201,7 @@ export function registerRemoteQuery(descriptor, options) {
200
201
  },
201
202
  params: [...descriptor.bind(params), options.maxRows + 1],
202
203
  tables: descriptor.tables,
203
- });
204
+ }, ctx.checkpoints);
204
205
  }
205
206
  catch (error) {
206
207
  if (error instanceof SyncError)
@@ -293,6 +294,7 @@ function commandContext(ctx, tx, resolved, schema, clientId, operationId, reques
293
294
  }
294
295
  return toValidateRow(table.columns, decodeRow(table.columns, stored.payload));
295
296
  },
297
+ queryAuthoritative: (query) => transactionQuery(tx, query),
296
298
  };
297
299
  }
298
300
  /** Register custom command code that plans one ordinary Syncular commit. */
@@ -318,7 +320,7 @@ export function registerRemoteCommand(descriptor, options) {
318
320
  }
319
321
  const resolved = { ok: true, allowed };
320
322
  const schema = compileSchema(ctx.schema);
321
- await ctx.storage.ensureSchema(schema);
323
+ await ctx.storage.ensureSchema(schema, ctx.checkpoints);
322
324
  const processed = await processPushOperationsWithTrace(ctx, schema, resolved, JSON.stringify(['remote-command', ctx.actorId, clientId]), JSON.stringify([descriptor.id, requestId]), async (tx) => commandOperations(await options.run(commandContext(ctx, tx, resolved, schema, clientId, descriptor.id, requestId), input), schema));
323
325
  return {
324
326
  revision: 1,