@syncular/server 0.37.0 → 0.39.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.
@@ -1,11 +1,11 @@
1
1
  import { validateCommitPruneQuery } from './prune.js';
2
2
  import { StorageQueryError } from './storage-errors.js';
3
- import { serveGateRefusal } from './storage.js';
3
+ import { PARTITION_TOUCH_INTERVAL_MS, serveGateRefusal } from './storage.js';
4
4
  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, assertStoredLayouts, COMMIT_WINDOW_BATCH_SQL, LATEST_SCOPED_CHANGE_BATCH_SQL, deleteRowSql, dropTableDdl, indexRowPageStatement, layoutsOf, migratePayload, parseLayouts, quoteIdent, retiredTableNames, rewritePlan, rewriteRowSql, rewriteValues, SCHEMA_META_DDL_POSTGRES, SYNC_COLUMN_VERSIONS_COLUMN, SYNC_PARTITION_COLUMN, SYNC_PAYLOAD_COLUMN, SYNC_ROW_ID_COLUMN, SYNC_SCOPES_COLUMN, SYNC_VERSION_COLUMN, scanRowBatchSql, scanRowPageSql, schemaDdl, selectRowScopesSql, selectRowSql, prefetchRowsSql, selectRowsForRewriteSql, upsertSql, upsertValues, } from './relational-rows.js';
8
+ import { assertPhysicalColumns, assertStoredLayouts, COMMIT_WINDOW_BATCH_SQL, LATEST_SCOPED_CHANGE_BATCH_SQL, deleteRowSql, dropTableDdl, indexRowPageStatement, layoutsOf, migratePayload, parseLayouts, quoteIdent, tableColumnNames, retiredTableNames, rewritePlan, columnSqlType, rewriteValues, SCHEMA_META_DDL_POSTGRES, SYNC_COLUMN_VERSIONS_COLUMN, SYNC_PARTITION_COLUMN, SYNC_PAYLOAD_COLUMN, SYNC_ROW_ID_COLUMN, SYNC_SCOPES_COLUMN, SYNC_VERSION_COLUMN, scanRowBatchSql, scanRowPageSql, indexSignature, schemaDdl, selectRowScopesSql, selectRowSql, prefetchRowsSql, selectRowsForRewriteSql, upsertSql, upsertValues, } from './relational-rows.js';
9
9
  import { matchesEffective, sha256Hex } from './scopes.js';
10
10
  import { isPostgresConstraintError, StorageConstraintError, } from './storage-errors.js';
11
11
  import { assertScopeIndexedScan, resolveIndexRowScan } from './storage-query.js';
@@ -22,7 +22,7 @@ import { assertScopeIndexedScan, resolveIndexRowScan } from './storage-query.js'
22
22
  * their table.
23
23
  *
24
24
  * `sync_row_scopes_by_row (partition, tbl, row_id)` is the one secondary
25
- * scope index: `writeRowOn` and `deleteRow` replace a row's scope entries
25
+ * scope index: `writeRowsOn` and `deleteRow` replace a row's scope entries
26
26
  * with a `(partition, tbl, row_id)` predicate, and `row_id` is the LAST
27
27
  * column of the inverted PRIMARY KEY, so without this index that predicate
28
28
  * scans the table's whole scope range on every row written (a bulk import
@@ -368,23 +368,52 @@ function toStoredChange(record) {
368
368
  */
369
369
  async function rewriteRowsOn(q, table, oldLayout) {
370
370
  const select = selectRowsForRewriteSql(table, 'postgres');
371
- const update = rewriteRowSql(table, 'postgres');
372
- const BATCH = 500;
371
+ // One UPDATE per page: behind a network hop (Hyperdrive, a managed
372
+ // database) every statement is a round trip, and the bump holds the
373
+ // migration lock for its whole duration. The page's rows bind as one
374
+ // VALUES list with typed placeholders (`rewriteValues` order: projection
375
+ // columns, payload, partition, row id).
376
+ const types = [
377
+ ...(table.materialize
378
+ ? table.columns.map((column) => columnSqlType(column, 'postgres'))
379
+ : []),
380
+ 'BYTEA',
381
+ 'TEXT',
382
+ 'TEXT',
383
+ ];
384
+ const projected = table.materialize ? table.columns : [];
385
+ const sets = [
386
+ ...projected.map((column, i) => `${quoteIdent(column.name)}=v.c${i}`),
387
+ `${quoteIdent(SYNC_PAYLOAD_COLUMN)}=v.c${projected.length}`,
388
+ ].join(', ');
389
+ const aliases = types.map((_, i) => `c${i}`).join(', ');
390
+ const partitionAlias = `c${projected.length + 1}`;
391
+ const rowIdAlias = `c${projected.length + 2}`;
392
+ // A page binds types.length parameters per row; PGlite corrupts its state
393
+ // above 32,767 bound parameters (see PG_CHANGES_PER_STATEMENT).
394
+ const batch = Math.max(1, Math.min(500, Math.floor(30_000 / types.length)));
373
395
  let afterPartition = '';
374
396
  let afterRowId = '';
375
397
  for (;;) {
376
- const { rows } = await q.query(select, [afterPartition, afterRowId, BATCH]);
398
+ const { rows } = await q.query(select, [afterPartition, afterRowId, batch]);
377
399
  if (rows.length === 0)
378
400
  break;
379
- for (const row of rows) {
401
+ const params = [];
402
+ const values = rows.map((row) => {
380
403
  const bytes = asBytes(row.payload);
381
404
  const payload = oldLayout !== undefined
382
405
  ? migratePayload(oldLayout, table, bytes)
383
406
  : bytes;
384
- await q.query(update, rewriteValues(table, row.partition, row.row_id, payload, 'postgres'));
385
- }
407
+ const base = params.length;
408
+ params.push(...rewriteValues(table, row.partition, row.row_id, payload, 'postgres'));
409
+ return `(${types.map((type, i) => `$${base + i + 1}::${type}`).join(',')})`;
410
+ });
411
+ await q.query(`UPDATE ${quoteIdent(table.name)} AS t SET ${sets}
412
+ FROM (VALUES ${values.join(',')}) AS v(${aliases})
413
+ WHERE t.${quoteIdent(SYNC_PARTITION_COLUMN)}=v.${partitionAlias}
414
+ AND t.${quoteIdent(SYNC_ROW_ID_COLUMN)}=v.${rowIdAlias}`, params);
386
415
  const last = rows[rows.length - 1];
387
- if (last === undefined || rows.length < BATCH)
416
+ if (last === undefined || rows.length < batch)
388
417
  break;
389
418
  afterPartition = last.partition;
390
419
  afterRowId = last.row_id;
@@ -402,29 +431,54 @@ async function scanRowsByIndexOn(q, compiled, partition, query) {
402
431
  const { rows } = await q.query(statement.sql, statement.params);
403
432
  return rows.map(toStoredRow);
404
433
  }
405
- async function writeRowOn(q, compiled, partition, row) {
406
- // One statement per row write: the upsert, and the scope index synced to
407
- // the row's scope map (entries outside it deleted, missing ones inserted).
408
- // No index entry is touched twice, so the writable CTEs cannot conflict.
409
- // The scopes bind as text before the JSONB cast (see `appendCommit`).
410
- const params = upsertValues(compiled, partition, row, 'postgres');
411
- const n = params.length;
412
- await q.query(`WITH upserted AS (${upsertSql(compiled, 'postgres')}),
413
- scope AS (SELECT key, value FROM jsonb_each_text($${n + 1}::text::jsonb)),
414
- removed AS (
415
- DELETE FROM sync_row_scopes
416
- WHERE partition=$${n + 2} AND tbl=$${n + 3} AND row_id=$${n + 4}
417
- AND (var, value) NOT IN (SELECT key, value FROM scope)
418
- )
419
- INSERT INTO sync_row_scopes(partition, tbl, var, value, row_id)
420
- SELECT $${n + 2}, $${n + 3}, key, value, $${n + 4} FROM scope
421
- ON CONFLICT DO NOTHING`, [
422
- ...params,
423
- JSON.stringify(row.scopes),
424
- partition,
425
- compiled.name,
426
- row.rowId,
427
- ]);
434
+ /**
435
+ * Write rows of one table in one statement per chunk: the upsert, and the
436
+ * scope index synced to each row's scope map (entries outside it deleted,
437
+ * missing ones inserted). Row ids are distinct, so no index entry is touched
438
+ * twice and the writable CTEs cannot conflict. The scopes bind as text
439
+ * before the JSONB cast (see `appendCommit`). Every chunk is handed to `q`
440
+ * before the first one settles, so a statement issued after this call
441
+ * queues behind all of them on the transaction FIFO.
442
+ */
443
+ function writeRowsOn(q, compiled, partition, rows) {
444
+ const width = tableColumnNames(compiled).length;
445
+ // PGlite corrupts its state above 32,767 bound parameters (see
446
+ // PG_CHANGES_PER_STATEMENT).
447
+ const chunk = Math.max(1, Math.floor(30_000 / width));
448
+ const writes = [];
449
+ for (let first = 0; first < rows.length; first += chunk) {
450
+ const slice = rows.slice(first, first + chunk);
451
+ const params = slice.flatMap((entry) => entry.values);
452
+ const n = params.length;
453
+ writes.push(q.query(`WITH upserted AS (${upsertSql(compiled, 'postgres', slice.length)}),
454
+ batch AS (
455
+ SELECT row_id, scopes
456
+ FROM jsonb_to_recordset($${n + 1}::text::jsonb) AS r(row_id text, scopes jsonb)
457
+ ),
458
+ scope AS (
459
+ SELECT batch.row_id, entry.key, entry.value
460
+ FROM batch CROSS JOIN LATERAL jsonb_each_text(batch.scopes) AS entry
461
+ ),
462
+ removed AS (
463
+ DELETE FROM sync_row_scopes existing
464
+ USING batch
465
+ WHERE existing.partition=$${n + 2} AND existing.tbl=$${n + 3}
466
+ AND existing.row_id=batch.row_id
467
+ AND NOT EXISTS (
468
+ SELECT 1 FROM scope
469
+ WHERE scope.row_id=existing.row_id
470
+ AND scope.key=existing.var AND scope.value=existing.value)
471
+ )
472
+ INSERT INTO sync_row_scopes(partition, tbl, var, value, row_id)
473
+ SELECT $${n + 2}, $${n + 3}, key, value, row_id FROM scope
474
+ ON CONFLICT DO NOTHING`, [
475
+ ...params,
476
+ JSON.stringify(slice.map(({ row }) => ({ row_id: row.rowId, scopes: row.scopes }))),
477
+ partition,
478
+ compiled.name,
479
+ ]));
480
+ }
481
+ return Promise.all(writes);
428
482
  }
429
483
  async function getPushResultOn(q, partition, clientId, clientCommitId) {
430
484
  const { rows } = await q.query('SELECT result FROM sync_push_results WHERE partition=$1 AND client_id=$2 AND client_commit_id=$3', [partition, clientId, clientCommitId]);
@@ -450,6 +504,8 @@ async function queryAuthoritativeOn(client, partition, prepared) {
450
504
  }
451
505
  class PostgresTransaction {
452
506
  #client;
507
+ /** The FIFO without the staged-write prelude (see the constructor). */
508
+ #serial;
453
509
  #partition;
454
510
  /** Storage-owned relation rewrite, bound to this transaction's partition. */
455
511
  #prepareQuery;
@@ -465,6 +521,15 @@ class PostgresTransaction {
465
521
  */
466
522
  #prefetchedRows = new Map();
467
523
  #prefetchedTombstones = new Map();
524
+ /**
525
+ * Upserts staged per table (row id → row and its push operation index).
526
+ * Every statement of this transaction writes them first (see the
527
+ * constructor), so reads and later writes observe them; a commit's rows
528
+ * reach the database as one statement per table instead of one per row.
529
+ * Tables with a unique index write immediately: a violation must name its
530
+ * operation.
531
+ */
532
+ #staged = new Map();
468
533
  /** Resolves/rejects the `transaction(fn)` wrapper (see `begin`). */
469
534
  #resolve;
470
535
  #reject;
@@ -476,13 +541,31 @@ class PostgresTransaction {
476
541
  // issued through this transaction is chained on one FIFO — the same
477
542
  // shape as `SqliteServerStorage.queryAuthoritative`.
478
543
  let tail = Promise.resolve();
479
- this.#client = {
544
+ const serial = {
480
545
  query: (text, params) => {
481
546
  const run = tail.then(() => client.query(text, params));
482
547
  tail = run.then(() => undefined, () => undefined);
483
548
  return run;
484
549
  },
485
550
  };
551
+ this.#serial = serial;
552
+ this.#client = {
553
+ query: async (text, params) => {
554
+ // Queue the staged writes ahead of this statement synchronously, so
555
+ // a concurrently issued statement cannot overtake them.
556
+ const staged = this.#writeStaged();
557
+ const run = serial.query(text, params);
558
+ try {
559
+ await staged;
560
+ }
561
+ catch (error) {
562
+ // The aborted transaction fails the queued statement too.
563
+ run.catch(() => undefined);
564
+ throw error;
565
+ }
566
+ return run;
567
+ },
568
+ };
486
569
  this.#partition = partition;
487
570
  this.#writerVersion = writerVersion;
488
571
  this.#resolveTable = resolveTable;
@@ -494,6 +577,23 @@ class PostgresTransaction {
494
577
  if (!this.#open)
495
578
  throw new Error('transaction already finished');
496
579
  }
580
+ #writeStaged() {
581
+ if (this.#staged.size === 0)
582
+ return Promise.resolve();
583
+ const staged = [...this.#staged];
584
+ this.#staged.clear();
585
+ return Promise.all(staged.map(([table, rows]) => {
586
+ const entries = [...rows.values()];
587
+ return writeRowsOn(this.#serial, table, this.#partition, entries).catch((error) => {
588
+ // Staged tables have no unique index, so a violation is a column
589
+ // constraint; it names the first staged operation of the table.
590
+ if (isPostgresConstraintError(error)) {
591
+ throw new StorageConstraintError(error, entries[0]?.opIndex);
592
+ }
593
+ throw error;
594
+ });
595
+ }));
596
+ }
497
597
  getRow(table, rowId) {
498
598
  this.#assertOpen();
499
599
  const prefetched = this.#prefetchedRows.get(`${table}\u0000${rowId}`);
@@ -619,6 +719,7 @@ class PostgresTransaction {
619
719
  if (!this.#pushApplySavepoint) {
620
720
  throw new Error('push rejection requires its apply savepoint');
621
721
  }
722
+ this.#staged.clear();
622
723
  await this.#client.query('ROLLBACK TO SAVEPOINT syncular_push_candidate');
623
724
  await this.#client.query('RELEASE SAVEPOINT syncular_push_candidate');
624
725
  this.#pushApplySavepoint = false;
@@ -628,8 +729,28 @@ class PostgresTransaction {
628
729
  async upsertRow(table, row, context) {
629
730
  this.#assertOpen();
630
731
  this.#prefetchedRows.delete(`${table}\u0000${row.rowId}`);
732
+ const compiled = this.#resolveTable(table);
733
+ // Decodes the payload: an undecodable row fails here, at its operation.
734
+ const values = upsertValues(compiled, this.#partition, row, 'postgres');
735
+ if (!compiled.indexes.some((index) => index.unique === true)) {
736
+ let rows = this.#staged.get(compiled);
737
+ if (rows === undefined) {
738
+ rows = new Map();
739
+ this.#staged.set(compiled, rows);
740
+ }
741
+ // A later upsert of the same row in this transaction replaces it.
742
+ rows.delete(row.rowId);
743
+ rows.set(row.rowId, {
744
+ row,
745
+ values,
746
+ ...(context === undefined ? {} : { opIndex: context.opIndex }),
747
+ });
748
+ return;
749
+ }
631
750
  try {
632
- await writeRowOn(this.#client, this.#resolveTable(table), this.#partition, row);
751
+ await writeRowsOn(this.#client, compiled, this.#partition, [
752
+ { row, values },
753
+ ]);
633
754
  }
634
755
  catch (error) {
635
756
  if (isPostgresConstraintError(error)) {
@@ -757,6 +878,7 @@ class PostgresTransaction {
757
878
  }
758
879
  async commit() {
759
880
  this.#assertOpen();
881
+ await this.#writeStaged();
760
882
  this.#open = false;
761
883
  // Signal the transaction wrapper to COMMIT; wait for it to actually land.
762
884
  this.#resolve();
@@ -765,6 +887,7 @@ class PostgresTransaction {
765
887
  async rollback() {
766
888
  if (!this.#open)
767
889
  return;
890
+ this.#staged.clear();
768
891
  this.#open = false;
769
892
  this.#reject(new RollbackSignal());
770
893
  // Swallow — a rollback is a normal outcome, not an error to the caller.
@@ -794,33 +917,39 @@ export class PostgresServerStorage {
794
917
  #schemaVersion;
795
918
  /**
796
919
  * Page reads waiting for the end of the current microtask turn, keyed by
797
- * statement and partition. A pull starts the window read (or first
920
+ * batch stream and partition. A pull starts the window read (or first
798
921
  * snapshot page) of every subscription before awaiting any of them, so the
799
- * window reads of all tables leave as ONE set-based statement (snapshot
800
- * pages as one per table, whose SQL names the table) instead of one round
801
- * trip per subscription or table (SYNCULAR-PULL-ROUNDTRIPS-001).
922
+ * reads of all tables leave as ONE set-based statement per stream instead
923
+ * of one round trip per subscription or table
924
+ * (SYNCULAR-PULL-ROUNDTRIPS-001).
802
925
  */
803
926
  #pageBatches = new Map();
804
927
  constructor(exec) {
805
928
  this.#exec = exec;
806
929
  }
807
930
  /**
808
- * Queue one page read into the batch for (`sql`, `partition`). The batch
809
- * runs as a single statement with the queued specs bound as a JSON array
810
- * (`$2`); each spec gets its position as `i`, and the result rows are
811
- * split back by their `sub` column.
931
+ * Queue one page read into the batch for (`key`, `partition`). `sql` is the
932
+ * statement every batch of that stream shares, or a builder over the queued
933
+ * specs when the statement names what the batch contains (the snapshot scan
934
+ * joins one branch per table present). The batch runs as a single statement
935
+ * with the queued specs bound as a JSON array (`$2`); each spec gets its
936
+ * position as `i`, and the result rows are split back by their `sub`
937
+ * column.
812
938
  */
813
- #batchedPage(sql, partition, spec) {
814
- const key = `${sql}\u0000${partition}`;
815
- let batch = this.#pageBatches.get(key);
939
+ #batchedPage(key, sql, partition, spec) {
940
+ const batchKey = `${key}\u0000${partition}`;
941
+ let batch = this.#pageBatches.get(batchKey);
816
942
  if (batch === undefined) {
817
943
  const queued = [];
818
944
  batch = queued;
819
- this.#pageBatches.set(key, queued);
945
+ this.#pageBatches.set(batchKey, queued);
820
946
  queueMicrotask(() => {
821
- this.#pageBatches.delete(key);
822
- const specs = JSON.stringify(queued.map((entry, i) => ({ ...entry.spec, i })));
823
- this.#exec.query(sql, [partition, specs]).then(({ rows }) => {
947
+ this.#pageBatches.delete(batchKey);
948
+ const specs = queued.map((entry, i) => ({ ...entry.spec, i }));
949
+ const statement = typeof sql === 'string' ? sql : sql(specs);
950
+ this.#exec
951
+ .query(statement, [partition, JSON.stringify(specs)])
952
+ .then(({ rows }) => {
824
953
  const grouped = queued.map(() => []);
825
954
  for (const row of rows)
826
955
  grouped[asNumber(row.sub)]?.push(row);
@@ -987,8 +1116,6 @@ FOR EACH ROW EXECUTE FUNCTION syncular_writer_fence()`,
987
1116
  // backfill for flipped-on materialization) — all inside one
988
1117
  // transaction (Postgres DDL is transactional — a failed bump leaves
989
1118
  // no half-state).
990
- const layouts = parseLayouts(typeof marker.layouts === 'string' ? marker.layouts : undefined);
991
- const retiredTables = retiredTableNames(schema, layouts);
992
1119
  await this.#exec.transaction(async (client) => {
993
1120
  // RFC 0007 migration barrier: `LOCK TABLE ... EXCLUSIVE` is the FIRST
994
1121
  // statement, before any read, DDL, rewrite, marker or checkpoint
@@ -1001,6 +1128,24 @@ FOR EACH ROW EXECUTE FUNCTION syncular_writer_fence()`,
1001
1128
  // when it takes this lock, so a writer already inside completes while
1002
1129
  // the migration waits.
1003
1130
  await client.query('LOCK TABLE sync_partitions IN EXCLUSIVE MODE');
1131
+ // The probe ran before the lock. A concurrent `ensureSchema` (another
1132
+ // isolate, the Durable Object's first request) may have bumped while
1133
+ // this one waited; re-running the rewrite would decode its new-layout
1134
+ // payloads with the old layout. The locked marker decides.
1135
+ const { rows: lockedMarker } = await client.query('SELECT schema_version, layouts FROM sync_schema_meta WHERE id=1');
1136
+ const lockedVersion = lockedMarker[0]?.schema_version === null ||
1137
+ lockedMarker[0]?.schema_version === undefined
1138
+ ? undefined
1139
+ : asNumber(lockedMarker[0].schema_version);
1140
+ if (lockedVersion !== undefined && lockedVersion > schema.version) {
1141
+ throw new Error(`stored schema version ${lockedVersion} is newer than the configured schema (${schema.version}) — refusing to run an older server against a migrated database`);
1142
+ }
1143
+ if (lockedVersion === schema.version)
1144
+ return;
1145
+ const layouts = parseLayouts(typeof lockedMarker[0]?.layouts === 'string'
1146
+ ? lockedMarker[0].layouts
1147
+ : undefined);
1148
+ const retiredTables = retiredTableNames(schema, layouts);
1004
1149
  const existing = new Map();
1005
1150
  const existingIndexes = new Map();
1006
1151
  for (const table of schema.tables.values()) {
@@ -1013,7 +1158,19 @@ FOR EACH ROW EXECUTE FUNCTION syncular_writer_fence()`,
1013
1158
  // indexes (PRIMARY KEY, UNIQUE constraints) can only be removed
1014
1159
  // through their constraint, so DROP INDEX on them would abort
1015
1160
  // the migration transaction.
1016
- const indexes = await client.query(`SELECT index_class.relname AS index_name
1161
+ const indexes = await client.query(`SELECT index_class.relname AS index_name,
1162
+ index_meta.indisunique AS is_unique,
1163
+ index_meta.indpred IS NOT NULL AS is_partial,
1164
+ ARRAY(
1165
+ SELECT attribute.attname::text
1166
+ FROM unnest(index_meta.indkey::int2[])
1167
+ WITH ORDINALITY AS key(attnum, ord)
1168
+ LEFT JOIN pg_catalog.pg_attribute AS attribute
1169
+ ON attribute.attrelid = table_class.oid
1170
+ AND attribute.attnum = key.attnum
1171
+ WHERE key.ord <= index_meta.indnkeyatts
1172
+ ORDER BY key.ord
1173
+ ) AS columns
1017
1174
  FROM pg_catalog.pg_class AS table_class
1018
1175
  JOIN pg_catalog.pg_namespace AS namespace
1019
1176
  ON namespace.oid = table_class.relnamespace
@@ -1029,16 +1186,21 @@ FOR EACH ROW EXECUTE FUNCTION syncular_writer_fence()`,
1029
1186
  FROM pg_catalog.pg_constraint AS owning_constraint
1030
1187
  WHERE owning_constraint.conindid = index_meta.indexrelid
1031
1188
  )`, [table.name]);
1032
- existingIndexes.set(table.name, new Set(indexes.rows.map((index) => index.index_name)));
1189
+ existingIndexes.set(table.name, new Map(indexes.rows.map((index) => [
1190
+ index.index_name,
1191
+ indexSignature(index.columns, index.is_unique, index.is_partial),
1192
+ ])));
1033
1193
  }
1034
1194
  }
1195
+ if (retiredTables.length > 0) {
1196
+ // Neither index leads with `tbl`: one scan per table, not one per
1197
+ // retired table.
1198
+ await client.query(`DELETE FROM sync_row_scopes
1199
+ WHERE tbl IN (SELECT jsonb_array_elements_text($1::text::jsonb))`, [JSON.stringify(retiredTables)]);
1200
+ await client.query(`DELETE FROM sync_blob_refs
1201
+ WHERE tbl IN (SELECT jsonb_array_elements_text($1::text::jsonb))`, [JSON.stringify(retiredTables)]);
1202
+ }
1035
1203
  for (const tableName of retiredTables) {
1036
- await client.query('DELETE FROM sync_row_scopes WHERE tbl=$1', [
1037
- tableName,
1038
- ]);
1039
- await client.query('DELETE FROM sync_blob_refs WHERE tbl=$1', [
1040
- tableName,
1041
- ]);
1042
1204
  await client.query(dropTableDdl(tableName));
1043
1205
  }
1044
1206
  for (const statement of schemaDdl(schema, existing, 'postgres', existingIndexes)) {
@@ -1081,13 +1243,18 @@ FOR EACH ROW EXECUTE FUNCTION syncular_writer_fence()`,
1081
1243
  if (initialLogEpoch.length === 0) {
1082
1244
  throw new Error('initial log epoch must be non-empty');
1083
1245
  }
1084
- const { rows } = await this.#exec.query(`INSERT INTO sync_partition_registry(
1085
- partition, log_epoch, last_authenticated_at_ms
1086
- ) VALUES ($1,$2,$3)
1087
- ON CONFLICT(partition) DO UPDATE SET
1088
- last_authenticated_at_ms=EXCLUDED.last_authenticated_at_ms
1089
- RETURNING log_epoch, epoch_required, last_authenticated_at_ms`, [partition, initialLogEpoch, authenticatedAtMs]);
1090
- const row = rows[0];
1246
+ let row = (await this.#exec.query(`SELECT log_epoch, epoch_required, last_authenticated_at_ms
1247
+ FROM sync_partition_registry WHERE partition=$1`, [partition])).rows[0];
1248
+ if (row === undefined ||
1249
+ authenticatedAtMs - asNumber(row.last_authenticated_at_ms) >=
1250
+ PARTITION_TOUCH_INTERVAL_MS) {
1251
+ row = (await this.#exec.query(`INSERT INTO sync_partition_registry(
1252
+ partition, log_epoch, last_authenticated_at_ms
1253
+ ) VALUES ($1,$2,$3)
1254
+ ON CONFLICT(partition) DO UPDATE SET
1255
+ last_authenticated_at_ms=EXCLUDED.last_authenticated_at_ms
1256
+ RETURNING log_epoch, epoch_required, last_authenticated_at_ms`, [partition, initialLogEpoch, authenticatedAtMs])).rows[0];
1257
+ }
1091
1258
  if (row === undefined)
1092
1259
  throw new Error('partition registry write did not persist');
1093
1260
  return {
@@ -1476,7 +1643,7 @@ FOR EACH ROW EXECUTE FUNCTION syncular_writer_fence()`,
1476
1643
  if (firstVariable === undefined || values.length === 0)
1477
1644
  return 0;
1478
1645
  // Concurrent reads of a pull share one statement across tables.
1479
- const [row] = await this.#batchedPage(LATEST_SCOPED_CHANGE_BATCH_SQL, partition, { tbl: query.table, var: firstVariable, vals: values });
1646
+ const [row] = await this.#batchedPage('latestScopedChange', LATEST_SCOPED_CHANGE_BATCH_SQL, partition, { tbl: query.table, var: firstVariable, vals: values });
1480
1647
  return row === undefined ? 0 : asNumber(row.seq);
1481
1648
  }
1482
1649
  async readCommitWindow(partition, query) {
@@ -1499,7 +1666,7 @@ FOR EACH ROW EXECUTE FUNCTION syncular_writer_fence()`,
1499
1666
  let afterSeq = query.afterSeq;
1500
1667
  const batchSize = Math.max(64, query.limitChanges);
1501
1668
  while (deliveredChanges < query.limitChanges) {
1502
- const records = await this.#batchedPage(COMMIT_WINDOW_BATCH_SQL, partition, {
1669
+ const records = await this.#batchedPage('commitWindow', COMMIT_WINDOW_BATCH_SQL, partition, {
1503
1670
  tbl: query.table,
1504
1671
  var: firstVariable,
1505
1672
  vals: firstValues,
@@ -1572,15 +1739,14 @@ FOR EACH ROW EXECUTE FUNCTION syncular_writer_fence()`,
1572
1739
  return [];
1573
1740
  // Candidates via the inverted index (ordered + LIMITed at the covering
1574
1741
  // PK) LEFT JOINed to the row table — one round trip per page, never one
1575
- // per row, and pages of concurrent scans on the same table share one
1576
- // statement (see `scanRowBatchSql`). Exact multi-variable verification
1577
- // against the stored scope map happens below.
1578
- const sql = scanRowBatchSql(this.table(query.table));
1742
+ // per row, and concurrent scans across tables share one statement (see
1743
+ // `scanRowBatchSql`). Exact multi-variable verification against the
1744
+ // stored scope map happens below.
1579
1745
  const rows = [];
1580
1746
  let afterRowId = query.afterRowId ?? '';
1581
1747
  const batchSize = Math.max(64, query.limit);
1582
1748
  while (rows.length < query.limit) {
1583
- const records = await this.#batchedPage(sql, partition, {
1749
+ const records = await this.#batchedPage('scanRows', (specs) => scanRowBatchSql([...new Set(specs.map((spec) => String(spec.tbl)))].map((table) => this.table(table))), partition, {
1584
1750
  tbl: query.table,
1585
1751
  var: firstVariable,
1586
1752
  vals: firstValues,
package/dist/pull.d.ts CHANGED
@@ -42,8 +42,8 @@ export interface PullSectionTrace {
42
42
  /**
43
43
  * The storage reads a section needs before its first frame. A pull starts
44
44
  * them for every subscription before awaiting any, so a storage that batches
45
- * concurrent page reads (PostgreSQL, D1) answers all subscriptions of one
46
- * table with one statement, and the horizon re-check runs once per pull.
45
+ * concurrent page reads (PostgreSQL, D1) answers the first pages of every
46
+ * subscription in one statement, and the horizon re-check runs once per pull.
47
47
  */
48
48
  export interface PreparedSection {
49
49
  /** §4.5 window read for an incremental cursor; empty when none was read. */
@@ -57,7 +57,11 @@ export interface PreparedSection {
57
57
  readonly firstPage: StoredRow[] | undefined;
58
58
  /** §4.7 pin of a fresh bootstrap; absent ⇒ `maxCommitSeq`. */
59
59
  readonly pin: number | undefined;
60
- /** §5.3 stored image for a sqlite-lane bootstrap's reuse key, if any. */
60
+ /**
61
+ * §5.3 stored image for a sqlite-lane bootstrap's reuse key. Read only when
62
+ * the eligibility probe shows a snapshot larger than one rows page, since a
63
+ * smaller table can never match a stored image.
64
+ */
61
65
  readonly image: SegmentRecord | undefined;
62
66
  }
63
67
  /** Run the pre-frame storage reads of every subscription concurrently. */
package/dist/pull.js CHANGED
@@ -375,18 +375,27 @@ export async function prepareSections(ctx, schema, limits, plans, maxSeq, horizo
375
375
  const pin = Math.min(maxSeq, Math.max(latest, horizonSeq));
376
376
  return { asOf: pin, pin };
377
377
  });
378
- const lane = pinned.then(async ({ asOf, pin }) => ({
379
- pin,
380
- image: await ctx.segments.find({
381
- partition: ctx.partition,
382
- logEpoch,
383
- table: plan.table.name,
384
- schemaVersion: schema.version,
385
- mediaType: 'sqlite',
386
- scopeDigest: await scopeDigest(plan.effective),
387
- asOfCommitSeq: asOf,
388
- }, clockOf(ctx)()),
389
- }));
378
+ const lane = pinned.then(async ({ asOf, pin }) => {
379
+ // §5.3 eligibility: a stored image only matches a snapshot larger than
380
+ // one rows page, so the probe decides before the reuse lookup; a remote
381
+ // `find` on a small table would be a wasted round trip.
382
+ const probe = await firstPage;
383
+ if (probe.length <= limits.limitSnapshotRows) {
384
+ return { pin, image: undefined };
385
+ }
386
+ return {
387
+ pin,
388
+ image: await ctx.segments.find({
389
+ partition: ctx.partition,
390
+ logEpoch,
391
+ table: plan.table.name,
392
+ schemaVersion: schema.version,
393
+ mediaType: 'sqlite',
394
+ scopeDigest: await scopeDigest(plan.effective),
395
+ asOfCommitSeq: asOf,
396
+ }, clockOf(ctx)()),
397
+ };
398
+ });
390
399
  return { firstPage, lane };
391
400
  });
392
401
  const settled = await Promise.all(reads.map(async (read) => ({