@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/d1-storage.ts CHANGED
@@ -45,11 +45,14 @@ import type { CommitPruneQuery, CommitPruneResult } from './storage';
45
45
  import { decodeRow, type RowValue } from '@syncular/core';
46
46
  import {
47
47
  bindAuthoritativePartition,
48
+ type BoundAuthoritativeQuery,
48
49
  prepareAuthoritativeQuery,
49
50
  } from './authoritative-query';
50
51
  import { syncError } from './errors';
51
52
  import {
52
53
  assertAppendOnlyMigration,
54
+ assertPhysicalColumns,
55
+ assertStoredLayouts,
53
56
  commitWindowPageSql,
54
57
  deleteRowSql,
55
58
  deleteSqliteRowScopesSql,
@@ -81,6 +84,7 @@ import {
81
84
  asUint8Array,
82
85
  collectCommitWindowPage,
83
86
  deserializePushResult,
87
+ placeholders,
84
88
  type SqliteCommitWindowRecord,
85
89
  type SqliteRowRecord,
86
90
  serializePushResult,
@@ -90,6 +94,7 @@ import {
90
94
  import type {
91
95
  AuthoritativeQueryRequest,
92
96
  AuthoritativeQueryResult,
97
+ CheckpointDeclaration,
93
98
  ClientCursorInfo,
94
99
  ClientRecord,
95
100
  ClientSubscription,
@@ -111,8 +116,10 @@ import type {
111
116
  ScopeActivityQuery,
112
117
  ScopeCommitActivity,
113
118
  ServerStorage,
119
+ ServeGate,
114
120
  StorageTransaction,
115
121
  StoredCommit,
122
+ StoredCheckpoint,
116
123
  StoredPushResult,
117
124
  StoredReaction,
118
125
  StoredRow,
@@ -199,6 +206,49 @@ function relationalValuesEqual(left: RowValue, right: RowValue): boolean {
199
206
  return Object.is(left, right);
200
207
  }
201
208
 
209
+ /** Run one partition-bound registered query and the cursor read as one batch. */
210
+ async function queryAuthoritativeOn(
211
+ db: Pick<D1Database, 'prepare' | 'batch'>,
212
+ partition: string,
213
+ prepared: BoundAuthoritativeQuery,
214
+ ): Promise<AuthoritativeQueryResult> {
215
+ const results = await db.batch([
216
+ db.prepare(prepared.sql).bind(...prepared.params),
217
+ db
218
+ .prepare('SELECT max_commit_seq FROM sync_partitions WHERE partition=?')
219
+ .bind(partition),
220
+ ]);
221
+ const rowsResult = results[0];
222
+ const cursorResult = results[1];
223
+ if (
224
+ typeof rowsResult !== 'object' ||
225
+ rowsResult === null ||
226
+ !('results' in rowsResult) ||
227
+ !Array.isArray(rowsResult.results) ||
228
+ typeof cursorResult !== 'object' ||
229
+ cursorResult === null ||
230
+ !('results' in cursorResult) ||
231
+ !Array.isArray(cursorResult.results)
232
+ ) {
233
+ throw new Error('D1 registered query returned an invalid batch result');
234
+ }
235
+ const cursor = cursorResult.results[0];
236
+ const maxCommitSeq =
237
+ typeof cursor === 'object' &&
238
+ cursor !== null &&
239
+ 'max_commit_seq' in cursor &&
240
+ typeof cursor.max_commit_seq === 'number'
241
+ ? cursor.max_commit_seq
242
+ : 0;
243
+ return {
244
+ rows: rowsResult.results.filter(
245
+ (row): row is Readonly<Record<string, unknown>> =>
246
+ typeof row === 'object' && row !== null,
247
+ ),
248
+ maxCommitSeq,
249
+ };
250
+ }
251
+
202
252
  /** Read-your-own-writes overlay entry: a buffered upsert, or a deletion. */
203
253
  type PendingRow =
204
254
  | { readonly kind: 'row'; readonly row: StoredRow }
@@ -210,6 +260,10 @@ class D1Transaction implements StorageTransaction {
210
260
  readonly #db: Pick<D1Database, 'prepare' | 'batch'>;
211
261
  readonly #partition: string;
212
262
  readonly #resolveTable: (name: string) => CompiledTable;
263
+ /** Storage-owned relation rewrite, bound to this transaction's partition. */
264
+ readonly #prepareQuery: (
265
+ query: AuthoritativeQueryRequest,
266
+ ) => BoundAuthoritativeQuery;
213
267
  readonly #pushApplySerialized: boolean;
214
268
  readonly #buffer: BufferedStatement[] = [];
215
269
  #open = true;
@@ -238,11 +292,13 @@ class D1Transaction implements StorageTransaction {
238
292
  db: Pick<D1Database, 'prepare' | 'batch'>,
239
293
  partition: string,
240
294
  resolveTable: (name: string) => CompiledTable,
295
+ prepareQuery: (query: AuthoritativeQueryRequest) => BoundAuthoritativeQuery,
241
296
  pushApplySerialized: boolean,
242
297
  ) {
243
298
  this.#db = db;
244
299
  this.#partition = partition;
245
300
  this.#resolveTable = resolveTable;
301
+ this.#prepareQuery = prepareQuery;
246
302
  this.#pushApplySerialized = pushApplySerialized;
247
303
  }
248
304
 
@@ -435,6 +491,25 @@ class D1Transaction implements StorageTransaction {
435
491
  .slice(0, query.limit);
436
492
  }
437
493
 
494
+ async queryAuthoritative(
495
+ query: AuthoritativeQueryRequest,
496
+ ): Promise<AuthoritativeQueryResult> {
497
+ this.#assertOpen();
498
+ // D1 buffers writes until commit() and cannot overlay them on generated
499
+ // SQL, so a read over a written table would return committed state
500
+ // without this transaction's staged rows. Refuse it instead.
501
+ for (const key of this.#pending.keys()) {
502
+ if (query.tables.includes(key.slice(0, key.indexOf('\u0000')))) {
503
+ throw new StorageQueryError('sync.storage.query_over_staged_writes');
504
+ }
505
+ }
506
+ return queryAuthoritativeOn(
507
+ this.#db,
508
+ this.#partition,
509
+ this.#prepareQuery(query),
510
+ );
511
+ }
512
+
438
513
  async lockPartitionForPush(): Promise<void> {
439
514
  this.#assertOpen();
440
515
  if (!this.#pushApplySerialized) {
@@ -447,6 +522,31 @@ class D1Transaction implements StorageTransaction {
447
522
  this.#pushApplyCheckpoint = this.#buffer.length;
448
523
  }
449
524
 
525
+ advanceCheckpoint(
526
+ _name: string,
527
+ _ownerEpoch: number,
528
+ _watermark: number,
529
+ _observedRows: number,
530
+ _nowMs: number,
531
+ ): Promise<boolean> {
532
+ // D1 installs no fence and declares no checkpoints in this release.
533
+ return Promise.reject(
534
+ new StorageQueryError('sync.storage.checkpoint_unsupported'),
535
+ );
536
+ }
537
+
538
+ async readServeGate(_runningSchemaVersion: number): Promise<ServeGate> {
539
+ this.#assertOpen();
540
+ const marker = await this.#db
541
+ .prepare('SELECT schema_version FROM sync_schema_meta WHERE id=1')
542
+ .first<{ schema_version: number }>();
543
+ return {
544
+ storedSchemaVersion: marker?.schema_version ?? 0,
545
+ logEpoch: undefined,
546
+ pending: [],
547
+ };
548
+ }
549
+
450
550
  async commitRejectedPushResult(
451
551
  clientId: string,
452
552
  clientCommitId: string,
@@ -777,6 +877,27 @@ export interface D1ServerStorageOptions {
777
877
  readonly commitValidationSerialized?: boolean;
778
878
  }
779
879
 
880
+ /**
881
+ * The core tables `D1ServerStorage` reads or writes outside schema migration.
882
+ * `migrateSchema` creates them in its core phase, which runs only when the
883
+ * version marker advances, so a same-version open refuses a database missing
884
+ * one. `sync_backfill_checkpoints` and `sync_writer_fence` stay out: D1
885
+ * declares no checkpoints and installs no fence.
886
+ */
887
+ const D1_CORE_TABLES = [
888
+ 'sync_partitions',
889
+ 'sync_partition_registry',
890
+ 'sync_row_scopes',
891
+ 'sync_commits',
892
+ 'sync_changes',
893
+ 'sync_change_scopes',
894
+ 'sync_push_results',
895
+ 'sync_reactions',
896
+ 'sync_clients',
897
+ 'sync_blob_refs',
898
+ 'sync_tombstones',
899
+ ] as const;
900
+
780
901
  interface D1MigrationState {
781
902
  phase: 'core' | 'prepare' | 'ddl' | 'rewrite';
782
903
  offset: number;
@@ -834,7 +955,15 @@ export class D1ServerStorage implements ServerStorage {
834
955
  return table;
835
956
  }
836
957
 
837
- async ensureSchema(schema: CompiledSchema): Promise<void> {
958
+ async ensureSchema(
959
+ schema: CompiledSchema,
960
+ checkpoints?: readonly CheckpointDeclaration[],
961
+ ): Promise<void> {
962
+ if (checkpoints !== undefined && checkpoints.length > 0) {
963
+ // No database-side fence can be installed atomically with schema
964
+ // visibility here; refuse instead of accepting a weaker guarantee.
965
+ throw new StorageQueryError('sync.storage.checkpoint_unsupported');
966
+ }
838
967
  if (this.#schemaVersion === schema.version) {
839
968
  await this.#schemaDatabase().batch([]);
840
969
  return;
@@ -910,6 +1039,39 @@ export class D1ServerStorage implements ServerStorage {
910
1039
  this.#db.prepare('SELECT * FROM sync_schema_migration WHERE id=1'),
911
1040
  );
912
1041
  if (claim === null && marker?.schema_version === schema.version) {
1042
+ assertStoredLayouts(schema, marker.layouts);
1043
+ statementsExecuted++;
1044
+ const present = await this.#db
1045
+ .prepare(
1046
+ `SELECT name FROM sqlite_master WHERE type='table' AND name IN (${placeholders(D1_CORE_TABLES.length)})`,
1047
+ )
1048
+ .bind(...D1_CORE_TABLES)
1049
+ .all<{ name: string }>();
1050
+ const presentNames = new Set(present.results.map((row) => row.name));
1051
+ for (const name of D1_CORE_TABLES) {
1052
+ if (!presentNames.has(name)) {
1053
+ throw new StorageQueryError('sync.storage.physical_layout_mismatch', {
1054
+ table: name,
1055
+ reason: 'missing_table',
1056
+ });
1057
+ }
1058
+ }
1059
+ for (const table of tables) {
1060
+ statementsExecuted++;
1061
+ const { results } = await this.#db
1062
+ .prepare(`PRAGMA table_info(${quoteIdent(table.name)})`)
1063
+ .all<{ name: string; type: string; notnull: number; pk: number }>();
1064
+ assertPhysicalColumns(
1065
+ table,
1066
+ results.map((column) => ({
1067
+ name: column.name,
1068
+ type: column.type,
1069
+ notNull: column.notnull === 1,
1070
+ primaryKeyPosition: column.pk,
1071
+ })),
1072
+ 'sqlite',
1073
+ );
1074
+ }
913
1075
  this.#tables = schema.tables;
914
1076
  this.#schemaVersion = schema.version;
915
1077
  return { complete: true, statementsExecuted };
@@ -1412,6 +1574,7 @@ export class D1ServerStorage implements ServerStorage {
1412
1574
  throw new StorageQueryError('sync.storage.schema_changed');
1413
1575
  return table;
1414
1576
  },
1577
+ (query) => this.#prepareAuthoritativeQuery(partition, query),
1415
1578
  this.#pushApplySerialized,
1416
1579
  );
1417
1580
  }
@@ -1427,14 +1590,29 @@ export class D1ServerStorage implements ServerStorage {
1427
1590
  async queryAuthoritative(
1428
1591
  partition: string,
1429
1592
  query: AuthoritativeQueryRequest,
1593
+ _checkpoints?: readonly CheckpointDeclaration[],
1430
1594
  ): Promise<AuthoritativeQueryResult> {
1595
+ // D1 declares no checkpoints. `#schemaDatabase` prepends a guard that
1596
+ // re-checks the published marker inside this same batch, so condition 2
1597
+ // is already evaluated with the query (§2.4 D1 schema readiness).
1431
1598
  const db = this.#schemaDatabase();
1599
+ return queryAuthoritativeOn(
1600
+ db,
1601
+ partition,
1602
+ this.#prepareAuthoritativeQuery(partition, query),
1603
+ );
1604
+ }
1605
+
1606
+ #prepareAuthoritativeQuery(
1607
+ partition: string,
1608
+ query: AuthoritativeQueryRequest,
1609
+ ): BoundAuthoritativeQuery {
1432
1610
  if (this.#tables === undefined) {
1433
1611
  throw new Error(
1434
1612
  'ensureSchema(schema) must run before registered queries',
1435
1613
  );
1436
1614
  }
1437
- const prepared = bindAuthoritativePartition(
1615
+ return bindAuthoritativePartition(
1438
1616
  prepareAuthoritativeQuery(
1439
1617
  query.plan,
1440
1618
  query.params,
@@ -1443,41 +1621,6 @@ export class D1ServerStorage implements ServerStorage {
1443
1621
  ),
1444
1622
  partition,
1445
1623
  );
1446
- const results = await db.batch([
1447
- db.prepare(prepared.sql).bind(...prepared.params),
1448
- db
1449
- .prepare('SELECT max_commit_seq FROM sync_partitions WHERE partition=?')
1450
- .bind(partition),
1451
- ]);
1452
- const rowsResult = results[0];
1453
- const cursorResult = results[1];
1454
- if (
1455
- typeof rowsResult !== 'object' ||
1456
- rowsResult === null ||
1457
- !('results' in rowsResult) ||
1458
- !Array.isArray(rowsResult.results) ||
1459
- typeof cursorResult !== 'object' ||
1460
- cursorResult === null ||
1461
- !('results' in cursorResult) ||
1462
- !Array.isArray(cursorResult.results)
1463
- ) {
1464
- throw new Error('D1 registered query returned an invalid batch result');
1465
- }
1466
- const cursor = cursorResult.results[0];
1467
- const maxCommitSeq =
1468
- typeof cursor === 'object' &&
1469
- cursor !== null &&
1470
- 'max_commit_seq' in cursor &&
1471
- typeof cursor.max_commit_seq === 'number'
1472
- ? cursor.max_commit_seq
1473
- : 0;
1474
- return {
1475
- rows: rowsResult.results.filter(
1476
- (row): row is Readonly<Record<string, unknown>> =>
1477
- typeof row === 'object' && row !== null,
1478
- ),
1479
- maxCommitSeq,
1480
- };
1481
1624
  }
1482
1625
 
1483
1626
  async getPartitionLogEpoch(partition: string): Promise<string | undefined> {
@@ -1591,6 +1734,108 @@ export class D1ServerStorage implements ServerStorage {
1591
1734
  return row?.seq ?? 0;
1592
1735
  }
1593
1736
 
1737
+ async readCheckpoints(_partition: string): Promise<StoredCheckpoint[]> {
1738
+ // D1 supports no declared backfill checkpoints in this release: migrations
1739
+ // run out of band and no database-side fence can be installed atomically
1740
+ // with schema visibility. No checkpoint row can exist.
1741
+ return [];
1742
+ }
1743
+
1744
+ async readServeGate(
1745
+ partition: string,
1746
+ _runningSchemaVersion: number,
1747
+ ): Promise<ServeGate> {
1748
+ // D1 has no checkpoints. The published marker is the only gate input;
1749
+ // `#schemaDatabase` already re-checks it per protected batch.
1750
+ const marker = await this.#db
1751
+ .prepare('SELECT schema_version FROM sync_schema_meta WHERE id=1')
1752
+ .first<{ schema_version: number }>();
1753
+ return {
1754
+ storedSchemaVersion: marker?.schema_version ?? 0,
1755
+ logEpoch: await this.getPartitionLogEpoch(partition),
1756
+ pending: [],
1757
+ };
1758
+ }
1759
+
1760
+ async declareCheckpoint(
1761
+ _partition: string,
1762
+ _name: string,
1763
+ _schemaVersion: number,
1764
+ _nowMs: number,
1765
+ ): Promise<StoredCheckpoint> {
1766
+ throw new StorageQueryError('sync.storage.checkpoint_unsupported');
1767
+ }
1768
+
1769
+ async activateCheckpoint(
1770
+ _partition: string,
1771
+ _name: string,
1772
+ _ownerEpoch: number,
1773
+ _watermark: number,
1774
+ _sources: readonly string[],
1775
+ _nowMs: number,
1776
+ ): Promise<'activated' | 'stale' | 'unverifiable'> {
1777
+ throw new StorageQueryError('sync.storage.checkpoint_unsupported');
1778
+ }
1779
+
1780
+ async claimCheckpoint(
1781
+ _partition: string,
1782
+ _name: string,
1783
+ _schemaVersion: number,
1784
+ _nowMs: number,
1785
+ ): Promise<StoredCheckpoint> {
1786
+ throw new StorageQueryError('sync.storage.checkpoint_unsupported');
1787
+ }
1788
+
1789
+ async advanceCheckpoint(
1790
+ _partition: string,
1791
+ _name: string,
1792
+ _ownerEpoch: number,
1793
+ _watermark: number,
1794
+ _observedRows: number,
1795
+ _nowMs: number,
1796
+ ): Promise<boolean> {
1797
+ throw new StorageQueryError('sync.storage.checkpoint_unsupported');
1798
+ }
1799
+
1800
+ async sourceCoverageSeq(
1801
+ partition: string,
1802
+ tables: readonly string[],
1803
+ ): Promise<number> {
1804
+ if (tables.length === 0) return 0;
1805
+ const tableParams = tables.map(() => '?').join(',');
1806
+ const row = await this.#db
1807
+ .prepare(
1808
+ `SELECT max(commit_seq) AS seq FROM sync_changes
1809
+ WHERE partition=? AND tbl IN (${tableParams})`,
1810
+ )
1811
+ .bind(partition, ...tables)
1812
+ .first<{ seq: number | null }>();
1813
+ return row?.seq ?? 0;
1814
+ }
1815
+
1816
+ async hasSourceChangesAbove(
1817
+ partition: string,
1818
+ tables: readonly string[],
1819
+ seq: number,
1820
+ ): Promise<'clean' | 'changed' | 'unverifiable'> {
1821
+ // Read the horizon before the window scan: a horizon past `seq` means the
1822
+ // history that would answer the question is gone, so an empty scan proves
1823
+ // nothing and must not be reported as `clean`.
1824
+ const horizon = await this.getHorizonSeq(partition);
1825
+ if (tables.length === 0) return 'clean';
1826
+ const tableParams = tables.map(() => '?').join(',');
1827
+ const hit = await this.#db
1828
+ .prepare(
1829
+ `SELECT 1 AS hit FROM sync_changes
1830
+ WHERE partition=? AND tbl IN (${tableParams}) AND commit_seq>?
1831
+ LIMIT 1`,
1832
+ )
1833
+ .bind(partition, ...tables, seq)
1834
+ .first<{ hit: number }>();
1835
+ if (hit !== null) return 'changed';
1836
+ return horizon > seq ? 'unverifiable' : 'clean';
1837
+ }
1838
+
1594
1839
  async getRow(
1595
1840
  partition: string,
1596
1841
  table: string,
package/src/errors.ts CHANGED
@@ -167,6 +167,16 @@ export const ERROR_CATALOG: Readonly<Record<string, ErrorCatalogEntry>> = {
167
167
  recommendedAction: 'retryLater',
168
168
  httpStatus: 500,
169
169
  },
170
+ // §2.4 on a non-D1 backend: the server refuses while a declared backfill
171
+ // checkpoint is incomplete or the database is newer than the running build.
172
+ // Formerly host-only; promoted to the §10.2 catalogue so an in-flight
173
+ // request can be refused with a distinguishable identity.
174
+ 'sync.schema_not_ready': {
175
+ category: 'internal',
176
+ retryable: true,
177
+ recommendedAction: 'retryLater',
178
+ httpStatus: 503,
179
+ },
170
180
  'sync.too_many_operations': {
171
181
  category: 'invalid-request',
172
182
  retryable: false,
package/src/handler.ts CHANGED
@@ -51,10 +51,17 @@ import {
51
51
  subscriptionSection,
52
52
  } from './pull';
53
53
  import { type ProcessedPushCommit, processPushCommitWithTrace } from './push';
54
+ import { serveNotReadyError } from './readiness';
54
55
  import type { CompiledSchema } from './schema';
55
56
  import { compileSchema } from './schema';
56
57
  import { computeEffective, type ResolvedScopes } from './scopes';
57
- import type { ClientSubscription, PartitionRegistryEntry } from './storage';
58
+ import { StorageQueryError } from './storage-errors';
59
+ import {
60
+ type ClientSubscription,
61
+ type PartitionRegistryEntry,
62
+ serveGateRefusal,
63
+ serveGateTokenChanged,
64
+ } from './storage';
58
65
 
59
66
  interface RequestPlan {
60
67
  readonly wireVersion: number;
@@ -709,13 +716,64 @@ async function createStreamCore(
709
716
  const schema = compileSchema(ctx.schema);
710
717
  // Relational row tables: create/
711
718
  // migrate on first contact; memoized per storage instance thereafter.
712
- await ctx.storage.ensureSchema(schema);
719
+ try {
720
+ await ctx.storage.ensureSchema(schema, ctx.checkpoints);
721
+ } catch (error) {
722
+ if (
723
+ error instanceof StorageQueryError &&
724
+ error.code === 'sync.storage.checkpoint_incomplete'
725
+ ) {
726
+ const refusal = serveGateRefusal(
727
+ await ctx.storage.readServeGate(ctx.partition, schema.version),
728
+ schema.version,
729
+ ctx.checkpoints,
730
+ );
731
+ throw refusal !== undefined ? serveNotReadyError(refusal) : error;
732
+ }
733
+ throw error;
734
+ }
735
+ // RFC 0007 serve gate: re-read stored state on every request so an
736
+ // already-serving process notices a migration or a newly declared
737
+ // checkpoint. Read after `touchAuthenticatedPartition` establishes the
738
+ // partition's log epoch, so the entry token is the epoch this request
739
+ // serves under. Nothing has escaped at this point; the push transaction
740
+ // re-evaluates the gate under the partition lock.
713
741
  const registry = await touchAuthenticatedPartition(ctx);
742
+ const gate = await ctx.storage.readServeGate(ctx.partition, schema.version);
743
+ const refusal = serveGateRefusal(gate, schema.version, ctx.checkpoints);
744
+ if (refusal !== undefined) throw serveNotReadyError(refusal);
714
745
  const plan = await planRequest(request, ctx, schema, registry);
715
- if (events === undefined) return streamResponse(plan, ctx, schema);
716
746
  const report: RequestReport = { outcome: 'ok' };
747
+ // RFC 0007 read-verify-refuse for the streamed read path: the generator
748
+ // builds the whole response (its data reads) before yielding the first
749
+ // frame, then compares the gate token across those reads. Nothing escapes
750
+ // before the verification, and the buffered size is bounded by the request's
751
+ // pull limits and the server's inline segment cap. Every request verifies,
752
+ // including a mixed push+pull: a write that already applied when the token
753
+ // changed is durable and replayable under the same commit id / idempotency
754
+ // key, so refusing the request is safe (proved in the serve-gate lane).
755
+ const verified = async function* (): AsyncGenerator<Uint8Array> {
756
+ const buffered: Uint8Array[] = [];
757
+ for await (const chunk of streamResponse(plan, ctx, schema, report)) {
758
+ buffered.push(chunk);
759
+ }
760
+ const after = await ctx.storage.readServeGate(
761
+ ctx.partition,
762
+ schema.version,
763
+ );
764
+ const changed = serveGateTokenChanged(gate, after);
765
+ const afterRefusal = serveGateRefusal(
766
+ after,
767
+ schema.version,
768
+ ctx.checkpoints,
769
+ );
770
+ if (afterRefusal !== undefined) throw serveNotReadyError(afterRefusal);
771
+ if (changed !== undefined) throw serveNotReadyError(changed);
772
+ for (const chunk of buffered) yield chunk;
773
+ };
774
+ if (events === undefined) return verified();
717
775
  return instrumentedStream(
718
- streamResponse(plan, ctx, schema, report),
776
+ verified(),
719
777
  ctx,
720
778
  events,
721
779
  plan,
package/src/operations.ts CHANGED
@@ -22,8 +22,13 @@ import { SyncError, syncError } from './errors';
22
22
  import { processPushOperationsWithTrace } from './push';
23
23
  import { compileSchema } from './schema';
24
24
  import { authorizeWrite, type ResolvedScopes } from './scopes';
25
- import type { AuthoritativeQueryValue } from './storage';
25
+ import type {
26
+ AuthoritativeQueryRequest,
27
+ AuthoritativeQueryResult,
28
+ AuthoritativeQueryValue,
29
+ } from './storage';
26
30
  import type { StorageTransaction } from './storage';
31
+ import { transactionQuery } from './storage-query';
27
32
  import { toValidateRow, type ValidateRow } from './validate';
28
33
 
29
34
  export interface RemoteQueryDependency {
@@ -107,6 +112,14 @@ export interface RemoteCommandContext {
107
112
  readonly operationId: string;
108
113
  readonly requestId: string;
109
114
  getRow(table: string, rowId: string): Promise<ValidateRow | undefined>;
115
+ /**
116
+ * Run a generated registered query on the command's push transaction
117
+ * (§6.7). The storage binds every relation to the partition; unlike
118
+ * `getRow`, the rows are not filtered by the actor's scopes.
119
+ */
120
+ queryAuthoritative(
121
+ query: AuthoritativeQueryRequest,
122
+ ): Promise<AuthoritativeQueryResult>;
110
123
  }
111
124
 
112
125
  export interface RemoteCommandOptions<Input> {
@@ -415,22 +428,26 @@ export function registerRemoteQuery<Params>(
415
428
  'selected SQL has no generated relation plan; regenerate queries',
416
429
  );
417
430
  }
418
- await ctx.storage.ensureSchema(schema);
431
+ await ctx.storage.ensureSchema(schema, ctx.checkpoints);
419
432
  const prefix = 'SELECT * FROM (';
420
433
  let result;
421
434
  try {
422
- result = await ctx.storage.queryAuthoritative(ctx.partition, {
423
- plan: {
424
- sql: `${prefix}${selectedSql}) AS "_syncular_registered_query" LIMIT ?`,
425
- relations: plan.relations.map((relation) => ({
426
- ...relation,
427
- start: relation.start + prefix.length,
428
- end: relation.end + prefix.length,
429
- })),
435
+ result = await ctx.storage.queryAuthoritative(
436
+ ctx.partition,
437
+ {
438
+ plan: {
439
+ sql: `${prefix}${selectedSql}) AS "_syncular_registered_query" LIMIT ?`,
440
+ relations: plan.relations.map((relation) => ({
441
+ ...relation,
442
+ start: relation.start + prefix.length,
443
+ end: relation.end + prefix.length,
444
+ })),
445
+ },
446
+ params: [...descriptor.bind(params), options.maxRows + 1],
447
+ tables: descriptor.tables,
430
448
  },
431
- params: [...descriptor.bind(params), options.maxRows + 1],
432
- tables: descriptor.tables,
433
- });
449
+ ctx.checkpoints,
450
+ );
434
451
  } catch (error) {
435
452
  if (error instanceof SyncError) throw error;
436
453
  throw syncError(
@@ -566,6 +583,7 @@ function commandContext(
566
583
  decodeRow(table.columns, stored.payload),
567
584
  );
568
585
  },
586
+ queryAuthoritative: (query) => transactionQuery(tx, query),
569
587
  };
570
588
  }
571
589
 
@@ -603,7 +621,7 @@ export function registerRemoteCommand<Input>(
603
621
  }
604
622
  const resolved: ResolvedScopes = { ok: true, allowed };
605
623
  const schema = compileSchema(ctx.schema);
606
- await ctx.storage.ensureSchema(schema);
624
+ await ctx.storage.ensureSchema(schema, ctx.checkpoints);
607
625
  const processed = await processPushOperationsWithTrace(
608
626
  ctx,
609
627
  schema,