@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.
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
@@ -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) {
@@ -510,6 +557,26 @@ _a = D1Transaction;
510
557
  * binds one per app column plus the five `_sync_*` meta columns.
511
558
  */
512
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
+ ];
513
580
  export class D1ServerStorage {
514
581
  #db;
515
582
  #pushApplySerialized;
@@ -602,6 +669,33 @@ export class D1ServerStorage {
602
669
  const marker = await read(this.#db.prepare('SELECT schema_version, layouts FROM sync_schema_meta WHERE id=1'));
603
670
  let claim = await read(this.#db.prepare('SELECT * FROM sync_schema_migration WHERE id=1'));
604
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
+ }
605
699
  this.#tables = schema.tables;
606
700
  this.#schemaVersion = schema.version;
607
701
  return { complete: true, statementsExecuted };
@@ -983,7 +1077,7 @@ export class D1ServerStorage {
983
1077
  if (table === undefined)
984
1078
  throw new StorageQueryError('sync.storage.schema_changed');
985
1079
  return table;
986
- }, this.#pushApplySerialized);
1080
+ }, (query) => this.#prepareAuthoritativeQuery(partition, query), this.#pushApplySerialized);
987
1081
  }
988
1082
  async getMaxCommitSeq(partition) {
989
1083
  const row = await this.#db
@@ -997,39 +1091,13 @@ export class D1ServerStorage {
997
1091
  // re-checks the published marker inside this same batch, so condition 2
998
1092
  // is already evaluated with the query (§2.4 D1 schema readiness).
999
1093
  const db = this.#schemaDatabase();
1094
+ return queryAuthoritativeOn(db, partition, this.#prepareAuthoritativeQuery(partition, query));
1095
+ }
1096
+ #prepareAuthoritativeQuery(partition, query) {
1000
1097
  if (this.#tables === undefined) {
1001
1098
  throw new Error('ensureSchema(schema) must run before registered queries');
1002
1099
  }
1003
- const prepared = bindAuthoritativePartition(prepareAuthoritativeQuery(query.plan, query.params, query.tables, this.#tables), partition);
1004
- const results = await db.batch([
1005
- db.prepare(prepared.sql).bind(...prepared.params),
1006
- db
1007
- .prepare('SELECT max_commit_seq FROM sync_partitions WHERE partition=?')
1008
- .bind(partition),
1009
- ]);
1010
- const rowsResult = results[0];
1011
- const cursorResult = results[1];
1012
- if (typeof rowsResult !== 'object' ||
1013
- rowsResult === null ||
1014
- !('results' in rowsResult) ||
1015
- !Array.isArray(rowsResult.results) ||
1016
- typeof cursorResult !== 'object' ||
1017
- cursorResult === null ||
1018
- !('results' in cursorResult) ||
1019
- !Array.isArray(cursorResult.results)) {
1020
- throw new Error('D1 registered query returned an invalid batch result');
1021
- }
1022
- const cursor = cursorResult.results[0];
1023
- const maxCommitSeq = typeof cursor === 'object' &&
1024
- cursor !== null &&
1025
- 'max_commit_seq' in cursor &&
1026
- typeof cursor.max_commit_seq === 'number'
1027
- ? cursor.max_commit_seq
1028
- : 0;
1029
- return {
1030
- rows: rowsResult.results.filter((row) => typeof row === 'object' && row !== null),
1031
- maxCommitSeq,
1032
- };
1100
+ return bindAuthoritativePartition(prepareAuthoritativeQuery(query.plan, query.params, query.tables, this.#tables), partition);
1033
1101
  }
1034
1102
  async getPartitionLogEpoch(partition) {
1035
1103
  const row = await this.#db
@@ -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];
@@ -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. */
@@ -5,7 +5,7 @@ import { serveNotReadyError } from './readiness.js';
5
5
  import { bindAuthoritativePartition, postgresPlaceholders, prepareAuthoritativeQuery, } from './authoritative-query.js';
6
6
  import { syncError } from './errors.js';
7
7
  import { asBytes, asNumber, } from './pg-executor.js';
8
- import { assertPhysicalColumns, commitWindowPageSql, deleteRowSql, dropTableDdl, indexRowPageStatement, layoutsOf, migratePayload, parseLayouts, retiredTableNames, rewritePlan, rewriteRowSql, rewriteValues, SCHEMA_META_DDL_POSTGRES, scanRowPageSql, schemaDdl, selectRowScopesSql, selectRowSql, selectRowsForRewriteSql, upsertSql, upsertValues, } from './relational-rows.js';
8
+ import { assertPhysicalColumns, assertStoredLayouts, commitWindowPageSql, deleteRowSql, dropTableDdl, indexRowPageStatement, layoutsOf, migratePayload, parseLayouts, retiredTableNames, rewritePlan, rewriteRowSql, rewriteValues, SCHEMA_META_DDL_POSTGRES, scanRowPageSql, schemaDdl, selectRowScopesSql, selectRowSql, selectRowsForRewriteSql, upsertSql, upsertValues, } from './relational-rows.js';
9
9
  import { matchesEffective } from './scopes.js';
10
10
  import { isPostgresConstraintError, StorageConstraintError, } from './storage-errors.js';
11
11
  import { assertScopeIndexedScan, resolveIndexRowScan } from './storage-query.js';
@@ -419,9 +419,22 @@ async function getPushResultOn(q, partition, clientId, clientCommitId) {
419
419
  throw syncError('sync.idempotency_cache_miss', 'persisted push result unreadable (§6.3)');
420
420
  }
421
421
  }
422
+ /** Run one partition-bound registered query and the cursor read on `client`. */
423
+ async function queryAuthoritativeOn(client, partition, prepared) {
424
+ const result = await client.query(postgresPlaceholders(prepared.sql), prepared.params);
425
+ const cursor = await client.query('SELECT max_commit_seq FROM sync_partitions WHERE partition=$1', [partition]);
426
+ return {
427
+ rows: result.rows,
428
+ maxCommitSeq: cursor.rows[0] === undefined
429
+ ? 0
430
+ : asNumber(cursor.rows[0].max_commit_seq),
431
+ };
432
+ }
422
433
  class PostgresTransaction {
423
434
  #client;
424
435
  #partition;
436
+ /** Storage-owned relation rewrite, bound to this transaction's partition. */
437
+ #prepareQuery;
425
438
  /** Schema version this transaction writes; rides every appended commit row. */
426
439
  #writerVersion;
427
440
  #resolveTable;
@@ -430,7 +443,7 @@ class PostgresTransaction {
430
443
  /** Resolves/rejects the `transaction(fn)` wrapper (see `begin`). */
431
444
  #resolve;
432
445
  #reject;
433
- constructor(client, partition, writerVersion, resolveTable, resolve, reject) {
446
+ constructor(client, partition, writerVersion, resolveTable, prepareQuery, resolve, reject) {
434
447
  // One pinned connection carries one statement at a time: node-postgres
435
448
  // warns about a concurrently issued second query today and rejects it in
436
449
  // node-postgres 9. A whole-commit validator that fans independent reads
@@ -448,6 +461,7 @@ class PostgresTransaction {
448
461
  this.#partition = partition;
449
462
  this.#writerVersion = writerVersion;
450
463
  this.#resolveTable = resolveTable;
464
+ this.#prepareQuery = prepareQuery;
451
465
  this.#resolve = resolve;
452
466
  this.#reject = reject;
453
467
  }
@@ -517,6 +531,12 @@ class PostgresTransaction {
517
531
  this.#assertOpen();
518
532
  return scanRowsByIndexOn(this.#client, this.#resolveTable(query.table), this.#partition, query);
519
533
  }
534
+ queryAuthoritative(query) {
535
+ this.#assertOpen();
536
+ // Runs on this transaction's pinned client, so the read observes every
537
+ // staged row and never waits for a second pool connection.
538
+ return queryAuthoritativeOn(this.#client, this.#partition, this.#prepareQuery(query));
539
+ }
520
540
  async lockPartitionForPush() {
521
541
  this.#assertOpen();
522
542
  await lockPartitionOn(this.#client, this.#partition);
@@ -775,57 +795,32 @@ FOR EACH ROW EXECUTE FUNCTION syncular_writer_fence()`);
775
795
  throw new Error(`stored schema version ${stored} is newer than the configured schema (${schema.version}) — refusing to run an older server against a migrated database`);
776
796
  }
777
797
  if (stored !== undefined && stored === schema.version) {
778
- // Version equality is not layout equality: a marker written by another
779
- // build at the same version describes rows the running codec cannot
780
- // decode. Compare the stored layouts instead of trusting the number.
781
- const storedLayouts = parseLayouts(typeof marker.rows[0]?.layouts === 'string'
798
+ assertStoredLayouts(schema, typeof marker.rows[0]?.layouts === 'string'
782
799
  ? marker.rows[0].layouts
783
800
  : undefined);
784
- const configuredLayouts = parseLayouts(layoutsOf(schema));
785
- let mismatch;
786
- for (const [tableName, columns] of Object.entries(configuredLayouts)) {
787
- const storedTable = storedLayouts[tableName];
788
- const columnCount = Math.max(columns.length, storedTable?.length ?? 0);
789
- for (let index = 0; index < columnCount; index++) {
790
- const expected = columns[index];
791
- const actual = storedTable?.[index];
792
- if (expected !== undefined &&
793
- actual !== undefined &&
794
- actual.name === expected.name &&
795
- actual.type === expected.type &&
796
- actual.nullable === expected.nullable) {
797
- continue;
798
- }
799
- mismatch = `table ${JSON.stringify(tableName)} column ${JSON.stringify(expected?.name ?? actual?.name ?? '')}`;
800
- break;
801
- }
802
- if (mismatch !== undefined)
803
- break;
804
- }
805
- if (mismatch === undefined) {
806
- for (const tableName of Object.keys(storedLayouts)) {
807
- if (!(tableName in configuredLayouts)) {
808
- mismatch = `table ${JSON.stringify(tableName)}`;
809
- break;
810
- }
811
- }
812
- }
813
- if (mismatch !== undefined) {
814
- 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`);
815
- }
816
- // The persisted layouts describe the codec's app columns only: read the
817
- // physical tables so a same-version database missing a
818
- // storage-internal column is refused at startup instead of failing at
819
- // the first write. Resolve through `to_regclass`, which follows the
820
- // session's `search_path` exactly as the unqualified row queries do —
801
+ // Resolve through `to_regclass`, which follows the session's
802
+ // `search_path` exactly as the unqualified row queries do:
821
803
  // `current_schema()` names only the first existing schema, so it would
822
804
  // reject a table that resolves later in the path.
823
805
  for (const table of schema.tables.values()) {
824
- const { rows } = await this.#exec.query(`SELECT attribute.attname AS column_name
806
+ const { rows } = await this.#exec.query(`SELECT attribute.attname AS name,
807
+ format_type(attribute.atttypid, attribute.atttypmod) AS type,
808
+ attribute.attnotnull AS not_null,
809
+ (SELECT key.position::int
810
+ FROM pg_index pk,
811
+ unnest(pk.indkey::int2[]) WITH ORDINALITY
812
+ AS key(attnum, position)
813
+ WHERE pk.indrelid = attribute.attrelid AND pk.indisprimary
814
+ AND key.attnum = attribute.attnum) AS pk
825
815
  FROM pg_attribute attribute
826
816
  WHERE attribute.attrelid = to_regclass($1)
827
817
  AND attribute.attnum > 0 AND NOT attribute.attisdropped`, [table.name]);
828
- assertPhysicalColumns(table, new Set(rows.map((r) => r.column_name)));
818
+ assertPhysicalColumns(table, rows.map((row) => ({
819
+ name: row.name,
820
+ type: row.type,
821
+ notNull: row.not_null,
822
+ primaryKeyPosition: row.pk ?? 0,
823
+ })), 'postgres');
829
824
  }
830
825
  }
831
826
  if (stored === undefined || stored < schema.version) {
@@ -990,7 +985,7 @@ FOR EACH ROW EXECUTE FUNCTION syncular_writer_fence()`);
990
985
  });
991
986
  const done = this.#exec
992
987
  .transaction(async (client) => {
993
- const tx = new PostgresTransaction(client, partition, this.#schemaVersion ?? 0, (name) => this.table(name), resolveScope, rejectScope);
988
+ const tx = new PostgresTransaction(client, partition, this.#schemaVersion ?? 0, (name) => this.table(name), (query) => this.#prepareAuthoritativeQuery(partition, query), resolveScope, rejectScope);
994
989
  resolveReady(tx);
995
990
  // Hold the transaction open until commit()/rollback() settles `scope`.
996
991
  await scope;
@@ -1008,11 +1003,14 @@ FOR EACH ROW EXECUTE FUNCTION syncular_writer_fence()`);
1008
1003
  const { rows } = await this.#exec.query('SELECT max_commit_seq FROM sync_partitions WHERE partition=$1', [partition]);
1009
1004
  return rows[0] === undefined ? 0 : asNumber(rows[0].max_commit_seq);
1010
1005
  }
1011
- async queryAuthoritative(partition, query, checkpoints) {
1006
+ #prepareAuthoritativeQuery(partition, query) {
1012
1007
  if (this.#tables === undefined) {
1013
1008
  throw new Error('ensureSchema(schema) must run before registered queries');
1014
1009
  }
1015
- const prepared = bindAuthoritativePartition(prepareAuthoritativeQuery(query.plan, query.params, query.tables, this.#tables), partition);
1010
+ return bindAuthoritativePartition(prepareAuthoritativeQuery(query.plan, query.params, query.tables, this.#tables), partition);
1011
+ }
1012
+ async queryAuthoritative(partition, query, checkpoints) {
1013
+ const prepared = this.#prepareAuthoritativeQuery(partition, query);
1016
1014
  return this.#exec.transaction(async (client) => {
1017
1015
  await client.query('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');
1018
1016
  // RFC 0007: evaluate the gate inside this pinned snapshot, so a
@@ -1024,14 +1022,7 @@ FOR EACH ROW EXECUTE FUNCTION syncular_writer_fence()`);
1024
1022
  const refusal = serveGateRefusal(gate, runningVersion, checkpoints);
1025
1023
  if (refusal !== undefined)
1026
1024
  throw serveNotReadyError(refusal);
1027
- const result = await client.query(postgresPlaceholders(prepared.sql), prepared.params);
1028
- const cursor = await client.query('SELECT max_commit_seq FROM sync_partitions WHERE partition=$1', [partition]);
1029
- return {
1030
- rows: result.rows,
1031
- maxCommitSeq: cursor.rows[0] === undefined
1032
- ? 0
1033
- : asNumber(cursor.rows[0].max_commit_seq),
1034
- };
1025
+ return queryAuthoritativeOn(client, partition, prepared);
1035
1026
  });
1036
1027
  }
1037
1028
  async getPartitionLogEpoch(partition) {
package/dist/push.js CHANGED
@@ -26,6 +26,7 @@ import { authorizeWrite, renderScopeValue, storedScopesForRow } from './scopes.j
26
26
  import { decodeColumnVersions, encodeColumnVersions } from './relational-rows.js';
27
27
  import { serveGateRefusal } from './storage.js';
28
28
  import { StorageConstraintError } from './storage-errors.js';
29
+ import { transactionQuery } from './storage-query.js';
29
30
  import { serveNotReadyError } from './readiness.js';
30
31
  import { CommitValidationRejection, toValidateRow, ValidationRejection, } from './validate.js';
31
32
  /**
@@ -86,7 +87,7 @@ function conflictColumnsBitmap(columnCount, marked) {
86
87
  * A no-op for tables with no validator — the `undefined` short-circuit
87
88
  * keeps the feature zero-cost when off.
88
89
  */
89
- async function runValidator(validators, table, op, rowId, values, storedValues, opIndex, partition, actorId) {
90
+ async function runValidator(validators, table, op, rowId, values, storedValues, opIndex, tx, partition, actorId) {
90
91
  const validator = validators?.[table.name];
91
92
  if (validator === undefined)
92
93
  return undefined;
@@ -101,7 +102,11 @@ async function runValidator(validators, table, op, rowId, values, storedValues,
101
102
  stored: storedValues !== undefined
102
103
  ? toValidateRow(table.columns, storedValues)
103
104
  : undefined,
104
- }, { actorId, partition });
105
+ }, {
106
+ actorId,
107
+ partition,
108
+ queryAuthoritative: (query) => transactionQuery(tx, query),
109
+ });
105
110
  }
106
111
  catch (error) {
107
112
  if (error instanceof ValidationRejection) {
@@ -199,7 +204,7 @@ async function applyOperation(tx, schema, resolved, op, opIndex, blobCtx, crdtMe
199
204
  // stored = the row about to be removed). Only reached for an existing
200
205
  // row — an absent-row delete is an idempotent no-op above.
201
206
  const storedValues = decodeRow(table.columns, stored.payload);
202
- const deleteReject = await runValidator(validators, table, 'delete', op.rowId, undefined, storedValues, opIndex, partition, actorId);
207
+ const deleteReject = await runValidator(validators, table, 'delete', op.rowId, undefined, storedValues, opIndex, tx, partition, actorId);
203
208
  if (deleteReject !== undefined)
204
209
  return deleteReject;
205
210
  await tx.deleteRow(op.table, op.rowId);
@@ -330,7 +335,7 @@ async function applyOperation(tx, schema, resolved, op, opIndex, blobCtx, crdtMe
330
335
  // §6.7: validate the merged row that will persist — for a crdt column
331
336
  // the validator sees the MERGED value (§5.10.3), the state the store
332
337
  // holds, not the raw pushed update.
333
- const updateReject = await runValidator(validators, table, 'upsert', op.rowId, values, storedValues, opIndex, partition, actorId);
338
+ const updateReject = await runValidator(validators, table, 'upsert', op.rowId, values, storedValues, opIndex, tx, partition, actorId);
334
339
  if (updateReject !== undefined)
335
340
  return updateReject;
336
341
  const columnVersions = encodeColumnVersions(nextVersions);
@@ -412,7 +417,7 @@ async function applyOperation(tx, schema, resolved, op, opIndex, blobCtx, crdtMe
412
417
  // §6.7: validate the insert row (stored = undefined, so a validator can
413
418
  // distinguish create from update); crdt columns are already merged
414
419
  // against the empty document.
415
- const insertReject = await runValidator(validators, table, 'upsert', op.rowId, values, undefined, opIndex, partition, actorId);
420
+ const insertReject = await runValidator(validators, table, 'upsert', op.rowId, values, undefined, opIndex, tx, partition, actorId);
416
421
  if (insertReject !== undefined)
417
422
  return insertReject;
418
423
  // §5.2: an explicit insert recreates — the tombstone goes with it.
@@ -526,6 +531,7 @@ function commitValidationReader(tx, schema) {
526
531
  serverVersion: stored.serverVersion,
527
532
  }));
528
533
  },
534
+ queryAuthoritative: (query) => transactionQuery(tx, query),
529
535
  };
530
536
  }
531
537
  async function runCommitValidator(validator, tx, schema, clientId, clientCommitId, actorId, partition, operations) {
@@ -682,7 +688,7 @@ async function enforceReferences(tx, schema, staged, clientOpCount, cascadeLimit
682
688
  const syntheticIndex = clientOpCount + originByIndex.length;
683
689
  const storedValues = decodeRow(child.columns, childRow.payload);
684
690
  if (reference.onDelete === 'CASCADE') {
685
- const reject = await runValidator(validators, child, 'delete', childRow.rowId, undefined, storedValues, item.originOpIndex, partition, actorId);
691
+ const reject = await runValidator(validators, child, 'delete', childRow.rowId, undefined, storedValues, item.originOpIndex, tx, partition, actorId);
686
692
  if (reject !== undefined) {
687
693
  return { kind: 'terminate', record: reject.record };
688
694
  }
@@ -706,7 +712,7 @@ async function enforceReferences(tx, schema, staged, clientOpCount, cascadeLimit
706
712
  else {
707
713
  const values = [...storedValues];
708
714
  values[reference.columnIndex] = null;
709
- const reject = await runValidator(validators, child, 'upsert', childRow.rowId, values, storedValues, item.originOpIndex, partition, actorId);
715
+ const reject = await runValidator(validators, child, 'upsert', childRow.rowId, values, storedValues, item.originOpIndex, tx, partition, actorId);
710
716
  if (reject !== undefined) {
711
717
  return { kind: 'terminate', record: reject.record };
712
718
  }
@@ -75,26 +75,38 @@ export declare function columnSqlType(column: RowColumn, dialect: RelationalDial
75
75
  * materialized), version, scopes, payload.
76
76
  */
77
77
  export declare function tableColumnNames(table: CompiledTable): string[];
78
+ /** One physical column of a synced table, read from the database catalog. */
79
+ export interface PhysicalColumn {
80
+ readonly name: string;
81
+ /** Declared type (SQLite) or `format_type` (PostgreSQL); compared case-insensitively. */
82
+ readonly type: string;
83
+ readonly notNull: boolean;
84
+ /** 1-based position in the primary key; 0 when the column is not in it. */
85
+ readonly primaryKeyPosition: number;
86
+ }
78
87
  /**
79
- * The physical column set the row codec and the scope index read for one
80
- * configured table. The persisted marker's layouts describe the codec's app
88
+ * Refuse a same-version database whose physical table the row store cannot
89
+ * read or write. The persisted marker's layouts describe the codec's app
81
90
  * columns only and its version number is the schema's, not the database's, so
82
- * a same-version database that is missing a storage-internal meta column (a
83
- * version-only bump whose ALTER never ran) still has to be refused: compare
84
- * the physical table and fail closed without writing a migration.
91
+ * a same-version database whose storage-internal meta columns drifted (a
92
+ * version-only bump whose ALTER never ran, or a table written by another
93
+ * build) is only visible in the catalog. Every configured column must exist;
94
+ * the meta columns must also carry the type, nullability, and primary-key
95
+ * position `createTableDdl` declares. App-column types and nullability stay
96
+ * with the stored layouts: migration-added app columns are nullable by design.
97
+ * Fails closed without writing a migration.
85
98
  *
86
- * `existingColumns` must be resolved the way the unqualified row queries
87
- * resolve the table — on PostgreSQL, through the session `search_path`, not
99
+ * `columns` must be resolved the way the unqualified row queries resolve the
100
+ * table: on PostgreSQL, through the session `search_path`, not
88
101
  * `current_schema()`. A search path whose first schema is not where the
89
102
  * configured tables live is outside the supported contract: DDL is created in
90
103
  * that first schema, so `ensureSchema` already writes shadow tables there.
91
104
  *
92
- * Scope: this checks that the columns EXIST. Stored types, nullability, and
93
- * non-column storage internals (the tombstone bookkeeping) stay outside it,
94
- * because a wrong type or nullability is a codec-compatibility question the
95
- * persisted layouts answer for app columns.
105
+ * Core storage tables (`sync_tombstones`, `sync_commits`, ...) are outside
106
+ * this check: every open re-applies their `CREATE TABLE IF NOT EXISTS` DDL
107
+ * and column additions, independently of the version marker.
96
108
  */
97
- export declare function assertPhysicalColumns(table: CompiledTable, existingColumns: ReadonlySet<string>): void;
109
+ export declare function assertPhysicalColumns(table: CompiledTable, columns: readonly PhysicalColumn[], dialect: RelationalDialect): void;
98
110
  /** CREATE TABLE IF NOT EXISTS for one app table. */
99
111
  export declare function createTableDdl(table: CompiledTable, dialect: RelationalDialect): string;
100
112
  /**
@@ -249,6 +261,13 @@ export type StoredLayouts = Record<string, readonly StoredColumnLayout[]>;
249
261
  /** The layouts JSON persisted alongside the schema version marker. */
250
262
  export declare function layoutsOf(schema: CompiledSchema): string;
251
263
  export declare function parseLayouts(json: string | null | undefined): StoredLayouts;
264
+ /**
265
+ * Version equality is not layout equality: a marker written by another build
266
+ * at the same version describes rows the running codec cannot decode. Refuse
267
+ * when the stored layouts disagree with the configured schema, naming the
268
+ * first table (and column) that differs.
269
+ */
270
+ export declare function assertStoredLayouts(schema: CompiledSchema, storedJson: string | null | undefined): void;
252
271
  /**
253
272
  * Tables present in the stored layout but absent from the configured head
254
273
  * schema. A schema-version bump retires their relational current-row tables;