@syncular/server 0.23.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.
@@ -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;
@@ -16,6 +16,13 @@ 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
+ /**
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;
19
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>;
@@ -12,7 +12,7 @@ import { serveNotReadyError } from './readiness.js';
12
12
  */
13
13
  import { bindAuthoritativePartition, prepareAuthoritativeQuery, } from './authoritative-query.js';
14
14
  import { syncError } from './errors.js';
15
- import { assertPhysicalColumns, commitWindowPageSql, deleteRowSql, deleteSqliteRowScopesSql, dropTableDdl, indexRowPageStatement, layoutsOf, migratePayload, parseLayouts, retiredTableNames, rewritePlan, rewriteRowSql, rewriteValues, SCHEMA_META_DDL_SQLITE, scanRowPageSql, schemaDdl, selectRowScopesSql, selectRowSql, selectRowsForRewriteSql, upsertSql, upsertValues, } from './relational-rows.js';
15
+ import { assertPhysicalColumns, assertStoredLayouts, commitWindowPageSql, deleteRowSql, deleteSqliteRowScopesSql, dropTableDdl, indexRowPageStatement, layoutsOf, migratePayload, parseLayouts, retiredTableNames, rewritePlan, rewriteRowSql, rewriteValues, SCHEMA_META_DDL_SQLITE, scanRowPageSql, schemaDdl, selectRowScopesSql, selectRowSql, selectRowsForRewriteSql, upsertSql, upsertValues, } from './relational-rows.js';
16
16
  import { matchesEffective } from './scopes.js';
17
17
  import { collectCommitWindowPage, deserializePushResult, placeholders, SQLITE_DDL, serializePushResult, toStoredRow, } from './sqlite-dialect.js';
18
18
  import { SqliteAdapterRequiredError, } from './sqlite-driver.js';
@@ -106,6 +106,12 @@ class SqliteTransaction {
106
106
  this.#assertOpen();
107
107
  return this.#storage.scanRowsByIndex(this.#partition, query);
108
108
  }
109
+ async queryAuthoritative(query) {
110
+ this.#assertOpen();
111
+ // Same shared connection: the query runs inside this transaction's
112
+ // BEGIN IMMEDIATE and observes every row staged so far.
113
+ return this.#storage.runAuthoritativeQuery(this.#partition, query);
114
+ }
109
115
  async lockPartitionForPush() {
110
116
  this.#assertOpen();
111
117
  // BEGIN IMMEDIATE in the constructor already owns SQLite's writer lock.
@@ -338,52 +344,18 @@ END`);
338
344
  throw new Error(`stored schema version ${marker.schema_version} is newer than the configured schema (${schema.version}) — refusing to run an older server against a migrated database`);
339
345
  }
340
346
  if (marker !== null && marker.schema_version === schema.version) {
341
- // Version equality is not layout equality: a marker written by another
342
- // build at the same version describes rows the running codec cannot
343
- // decode. Compare the stored layouts instead of trusting the number.
344
- const storedLayouts = parseLayouts(marker.layouts);
345
- const configuredLayouts = parseLayouts(layoutsOf(schema));
346
- let mismatch;
347
- for (const [tableName, columns] of Object.entries(configuredLayouts)) {
348
- const stored = storedLayouts[tableName];
349
- const columnCount = Math.max(columns.length, stored?.length ?? 0);
350
- for (let index = 0; index < columnCount; index++) {
351
- const expected = columns[index];
352
- const actual = stored?.[index];
353
- if (expected !== undefined &&
354
- actual !== undefined &&
355
- actual.name === expected.name &&
356
- actual.type === expected.type &&
357
- actual.nullable === expected.nullable) {
358
- continue;
359
- }
360
- mismatch = `table ${JSON.stringify(tableName)} column ${JSON.stringify(expected?.name ?? actual?.name ?? '')}`;
361
- break;
362
- }
363
- if (mismatch !== undefined)
364
- break;
365
- }
366
- if (mismatch === undefined) {
367
- for (const tableName of Object.keys(storedLayouts)) {
368
- if (!(tableName in configuredLayouts)) {
369
- mismatch = `table ${JSON.stringify(tableName)}`;
370
- break;
371
- }
372
- }
373
- }
374
- if (mismatch !== undefined) {
375
- throw new Error(`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`);
376
- }
377
- // The persisted layouts describe the codec's app columns only: read the
378
- // physical tables so a same-version database missing a
379
- // storage-internal column is refused at startup instead of failing at
380
- // the first write.
347
+ assertStoredLayouts(schema, marker.layouts);
381
348
  for (const table of schema.tables.values()) {
382
349
  const escapedTableName = table.name.replaceAll('"', '""');
383
350
  const columns = this.db
384
351
  .query(`PRAGMA table_info("${escapedTableName}")`)
385
352
  .all();
386
- assertPhysicalColumns(table, new Set(columns.map((c) => c.name)));
353
+ assertPhysicalColumns(table, columns.map((column) => ({
354
+ name: column.name,
355
+ type: column.type,
356
+ notNull: column.notnull === 1,
357
+ primaryKeyPosition: column.pk,
358
+ })), 'sqlite');
387
359
  }
388
360
  }
389
361
  if (marker === null || marker.schema_version < schema.version) {
@@ -597,11 +569,26 @@ END`);
597
569
  .get(partition);
598
570
  return row?.max_commit_seq ?? 0;
599
571
  }
600
- async queryAuthoritative(partition, query, checkpoints) {
572
+ /**
573
+ * Internal: rewrite and run one registered query on the shared connection.
574
+ * The caller owns the transaction: `queryAuthoritative` opens a read
575
+ * transaction behind the FIFO; `SqliteTransaction.queryAuthoritative` runs
576
+ * inside the push transaction's `BEGIN IMMEDIATE`.
577
+ */
578
+ runAuthoritativeQuery(partition, query) {
601
579
  if (this.#tables === undefined) {
602
580
  throw new Error('ensureSchema(schema) must run before registered queries');
603
581
  }
604
582
  const prepared = bindAuthoritativePartition(prepareAuthoritativeQuery(query.plan, query.params, query.tables, this.#tables), partition);
583
+ const rows = this.db
584
+ .query(prepared.sql)
585
+ .all(...prepared.params);
586
+ const cursor = this.db
587
+ .query('SELECT max_commit_seq FROM sync_partitions WHERE partition=?')
588
+ .get(partition);
589
+ return { rows, maxCommitSeq: cursor?.max_commit_seq ?? 0 };
590
+ }
591
+ async queryAuthoritative(partition, query, checkpoints) {
605
592
  const previous = this.#transactionTail;
606
593
  let release;
607
594
  this.#transactionTail = new Promise((resolve) => {
@@ -620,15 +607,10 @@ END`);
620
607
  const refusal = serveGateRefusal(gate, runningVersion, checkpoints);
621
608
  if (refusal !== undefined)
622
609
  throw serveNotReadyError(refusal);
623
- const rows = this.db
624
- .query(prepared.sql)
625
- .all(...prepared.params);
626
- const cursor = this.db
627
- .query('SELECT max_commit_seq FROM sync_partitions WHERE partition=?')
628
- .get(partition);
610
+ const result = this.runAuthoritativeQuery(partition, query);
629
611
  this.db.exec('COMMIT');
630
612
  open = false;
631
- return { rows, maxCommitSeq: cursor?.max_commit_seq ?? 0 };
613
+ return result;
632
614
  }
633
615
  catch (error) {
634
616
  if (open)
@@ -9,15 +9,17 @@ export declare class StorageConstraintError extends Error {
9
9
  constructor(cause: unknown, opIndex?: number);
10
10
  }
11
11
  /** Stable, privacy-safe failures for trusted server storage queries. */
12
- export type StorageQueryErrorCode = 'sync.storage.schema_migration_pending' | 'sync.storage.schema_migration_conflict' | 'sync.storage.schema_changed' | 'sync.storage.invalid_migration_budget' | 'sync.storage.scan_requires_scope' | 'sync.storage.index_not_found' | 'sync.storage.index_not_materialized' | 'sync.storage.index_value_count_mismatch' | 'sync.storage.invalid_limit' | 'sync.storage.prune_epoch_mismatch' | 'sync.storage.partition_unregistered' | 'sync.storage.invalid_prune_cursor' | 'sync.storage.checkpoint_not_declared' | 'sync.storage.checkpoint_unsupported' | 'sync.storage.checkpoint_fence_missing' | 'sync.storage.checkpoint_incomplete';
12
+ export type StorageQueryErrorCode = 'sync.storage.schema_migration_pending' | 'sync.storage.schema_migration_conflict' | 'sync.storage.schema_changed' | 'sync.storage.invalid_migration_budget' | 'sync.storage.scan_requires_scope' | 'sync.storage.index_not_found' | 'sync.storage.index_not_materialized' | 'sync.storage.index_value_count_mismatch' | 'sync.storage.invalid_limit' | 'sync.storage.prune_epoch_mismatch' | 'sync.storage.partition_unregistered' | 'sync.storage.invalid_prune_cursor' | 'sync.storage.checkpoint_not_declared' | 'sync.storage.checkpoint_unsupported' | 'sync.storage.checkpoint_fence_missing' | 'sync.storage.checkpoint_incomplete' | 'sync.storage.stored_layout_mismatch' | 'sync.storage.physical_layout_mismatch' | 'sync.storage.transaction_query_unsupported' | 'sync.storage.query_over_staged_writes';
13
13
  /**
14
14
  * Host-only query error. Messages never include identifiers, values, SQL,
15
- * paths, or row data; callers branch on `code`, never message text.
15
+ * paths, or row data; callers branch on `code`, never message text. The
16
+ * schema-readiness codes name the offending table and column in `details`.
16
17
  */
17
18
  export declare class StorageQueryError extends Error {
18
19
  readonly name = "StorageQueryError";
19
20
  readonly code: StorageQueryErrorCode;
20
- constructor(code: StorageQueryErrorCode);
21
+ readonly details: Readonly<Record<string, string>> | undefined;
22
+ constructor(code: StorageQueryErrorCode, details?: Readonly<Record<string, string>>);
21
23
  }
22
24
  /** SQLite primary/extended constraint result codes (`SQLITE_CONSTRAINT*`). */
23
25
  export declare function isSqliteConstraintError(error: unknown): boolean;
@@ -23,6 +23,10 @@ const STORAGE_QUERY_MESSAGES = {
23
23
  'sync.storage.checkpoint_unsupported': 'this storage backend cannot declare backfill checkpoints',
24
24
  'sync.storage.checkpoint_fence_missing': 'checkpoint activation requires the writer fence raised at declaration',
25
25
  'sync.storage.checkpoint_incomplete': 'a declared backfill checkpoint is not activated and this caller did not declare it',
26
+ 'sync.storage.stored_layout_mismatch': 'stored schema layouts disagree with the configured schema at the same version',
27
+ 'sync.storage.physical_layout_mismatch': 'a synced table does not match the storage layout the running code reads and writes',
28
+ 'sync.storage.transaction_query_unsupported': 'this storage transaction cannot run registered queries',
29
+ 'sync.storage.query_over_staged_writes': 'registered query reads a table this transaction has buffered writes for',
26
30
  'sync.storage.scan_requires_scope': 'scope-indexed row scans require at least one scope variable',
27
31
  'sync.storage.index_not_found': 'trusted row lookup requires a declared relational index',
28
32
  'sync.storage.index_not_materialized': 'trusted row lookup requires a materialized relational table',
@@ -31,14 +35,17 @@ const STORAGE_QUERY_MESSAGES = {
31
35
  };
32
36
  /**
33
37
  * Host-only query error. Messages never include identifiers, values, SQL,
34
- * paths, or row data; callers branch on `code`, never message text.
38
+ * paths, or row data; callers branch on `code`, never message text. The
39
+ * schema-readiness codes name the offending table and column in `details`.
35
40
  */
36
41
  export class StorageQueryError extends Error {
37
42
  name = 'StorageQueryError';
38
43
  code;
39
- constructor(code) {
44
+ details;
45
+ constructor(code, details) {
40
46
  super(STORAGE_QUERY_MESSAGES[code]);
41
47
  this.code = code;
48
+ this.details = details;
42
49
  }
43
50
  }
44
51
  function driverError(error) {
@@ -1,5 +1,5 @@
1
1
  import type { CompiledTable, IndexSchema } from './schema.js';
2
- import type { IndexRowScanQuery, RowScanQuery } from './storage.js';
2
+ import type { AuthoritativeQueryRequest, AuthoritativeQueryResult, IndexRowScanQuery, RowScanQuery, StorageTransaction } from './storage.js';
3
3
  /**
4
4
  * Fail loudly instead of making an unsupported unscoped scan look empty.
5
5
  * Returns the first scope variable in sorted order — the one every adapter
@@ -8,3 +8,10 @@ import type { IndexRowScanQuery, RowScanQuery } from './storage.js';
8
8
  export declare function assertScopeIndexedScan(query: RowScanQuery): string;
9
9
  /** Validate and resolve one exact trusted-host relational index lookup. */
10
10
  export declare function resolveIndexRowScan(table: CompiledTable, query: IndexRowScanQuery): IndexSchema;
11
+ /**
12
+ * §6.7/§6.8 transaction-bound registered query for every host hook that runs
13
+ * inside a push transaction. A storage transaction without the capability
14
+ * fails; the call never falls back to `ServerStorage.queryAuthoritative`,
15
+ * which reads outside the transaction.
16
+ */
17
+ export declare function transactionQuery(tx: StorageTransaction, query: AuthoritativeQueryRequest): Promise<AuthoritativeQueryResult>;
@@ -34,3 +34,15 @@ export function resolveIndexRowScan(table, query) {
34
34
  }
35
35
  return index;
36
36
  }
37
+ /**
38
+ * §6.7/§6.8 transaction-bound registered query for every host hook that runs
39
+ * inside a push transaction. A storage transaction without the capability
40
+ * fails; the call never falls back to `ServerStorage.queryAuthoritative`,
41
+ * which reads outside the transaction.
42
+ */
43
+ export async function transactionQuery(tx, query) {
44
+ if (tx.queryAuthoritative === undefined) {
45
+ throw new StorageQueryError('sync.storage.transaction_query_unsupported');
46
+ }
47
+ return tx.queryAuthoritative(query);
48
+ }
package/dist/storage.d.ts CHANGED
@@ -381,6 +381,19 @@ export interface StorageTransaction {
381
381
  * read-your-own-writes semantics. It is not reachable from SSP2 requests.
382
382
  */
383
383
  scanRowsByIndex?(query: IndexRowScanQuery): Promise<StoredRow[]>;
384
+ /**
385
+ * Optional transaction-bound registered query (§6.7, §6.8): the relation
386
+ * rewrite of `ServerStorage.queryAuthoritative`, bound to this
387
+ * transaction's partition and executed on this transaction's connection.
388
+ * A hook inside the push transaction therefore reads the transaction's
389
+ * snapshot and never waits for a second connection. The push layer has
390
+ * already evaluated the serve gate on this transaction, so the call takes
391
+ * no checkpoint declarations. SQLite and PostgreSQL return the staged
392
+ * writes of this transaction. D1 buffers writes until commit and fails
393
+ * with `sync.storage.query_over_staged_writes` when the query reads a
394
+ * table this transaction has written.
395
+ */
396
+ queryAuthoritative?(query: AuthoritativeQueryRequest): Promise<AuthoritativeQueryResult>;
384
397
  /**
385
398
  * Serialize every push apply for this partition before any operation read,
386
399
  * validation, merge, or write. The push layer re-checks idempotency only
@@ -575,17 +588,18 @@ export interface ServerStorage {
575
588
  * every generated app-table relation with a partition-filtered relation and
576
589
  * return rows plus maxCommitSeq from one consistent database snapshot.
577
590
  *
578
- * The storage serializes this call behind an open transaction on a
579
- * single-connection executor (SQLite, PGlite). A validator runs inside the
580
- * push transaction, so on that executor a validator that calls
581
- * `queryAuthoritative` waits on the transaction it is already inside and
582
- * the push never completes. A pool-backed executor (`pg`, `Bun.sql`) hands
583
- * the call a second connection instead: the validator does not deadlock,
584
- * but it reads committed state without the candidate write. On either
585
- * executor read candidate state through the whole-commit `CommitValidator`
586
- * reader (§6.8). A call made from any other context waits for the open
587
- * transaction to finish on a single-connection executor, and runs
588
- * concurrently on a pool-backed one.
591
+ * Do not call this method from a hook that runs inside the push
592
+ * transaction (a row validator, the whole-commit validator, or a reaction
593
+ * planner). On a single-connection executor (SQLite, PGlite) the storage
594
+ * serializes this call behind the open transaction, so the hook waits on
595
+ * the transaction it is inside and the push never completes. A pool-backed
596
+ * executor (`pg`, `Bun.sql`) runs the call on a second connection: it reads
597
+ * committed state without the transaction's staged writes, and it waits
598
+ * forever when the push transactions hold every pool connection. Those
599
+ * hooks call the transaction-bound `queryAuthoritative` on
600
+ * `ValidateContext` or on the candidate-state reader instead (§6.7, §6.8).
601
+ * A call from any other context waits for the open transaction on a
602
+ * single-connection executor and runs concurrently on a pool-backed one.
589
603
  */
590
604
  queryAuthoritative?(partition: string, query: AuthoritativeQueryRequest, checkpoints?: readonly CheckpointDeclaration[]): Promise<AuthoritativeQueryResult>;
591
605
  /**
@@ -14,6 +14,7 @@
14
14
  * object — zero cost, the events-seam discipline.
15
15
  */
16
16
  import { type RejectionDetails, type RowColumn, type RowValue, type ScopeMap } from '@syncular/core';
17
+ import type { AuthoritativeQueryRequest, AuthoritativeQueryResult } from './storage.js';
17
18
  /**
18
19
  * §6.7 reserved code prefixes. A host validator code MUST NOT start with
19
20
  * any of these: they namespace the protocol's own error families (§10.2)
@@ -81,6 +82,15 @@ export interface CommitValidationScanInput {
81
82
  export interface CommitValidationReader {
82
83
  getRow(table: string, rowId: string): Promise<CommitValidationRow | undefined>;
83
84
  scanRows(input: CommitValidationScanInput): Promise<CommitValidationRow[]>;
85
+ /**
86
+ * Run a generated registered query on the commit transaction (§6.8). The
87
+ * request has the shape `ServerStorage.queryAuthoritative` takes; the
88
+ * storage binds every relation to the commit's partition. SQLite and
89
+ * PostgreSQL return the final candidate state; D1 fails with
90
+ * `sync.storage.query_over_staged_writes` when the query reads a table the
91
+ * commit has written.
92
+ */
93
+ queryAuthoritative(query: AuthoritativeQueryRequest): Promise<AuthoritativeQueryResult>;
84
94
  }
85
95
  export interface ValidateCommitInput {
86
96
  readonly clientId: string;
@@ -98,21 +108,29 @@ export type CommitValidator = (input: ValidateCommitInput) => void | Promise<voi
98
108
  /**
99
109
  * Ambient context a validator may consult (§6.7).
100
110
  *
101
- * A validator runs inside the push transaction. On a single-connection
102
- * executor (SQLite, PGlite) the storage serializes authoritative queries
103
- * behind that transaction, so a validator that calls `queryAuthoritative`
104
- * waits for the transaction it is already inside and the push never
105
- * completes. On a pool-backed executor (`pg`, `Bun.sql`) the query takes a
106
- * second connection and returns committed state without the candidate write.
107
- * Either way a rule that must read rows belongs in the whole-commit
108
- * `CommitValidator`, whose reader runs get and scan operations on the same
109
- * transaction (§6.8).
111
+ * A validator runs inside the push transaction. It MUST NOT call
112
+ * `ServerStorage.queryAuthoritative`: on a single-connection executor
113
+ * (SQLite, PGlite) that call waits for the transaction the validator is
114
+ * inside and the push never completes, and on a pool-backed executor (`pg`,
115
+ * `Bun.sql`) it reads committed state without the staged writes, or waits
116
+ * forever once push transactions hold every pool connection. An authority
117
+ * read calls `queryAuthoritative` on this context instead.
110
118
  */
111
119
  export interface ValidateContext {
112
120
  /** Host-authenticated actor (§1.1) performing the write. */
113
121
  readonly actorId: string;
114
122
  /** The partition (§1.1) the commit targets. */
115
123
  readonly partition: string;
124
+ /**
125
+ * Run a generated registered query on the push transaction (§6.7). The
126
+ * request has the shape `ServerStorage.queryAuthoritative` takes; the
127
+ * storage binds every relation to the commit's partition. SQLite and
128
+ * PostgreSQL return the transaction's snapshot, including the operations
129
+ * staged before this one; D1 fails with
130
+ * `sync.storage.query_over_staged_writes` when the query reads a table the
131
+ * commit has written.
132
+ */
133
+ readonly queryAuthoritative: (query: AuthoritativeQueryRequest) => Promise<AuthoritativeQueryResult>;
116
134
  }
117
135
  /**
118
136
  * A per-table validation hook (§6.7). Called once per push operation on
@@ -123,12 +141,12 @@ export interface ValidateContext {
123
141
  * `sync.constraint_violation` code (a validator SHOULD throw
124
142
  * `ValidationRejection` to control the code). MUST NOT mutate the row.
125
143
  *
126
- * The hook runs inside the push transaction and cannot read candidate rows
127
- * from an authoritative query: on a single-connection executor
128
- * `queryAuthoritative` serializes behind that transaction and the push never
129
- * completes, and on a pool-backed executor it reads committed state without
130
- * the candidate write. Read rows with the whole-commit `CommitValidator`
131
- * reader instead (§6.8).
144
+ * The hook runs inside the push transaction. An authority read uses
145
+ * `ctx.queryAuthoritative`, which executes on that transaction; calling
146
+ * `ServerStorage.queryAuthoritative` from here deadlocks on a
147
+ * single-connection executor and reads committed state on a pool-backed one.
148
+ * A rule over sibling operations belongs in the whole-commit
149
+ * `CommitValidator` (§6.8).
132
150
  */
133
151
  export type Validator = (op: ValidateOperation, ctx: ValidateContext) => void | Promise<void>;
134
152
  /** Table name → validator (§6.7). Absent tables are unvalidated. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@syncular/server",
3
- "version": "0.23.0",
3
+ "version": "0.24.0",
4
4
  "description": "Syncular server: handleSyncRequest + storage/auth interfaces for the sync protocol",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Benjamin Kniffler",
@@ -68,7 +68,7 @@
68
68
  "verify:node": "cd ../.. && bun build ./packages/server/test/sqlite-runtime/verify-node.mjs --target=node --conditions=bun --outfile=./packages/server/.verify-node.built.mjs && node ./packages/server/.verify-node.built.mjs"
69
69
  },
70
70
  "dependencies": {
71
- "@syncular/core": "0.23.0"
71
+ "@syncular/core": "0.24.0"
72
72
  },
73
73
  "devDependencies": {
74
74
  "@electric-sql/pglite": "^0.5.4"