@syncular/server 0.23.0 → 0.25.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.
@@ -55,6 +55,7 @@ import {
55
55
  } from '@syncular/core';
56
56
  import type { CompiledSchema, CompiledTable, IndexSchema } from './schema';
57
57
  import type { StoredRow } from './storage';
58
+ import { StorageQueryError } from './storage-errors';
58
59
 
59
60
  export type RelationalDialect = 'sqlite' | 'postgres';
60
61
 
@@ -192,38 +193,112 @@ export function tableColumnNames(table: CompiledTable): string[] {
192
193
  }
193
194
 
194
195
  /**
195
- * The physical column set the row codec and the scope index read for one
196
- * configured table. The persisted marker's layouts describe the codec's app
196
+ * The storage-internal columns of every synced table, in `createTableDdl`
197
+ * order (app columns sit between the row id and the version). The same-version
198
+ * physical check compares a table against exactly these declarations.
199
+ */
200
+ function syncMetaColumns(dialect: RelationalDialect): readonly {
201
+ readonly name: string;
202
+ readonly type: string;
203
+ readonly notNull: boolean;
204
+ }[] {
205
+ const payloadType = dialect === 'postgres' ? 'BYTEA' : 'BLOB';
206
+ return [
207
+ { name: SYNC_PARTITION_COLUMN, type: 'TEXT', notNull: true },
208
+ { name: SYNC_ROW_ID_COLUMN, type: 'TEXT', notNull: true },
209
+ {
210
+ name: SYNC_VERSION_COLUMN,
211
+ type: dialect === 'postgres' ? 'BIGINT' : 'INTEGER',
212
+ notNull: true,
213
+ },
214
+ {
215
+ name: SYNC_SCOPES_COLUMN,
216
+ type: dialect === 'postgres' ? 'JSONB' : 'TEXT',
217
+ notNull: true,
218
+ },
219
+ { name: SYNC_PAYLOAD_COLUMN, type: payloadType, notNull: true },
220
+ { name: SYNC_COLUMN_VERSIONS_COLUMN, type: payloadType, notNull: false },
221
+ ];
222
+ }
223
+
224
+ /** One physical column of a synced table, read from the database catalog. */
225
+ export interface PhysicalColumn {
226
+ readonly name: string;
227
+ /** Declared type (SQLite) or `format_type` (PostgreSQL); compared case-insensitively. */
228
+ readonly type: string;
229
+ readonly notNull: boolean;
230
+ /** 1-based position in the primary key; 0 when the column is not in it. */
231
+ readonly primaryKeyPosition: number;
232
+ }
233
+
234
+ /**
235
+ * Refuse a same-version database whose physical table the row store cannot
236
+ * read or write. The persisted marker's layouts describe the codec's app
197
237
  * columns only and its version number is the schema's, not the database's, so
198
- * a same-version database that is missing a storage-internal meta column (a
199
- * version-only bump whose ALTER never ran) still has to be refused: compare
200
- * the physical table and fail closed without writing a migration.
238
+ * a same-version database whose storage-internal meta columns drifted (a
239
+ * version-only bump whose ALTER never ran, or a table written by another
240
+ * build) is only visible in the catalog. Every configured column must exist;
241
+ * the meta columns must also carry the type, nullability, and primary-key
242
+ * position `createTableDdl` declares. App-column types and nullability stay
243
+ * with the stored layouts: migration-added app columns are nullable by design.
244
+ * Fails closed without writing a migration.
201
245
  *
202
- * `existingColumns` must be resolved the way the unqualified row queries
203
- * resolve the table — on PostgreSQL, through the session `search_path`, not
246
+ * `columns` must be resolved the way the unqualified row queries resolve the
247
+ * table: on PostgreSQL, through the session `search_path`, not
204
248
  * `current_schema()`. A search path whose first schema is not where the
205
249
  * configured tables live is outside the supported contract: DDL is created in
206
250
  * that first schema, so `ensureSchema` already writes shadow tables there.
207
251
  *
208
- * Scope: this checks that the columns EXIST. Stored types, nullability, and
209
- * non-column storage internals (the tombstone bookkeeping) stay outside it,
210
- * because a wrong type or nullability is a codec-compatibility question the
211
- * persisted layouts answer for app columns.
252
+ * Core storage tables (`sync_tombstones`, `sync_commits`, ...) are outside
253
+ * this check: every open re-applies their `CREATE TABLE IF NOT EXISTS` DDL
254
+ * and column additions, independently of the version marker.
212
255
  */
213
256
  export function assertPhysicalColumns(
214
257
  table: CompiledTable,
215
- existingColumns: ReadonlySet<string>,
258
+ columns: readonly PhysicalColumn[],
259
+ dialect: RelationalDialect,
216
260
  ): void {
217
- if (existingColumns.size === 0) {
218
- throw new Error(
219
- `table ${JSON.stringify(table.name)} is missing from the database at the stored schema version — refusing to serve a database whose rows the running code cannot read`,
220
- );
221
- }
261
+ const refuse = (details: Record<string, string>): never => {
262
+ throw new StorageQueryError('sync.storage.physical_layout_mismatch', {
263
+ table: table.name,
264
+ ...details,
265
+ });
266
+ };
267
+ if (columns.length === 0) refuse({ reason: 'missing_table' });
268
+ const byName = new Map(columns.map((column) => [column.name, column]));
222
269
  for (const name of tableColumnNames(table)) {
223
- if (existingColumns.has(name)) continue;
224
- throw new Error(
225
- `table ${JSON.stringify(table.name)} is missing column ${JSON.stringify(name)} at the stored schema version — refusing to serve a database whose rows the running code cannot read`,
226
- );
270
+ if (!byName.has(name)) refuse({ column: name, reason: 'missing_column' });
271
+ }
272
+ for (const expected of syncMetaColumns(dialect)) {
273
+ const actual = byName.get(expected.name);
274
+ if (actual === undefined) continue;
275
+ if (actual.type.toUpperCase() !== expected.type) {
276
+ refuse({
277
+ column: expected.name,
278
+ reason: 'type',
279
+ expected: expected.type,
280
+ actual: actual.type,
281
+ });
282
+ }
283
+ if (actual.notNull !== expected.notNull) {
284
+ refuse({
285
+ column: expected.name,
286
+ reason: 'nullability',
287
+ expected: expected.notNull ? 'not_null' : 'nullable',
288
+ actual: actual.notNull ? 'not_null' : 'nullable',
289
+ });
290
+ }
291
+ }
292
+ for (const column of columns) {
293
+ const expected =
294
+ column.name === SYNC_PARTITION_COLUMN
295
+ ? 1
296
+ : column.name === SYNC_ROW_ID_COLUMN
297
+ ? 2
298
+ : 0;
299
+ if (column.primaryKeyPosition !== expected) {
300
+ refuse({ column: column.name, reason: 'primary_key' });
301
+ }
227
302
  }
228
303
  }
229
304
 
@@ -232,22 +307,20 @@ export function createTableDdl(
232
307
  table: CompiledTable,
233
308
  dialect: RelationalDialect,
234
309
  ): string {
235
- const versionType = dialect === 'postgres' ? 'BIGINT' : 'INTEGER';
236
- const scopesType = dialect === 'postgres' ? 'JSONB' : 'TEXT';
237
- const payloadType = dialect === 'postgres' ? 'BYTEA' : 'BLOB';
310
+ const [partition, rowId, ...trailing] = syncMetaColumns(dialect).map(
311
+ (column) =>
312
+ `${quoteIdent(column.name)} ${column.type}${column.notNull ? ' NOT NULL' : ''}`,
313
+ );
238
314
  const defs = [
239
- `${quoteIdent(SYNC_PARTITION_COLUMN)} TEXT NOT NULL`,
240
- `${quoteIdent(SYNC_ROW_ID_COLUMN)} TEXT NOT NULL`,
315
+ partition,
316
+ rowId,
241
317
  ...(table.materialize
242
318
  ? table.columns.map((column) => {
243
319
  const notNull = column.nullable ? '' : ' NOT NULL';
244
320
  return `${quoteIdent(column.name)} ${columnSqlType(column, dialect)}${notNull}`;
245
321
  })
246
322
  : []),
247
- `${quoteIdent(SYNC_VERSION_COLUMN)} ${versionType} NOT NULL`,
248
- `${quoteIdent(SYNC_SCOPES_COLUMN)} ${scopesType} NOT NULL`,
249
- `${quoteIdent(SYNC_PAYLOAD_COLUMN)} ${payloadType} NOT NULL`,
250
- `${quoteIdent(SYNC_COLUMN_VERSIONS_COLUMN)} ${payloadType}`,
323
+ ...trailing,
251
324
  `PRIMARY KEY (${quoteIdent(SYNC_PARTITION_COLUMN)}, ${quoteIdent(SYNC_ROW_ID_COLUMN)})`,
252
325
  ];
253
326
  return `CREATE TABLE IF NOT EXISTS ${quoteIdent(table.name)} (${defs.join(', ')})`;
@@ -660,6 +733,48 @@ export function parseLayouts(json: string | null | undefined): StoredLayouts {
660
733
  return JSON.parse(json) as StoredLayouts;
661
734
  }
662
735
 
736
+ /**
737
+ * Version equality is not layout equality: a marker written by another build
738
+ * at the same version describes rows the running codec cannot decode. Refuse
739
+ * when the stored layouts disagree with the configured schema, naming the
740
+ * first table (and column) that differs.
741
+ */
742
+ export function assertStoredLayouts(
743
+ schema: CompiledSchema,
744
+ storedJson: string | null | undefined,
745
+ ): void {
746
+ const storedLayouts = parseLayouts(storedJson);
747
+ const configuredLayouts = parseLayouts(layoutsOf(schema));
748
+ for (const [tableName, columns] of Object.entries(configuredLayouts)) {
749
+ const stored = storedLayouts[tableName];
750
+ const columnCount = Math.max(columns.length, stored?.length ?? 0);
751
+ for (let index = 0; index < columnCount; index++) {
752
+ const expected = columns[index];
753
+ const actual = stored?.[index];
754
+ if (
755
+ expected !== undefined &&
756
+ actual !== undefined &&
757
+ actual.name === expected.name &&
758
+ actual.type === expected.type &&
759
+ actual.nullable === expected.nullable
760
+ ) {
761
+ continue;
762
+ }
763
+ throw new StorageQueryError('sync.storage.stored_layout_mismatch', {
764
+ table: tableName,
765
+ column: expected?.name ?? actual?.name ?? '',
766
+ });
767
+ }
768
+ }
769
+ for (const tableName of Object.keys(storedLayouts)) {
770
+ if (!(tableName in configuredLayouts)) {
771
+ throw new StorageQueryError('sync.storage.stored_layout_mismatch', {
772
+ table: tableName,
773
+ });
774
+ }
775
+ }
776
+ }
777
+
663
778
  /**
664
779
  * Tables present in the stored layout but absent from the configured head
665
780
  * schema. A schema-version bump retires their relational current-row tables;
@@ -18,6 +18,7 @@ import {
18
18
  import { syncError } from './errors';
19
19
  import {
20
20
  assertPhysicalColumns,
21
+ assertStoredLayouts,
21
22
  commitWindowPageSql,
22
23
  deleteRowSql,
23
24
  deleteSqliteRowScopesSql,
@@ -244,6 +245,15 @@ class SqliteTransaction implements StorageTransaction {
244
245
  return this.#storage.scanRowsByIndex(this.#partition, query);
245
246
  }
246
247
 
248
+ async queryAuthoritative(
249
+ query: AuthoritativeQueryRequest,
250
+ ): Promise<AuthoritativeQueryResult> {
251
+ this.#assertOpen();
252
+ // Same shared connection: the query runs inside this transaction's
253
+ // BEGIN IMMEDIATE and observes every row staged so far.
254
+ return this.#storage.runAuthoritativeQuery(this.#partition, query);
255
+ }
256
+
247
257
  async lockPartitionForPush(): Promise<void> {
248
258
  this.#assertOpen();
249
259
  // BEGIN IMMEDIATE in the constructor already owns SQLite's writer lock.
@@ -594,57 +604,25 @@ END`);
594
604
  );
595
605
  }
596
606
  if (marker !== null && marker.schema_version === schema.version) {
597
- // Version equality is not layout equality: a marker written by another
598
- // build at the same version describes rows the running codec cannot
599
- // decode. Compare the stored layouts instead of trusting the number.
600
- const storedLayouts = parseLayouts(marker.layouts);
601
- const configuredLayouts = parseLayouts(layoutsOf(schema));
602
- let mismatch: string | undefined;
603
- for (const [tableName, columns] of Object.entries(configuredLayouts)) {
604
- const stored = storedLayouts[tableName];
605
- const columnCount = Math.max(columns.length, stored?.length ?? 0);
606
- for (let index = 0; index < columnCount; index++) {
607
- const expected = columns[index];
608
- const actual = stored?.[index];
609
- if (
610
- expected !== undefined &&
611
- actual !== undefined &&
612
- actual.name === expected.name &&
613
- actual.type === expected.type &&
614
- actual.nullable === expected.nullable
615
- ) {
616
- continue;
617
- }
618
- mismatch = `table ${JSON.stringify(tableName)} column ${JSON.stringify(expected?.name ?? actual?.name ?? '')}`;
619
- break;
620
- }
621
- if (mismatch !== undefined) break;
622
- }
623
- if (mismatch === undefined) {
624
- for (const tableName of Object.keys(storedLayouts)) {
625
- if (!(tableName in configuredLayouts)) {
626
- mismatch = `table ${JSON.stringify(tableName)}`;
627
- break;
628
- }
629
- }
630
- }
631
- if (mismatch !== undefined) {
632
- throw new Error(
633
- `stored schema layouts disagree with the configured schema at version ${schema.version} (${mismatch}) — refusing to serve a database whose stored rows the running code cannot decode`,
634
- );
635
- }
636
- // The persisted layouts describe the codec's app columns only: read the
637
- // physical tables so a same-version database missing a
638
- // storage-internal column is refused at startup instead of failing at
639
- // the first write.
607
+ assertStoredLayouts(schema, marker.layouts);
640
608
  for (const table of schema.tables.values()) {
641
609
  const escapedTableName = table.name.replaceAll('"', '""');
642
610
  const columns = this.db
643
- .query<{ name: string }, []>(
644
- `PRAGMA table_info("${escapedTableName}")`,
645
- )
611
+ .query<
612
+ { name: string; type: string; notnull: number; pk: number },
613
+ []
614
+ >(`PRAGMA table_info("${escapedTableName}")`)
646
615
  .all();
647
- assertPhysicalColumns(table, new Set(columns.map((c) => c.name)));
616
+ assertPhysicalColumns(
617
+ table,
618
+ columns.map((column) => ({
619
+ name: column.name,
620
+ type: column.type,
621
+ notNull: column.notnull === 1,
622
+ primaryKeyPosition: column.pk,
623
+ })),
624
+ 'sqlite',
625
+ );
648
626
  }
649
627
  }
650
628
  if (marker === null || marker.schema_version < schema.version) {
@@ -945,11 +923,16 @@ END`);
945
923
  return row?.max_commit_seq ?? 0;
946
924
  }
947
925
 
948
- async queryAuthoritative(
926
+ /**
927
+ * Internal: rewrite and run one registered query on the shared connection.
928
+ * The caller owns the transaction: `queryAuthoritative` opens a read
929
+ * transaction behind the FIFO; `SqliteTransaction.queryAuthoritative` runs
930
+ * inside the push transaction's `BEGIN IMMEDIATE`.
931
+ */
932
+ runAuthoritativeQuery(
949
933
  partition: string,
950
934
  query: AuthoritativeQueryRequest,
951
- checkpoints?: readonly CheckpointDeclaration[],
952
- ): Promise<AuthoritativeQueryResult> {
935
+ ): AuthoritativeQueryResult {
953
936
  if (this.#tables === undefined) {
954
937
  throw new Error(
955
938
  'ensureSchema(schema) must run before registered queries',
@@ -964,6 +947,24 @@ END`);
964
947
  ),
965
948
  partition,
966
949
  );
950
+ const rows = this.db
951
+ .query<Readonly<Record<string, unknown>>, AuthoritativeQueryValue[]>(
952
+ prepared.sql,
953
+ )
954
+ .all(...prepared.params);
955
+ const cursor = this.db
956
+ .query<{ max_commit_seq: number }, [string]>(
957
+ 'SELECT max_commit_seq FROM sync_partitions WHERE partition=?',
958
+ )
959
+ .get(partition);
960
+ return { rows, maxCommitSeq: cursor?.max_commit_seq ?? 0 };
961
+ }
962
+
963
+ async queryAuthoritative(
964
+ partition: string,
965
+ query: AuthoritativeQueryRequest,
966
+ checkpoints?: readonly CheckpointDeclaration[],
967
+ ): Promise<AuthoritativeQueryResult> {
967
968
  const previous = this.#transactionTail;
968
969
  let release!: () => void;
969
970
  this.#transactionTail = new Promise<void>((resolve) => {
@@ -981,19 +982,10 @@ END`);
981
982
  const gate = await this.readServeGate(partition, runningVersion);
982
983
  const refusal = serveGateRefusal(gate, runningVersion, checkpoints);
983
984
  if (refusal !== undefined) throw serveNotReadyError(refusal);
984
- const rows = this.db
985
- .query<Readonly<Record<string, unknown>>, AuthoritativeQueryValue[]>(
986
- prepared.sql,
987
- )
988
- .all(...prepared.params);
989
- const cursor = this.db
990
- .query<{ max_commit_seq: number }, [string]>(
991
- 'SELECT max_commit_seq FROM sync_partitions WHERE partition=?',
992
- )
993
- .get(partition);
985
+ const result = this.runAuthoritativeQuery(partition, query);
994
986
  this.db.exec('COMMIT');
995
987
  open = false;
996
- return { rows, maxCommitSeq: cursor?.max_commit_seq ?? 0 };
988
+ return result;
997
989
  } catch (error) {
998
990
  if (open) this.db.exec('ROLLBACK');
999
991
  throw error;
@@ -30,7 +30,11 @@ export type StorageQueryErrorCode =
30
30
  | 'sync.storage.checkpoint_not_declared'
31
31
  | 'sync.storage.checkpoint_unsupported'
32
32
  | 'sync.storage.checkpoint_fence_missing'
33
- | 'sync.storage.checkpoint_incomplete';
33
+ | 'sync.storage.checkpoint_incomplete'
34
+ | 'sync.storage.stored_layout_mismatch'
35
+ | 'sync.storage.physical_layout_mismatch'
36
+ | 'sync.storage.transaction_query_unsupported'
37
+ | 'sync.storage.query_over_staged_writes';
34
38
 
35
39
  const STORAGE_QUERY_MESSAGES: Readonly<Record<StorageQueryErrorCode, string>> =
36
40
  {
@@ -56,6 +60,14 @@ const STORAGE_QUERY_MESSAGES: Readonly<Record<StorageQueryErrorCode, string>> =
56
60
  'checkpoint activation requires the writer fence raised at declaration',
57
61
  'sync.storage.checkpoint_incomplete':
58
62
  'a declared backfill checkpoint is not activated and this caller did not declare it',
63
+ 'sync.storage.stored_layout_mismatch':
64
+ 'stored schema layouts disagree with the configured schema at the same version',
65
+ 'sync.storage.physical_layout_mismatch':
66
+ 'a synced table does not match the storage layout the running code reads and writes',
67
+ 'sync.storage.transaction_query_unsupported':
68
+ 'this storage transaction cannot run registered queries',
69
+ 'sync.storage.query_over_staged_writes':
70
+ 'registered query reads a table this transaction has buffered writes for',
59
71
  'sync.storage.scan_requires_scope':
60
72
  'scope-indexed row scans require at least one scope variable',
61
73
  'sync.storage.index_not_found':
@@ -70,15 +82,21 @@ const STORAGE_QUERY_MESSAGES: Readonly<Record<StorageQueryErrorCode, string>> =
70
82
 
71
83
  /**
72
84
  * Host-only query error. Messages never include identifiers, values, SQL,
73
- * paths, or row data; callers branch on `code`, never message text.
85
+ * paths, or row data; callers branch on `code`, never message text. The
86
+ * schema-readiness codes name the offending table and column in `details`.
74
87
  */
75
88
  export class StorageQueryError extends Error {
76
89
  override readonly name = 'StorageQueryError';
77
90
  readonly code: StorageQueryErrorCode;
91
+ readonly details: Readonly<Record<string, string>> | undefined;
78
92
 
79
- constructor(code: StorageQueryErrorCode) {
93
+ constructor(
94
+ code: StorageQueryErrorCode,
95
+ details?: Readonly<Record<string, string>>,
96
+ ) {
80
97
  super(STORAGE_QUERY_MESSAGES[code]);
81
98
  this.code = code;
99
+ this.details = details;
82
100
  }
83
101
  }
84
102
 
@@ -1,6 +1,12 @@
1
1
  import type { ScopeMap } from '@syncular/core';
2
2
  import type { CompiledTable, IndexSchema } from './schema';
3
- import type { IndexRowScanQuery, RowScanQuery } from './storage';
3
+ import type {
4
+ AuthoritativeQueryRequest,
5
+ AuthoritativeQueryResult,
6
+ IndexRowScanQuery,
7
+ RowScanQuery,
8
+ StorageTransaction,
9
+ } from './storage';
4
10
  import { StorageQueryError } from './storage-errors';
5
11
 
6
12
  /**
@@ -51,3 +57,19 @@ export function resolveIndexRowScan(
51
57
  }
52
58
  return index;
53
59
  }
60
+
61
+ /**
62
+ * §6.7/§6.8 transaction-bound registered query for every host hook that runs
63
+ * inside a push transaction. A storage transaction without the capability
64
+ * fails; the call never falls back to `ServerStorage.queryAuthoritative`,
65
+ * which reads outside the transaction.
66
+ */
67
+ export async function transactionQuery(
68
+ tx: StorageTransaction,
69
+ query: AuthoritativeQueryRequest,
70
+ ): Promise<AuthoritativeQueryResult> {
71
+ if (tx.queryAuthoritative === undefined) {
72
+ throw new StorageQueryError('sync.storage.transaction_query_unsupported');
73
+ }
74
+ return tx.queryAuthoritative(query);
75
+ }
package/src/storage.ts CHANGED
@@ -473,6 +473,21 @@ export interface StorageTransaction {
473
473
  * read-your-own-writes semantics. It is not reachable from SSP2 requests.
474
474
  */
475
475
  scanRowsByIndex?(query: IndexRowScanQuery): Promise<StoredRow[]>;
476
+ /**
477
+ * Optional transaction-bound registered query (§6.7, §6.8): the relation
478
+ * rewrite of `ServerStorage.queryAuthoritative`, bound to this
479
+ * transaction's partition and executed on this transaction's connection.
480
+ * A hook inside the push transaction therefore reads the transaction's
481
+ * snapshot and never waits for a second connection. The push layer has
482
+ * already evaluated the serve gate on this transaction, so the call takes
483
+ * no checkpoint declarations. SQLite and PostgreSQL return the staged
484
+ * writes of this transaction. D1 buffers writes until commit and fails
485
+ * with `sync.storage.query_over_staged_writes` when the query reads a
486
+ * table this transaction has written.
487
+ */
488
+ queryAuthoritative?(
489
+ query: AuthoritativeQueryRequest,
490
+ ): Promise<AuthoritativeQueryResult>;
476
491
  /**
477
492
  * Serialize every push apply for this partition before any operation read,
478
493
  * validation, merge, or write. The push layer re-checks idempotency only
@@ -790,17 +805,18 @@ export interface ServerStorage {
790
805
  * every generated app-table relation with a partition-filtered relation and
791
806
  * return rows plus maxCommitSeq from one consistent database snapshot.
792
807
  *
793
- * The storage serializes this call behind an open transaction on a
794
- * single-connection executor (SQLite, PGlite). A validator runs inside the
795
- * push transaction, so on that executor a validator that calls
796
- * `queryAuthoritative` waits on the transaction it is already inside and
797
- * the push never completes. A pool-backed executor (`pg`, `Bun.sql`) hands
798
- * the call a second connection instead: the validator does not deadlock,
799
- * but it reads committed state without the candidate write. On either
800
- * executor read candidate state through the whole-commit `CommitValidator`
801
- * reader (§6.8). A call made from any other context waits for the open
802
- * transaction to finish on a single-connection executor, and runs
803
- * concurrently on a pool-backed one.
808
+ * Do not call this method from a hook that runs inside the push
809
+ * transaction (a row validator, the whole-commit validator, or a reaction
810
+ * planner). On a single-connection executor (SQLite, PGlite) the storage
811
+ * serializes this call behind the open transaction, so the hook waits on
812
+ * the transaction it is inside and the push never completes. A pool-backed
813
+ * executor (`pg`, `Bun.sql`) runs the call on a second connection: it reads
814
+ * committed state without the transaction's staged writes, and it waits
815
+ * forever when the push transactions hold every pool connection. Those
816
+ * hooks call the transaction-bound `queryAuthoritative` on
817
+ * `ValidateContext` or on the candidate-state reader instead (§6.7, §6.8).
818
+ * A call from any other context waits for the open transaction on a
819
+ * single-connection executor and runs concurrently on a pool-backed one.
804
820
  */
805
821
  queryAuthoritative?(
806
822
  partition: string,
package/src/validate.ts CHANGED
@@ -20,6 +20,10 @@ import {
20
20
  type RowValue,
21
21
  type ScopeMap,
22
22
  } from '@syncular/core';
23
+ import type {
24
+ AuthoritativeQueryRequest,
25
+ AuthoritativeQueryResult,
26
+ } from './storage';
23
27
 
24
28
  /**
25
29
  * §6.7 reserved code prefixes. A host validator code MUST NOT start with
@@ -104,6 +108,17 @@ export interface CommitValidationReader {
104
108
  rowId: string,
105
109
  ): Promise<CommitValidationRow | undefined>;
106
110
  scanRows(input: CommitValidationScanInput): Promise<CommitValidationRow[]>;
111
+ /**
112
+ * Run a generated registered query on the commit transaction (§6.8). The
113
+ * request has the shape `ServerStorage.queryAuthoritative` takes; the
114
+ * storage binds every relation to the commit's partition. SQLite and
115
+ * PostgreSQL return the final candidate state; D1 fails with
116
+ * `sync.storage.query_over_staged_writes` when the query reads a table the
117
+ * commit has written.
118
+ */
119
+ queryAuthoritative(
120
+ query: AuthoritativeQueryRequest,
121
+ ): Promise<AuthoritativeQueryResult>;
107
122
  }
108
123
 
109
124
  export interface ValidateCommitInput {
@@ -126,21 +141,31 @@ export type CommitValidator = (
126
141
  /**
127
142
  * Ambient context a validator may consult (§6.7).
128
143
  *
129
- * A validator runs inside the push transaction. On a single-connection
130
- * executor (SQLite, PGlite) the storage serializes authoritative queries
131
- * behind that transaction, so a validator that calls `queryAuthoritative`
132
- * waits for the transaction it is already inside and the push never
133
- * completes. On a pool-backed executor (`pg`, `Bun.sql`) the query takes a
134
- * second connection and returns committed state without the candidate write.
135
- * Either way a rule that must read rows belongs in the whole-commit
136
- * `CommitValidator`, whose reader runs get and scan operations on the same
137
- * transaction (§6.8).
144
+ * A validator runs inside the push transaction. It MUST NOT call
145
+ * `ServerStorage.queryAuthoritative`: on a single-connection executor
146
+ * (SQLite, PGlite) that call waits for the transaction the validator is
147
+ * inside and the push never completes, and on a pool-backed executor (`pg`,
148
+ * `Bun.sql`) it reads committed state without the staged writes, or waits
149
+ * forever once push transactions hold every pool connection. An authority
150
+ * read calls `queryAuthoritative` on this context instead.
138
151
  */
139
152
  export interface ValidateContext {
140
153
  /** Host-authenticated actor (§1.1) performing the write. */
141
154
  readonly actorId: string;
142
155
  /** The partition (§1.1) the commit targets. */
143
156
  readonly partition: string;
157
+ /**
158
+ * Run a generated registered query on the push transaction (§6.7). The
159
+ * request has the shape `ServerStorage.queryAuthoritative` takes; the
160
+ * storage binds every relation to the commit's partition. SQLite and
161
+ * PostgreSQL return the transaction's snapshot, including the operations
162
+ * staged before this one; D1 fails with
163
+ * `sync.storage.query_over_staged_writes` when the query reads a table the
164
+ * commit has written.
165
+ */
166
+ readonly queryAuthoritative: (
167
+ query: AuthoritativeQueryRequest,
168
+ ) => Promise<AuthoritativeQueryResult>;
144
169
  }
145
170
 
146
171
  /**
@@ -152,12 +177,12 @@ export interface ValidateContext {
152
177
  * `sync.constraint_violation` code (a validator SHOULD throw
153
178
  * `ValidationRejection` to control the code). MUST NOT mutate the row.
154
179
  *
155
- * The hook runs inside the push transaction and cannot read candidate rows
156
- * from an authoritative query: on a single-connection executor
157
- * `queryAuthoritative` serializes behind that transaction and the push never
158
- * completes, and on a pool-backed executor it reads committed state without
159
- * the candidate write. Read rows with the whole-commit `CommitValidator`
160
- * reader instead (§6.8).
180
+ * The hook runs inside the push transaction. An authority read uses
181
+ * `ctx.queryAuthoritative`, which executes on that transaction; calling
182
+ * `ServerStorage.queryAuthoritative` from here deadlocks on a
183
+ * single-connection executor and reads committed state on a pool-backed one.
184
+ * A rule over sibling operations belongs in the whole-commit
185
+ * `CommitValidator` (§6.8).
161
186
  */
162
187
  export type Validator = (
163
188
  op: ValidateOperation,