@syncular/server 0.26.1 → 0.26.4

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.
@@ -583,6 +583,13 @@ export class D1ServerStorage {
583
583
  /** Set by `ensureSchema`: app-table lookup for the relational row store. */
584
584
  #tables;
585
585
  #schemaVersion;
586
+ /**
587
+ * Page reads waiting for the end of the current microtask turn. A pull
588
+ * starts the window read (or first snapshot page) of every subscription
589
+ * before awaiting any of them, so they leave as ONE `db.batch` round trip
590
+ * instead of one per subscription (SYNCULAR-PULL-ROUNDTRIPS-001).
591
+ */
592
+ #pendingReads;
586
593
  constructor(db, options = {}) {
587
594
  this.#db = db;
588
595
  this.#pushApplySerialized =
@@ -944,6 +951,47 @@ export class D1ServerStorage {
944
951
  return { complete: false, statementsExecuted };
945
952
  }
946
953
  /** Every protected read or write shares a transaction with its schema check. */
954
+ /** Queue one read into the current turn's `db.batch` (see `#pendingReads`). */
955
+ #batchedAll(sql, params) {
956
+ let pending = this.#pendingReads;
957
+ if (pending === undefined) {
958
+ const created = {
959
+ db: this.#schemaDatabase(),
960
+ entries: [],
961
+ };
962
+ pending = created;
963
+ this.#pendingReads = created;
964
+ queueMicrotask(() => {
965
+ this.#pendingReads = undefined;
966
+ created.db.batch(created.entries.map((entry) => entry.statement)).then((results) => {
967
+ created.entries.forEach((entry, i) => {
968
+ const result = results[i];
969
+ if (typeof result !== 'object' ||
970
+ result === null ||
971
+ !('results' in result) ||
972
+ !Array.isArray(result.results)) {
973
+ entry.reject(new Error('D1 query returned an invalid batch result'));
974
+ }
975
+ else {
976
+ entry.resolve(result.results);
977
+ }
978
+ });
979
+ }, (error) => {
980
+ for (const entry of created.entries)
981
+ entry.reject(error);
982
+ });
983
+ });
984
+ }
985
+ const target = pending;
986
+ const statement = target.db.prepare(sql).bind(...params);
987
+ return new Promise((resolve, reject) => {
988
+ target.entries.push({
989
+ statement,
990
+ resolve: (rows) => resolve(rows),
991
+ reject,
992
+ });
993
+ });
994
+ }
947
995
  #schemaDatabase() {
948
996
  if (this.#schemaVersion === undefined)
949
997
  throw new StorageQueryError('sync.storage.schema_changed');
@@ -1392,7 +1440,6 @@ export class D1ServerStorage {
1392
1440
  };
1393
1441
  }
1394
1442
  async readCommitWindow(partition, query) {
1395
- const db = this.#schemaDatabase();
1396
1443
  const variables = Object.keys(query.scopeFilter).sort();
1397
1444
  const firstVariable = variables[0];
1398
1445
  if (firstVariable === undefined)
@@ -1401,19 +1448,28 @@ export class D1ServerStorage {
1401
1448
  if (firstValues.length === 0)
1402
1449
  return [];
1403
1450
  // Candidates via the inverted index (one variable) LEFT JOINed to the
1404
- // commit meta + the table's changes — one D1 round trip per page, never
1405
- // two per candidate (see `commitWindowPageSql`). Exact multi-variable
1406
- // verification against the stored scope map in `collectCommitWindowPage`.
1451
+ // commit meta + the table's changes — one statement per page, never two
1452
+ // per candidate (see `commitWindowPageSql`), and concurrent pages share
1453
+ // one D1 round trip. Exact multi-variable verification against the
1454
+ // stored scope map in `collectCommitWindowPage`.
1407
1455
  const sql = commitWindowPageSql(firstValues.length, 'sqlite');
1408
1456
  const commits = [];
1409
1457
  let deliveredChanges = 0;
1410
1458
  let afterSeq = query.afterSeq;
1411
1459
  const batchSize = Math.max(64, query.limitChanges);
1412
1460
  while (deliveredChanges < query.limitChanges) {
1413
- const { results: records } = await db
1414
- .prepare(sql)
1415
- .bind(partition, query.table, firstVariable, ...firstValues, afterSeq, query.throughSeq, batchSize, partition, partition, query.table)
1416
- .all();
1461
+ const records = await this.#batchedAll(sql, [
1462
+ partition,
1463
+ query.table,
1464
+ firstVariable,
1465
+ ...firstValues,
1466
+ afterSeq,
1467
+ query.throughSeq,
1468
+ batchSize,
1469
+ partition,
1470
+ partition,
1471
+ query.table,
1472
+ ]);
1417
1473
  if (records.length === 0)
1418
1474
  break;
1419
1475
  const page = collectCommitWindowPage(records, query.scopeFilter, query.limitChanges - deliveredChanges);
@@ -1426,23 +1482,28 @@ export class D1ServerStorage {
1426
1482
  return commits;
1427
1483
  }
1428
1484
  async scanRows(partition, query) {
1429
- const db = this.#schemaDatabase();
1430
1485
  const firstVariable = assertScopeIndexedScan(query);
1431
1486
  const firstValues = query.scopeFilter[firstVariable] ?? [];
1432
1487
  if (firstValues.length === 0)
1433
1488
  return [];
1434
1489
  // Candidates via the inverted index LEFT JOINed to the row table — one
1435
- // D1 round trip per page, never one per row (see `scanRowPageSql`).
1490
+ // statement per page, never one per row (see `scanRowPageSql`), and
1491
+ // concurrent pages share one D1 round trip.
1436
1492
  // Exact multi-variable verification against the stored scope map below.
1437
1493
  const sql = scanRowPageSql(this.table(query.table), firstValues.length, 'sqlite');
1438
1494
  const rows = [];
1439
1495
  let afterRowId = query.afterRowId ?? '';
1440
1496
  const batchSize = Math.max(64, query.limit);
1441
1497
  while (rows.length < query.limit) {
1442
- const { results: records } = await db
1443
- .prepare(sql)
1444
- .bind(partition, query.table, firstVariable, ...firstValues, afterRowId, batchSize, partition)
1445
- .all();
1498
+ const records = await this.#batchedAll(sql, [
1499
+ partition,
1500
+ query.table,
1501
+ firstVariable,
1502
+ ...firstValues,
1503
+ afterRowId,
1504
+ batchSize,
1505
+ partition,
1506
+ ]);
1446
1507
  if (records.length === 0)
1447
1508
  break;
1448
1509
  for (const record of records) {
package/dist/handler.js CHANGED
@@ -14,7 +14,7 @@ import { clockOf, limitsOf, REMOTE_COMMAND_CLIENT_ID_PREFIX, RESOLVER_OUTAGE, to
14
14
  import { SyncError, syncError } from './errors.js';
15
15
  import { emitEvent, } from './events.js';
16
16
  import { END_FRAME_BYTES, encodeResponseFrame, responseEnvelopeHeader, } from './frame-bytes.js';
17
- import { ACCEPT_EXTERNAL_ROWS, ACCEPT_INLINE_ROWS, clampPullLimits, subscriptionSection, } from './pull.js';
17
+ import { ACCEPT_EXTERNAL_ROWS, ACCEPT_INLINE_ROWS, clampPullLimits, prepareSections, preparedSection, } from './pull.js';
18
18
  import { processPushCommitWithTrace } from './push.js';
19
19
  import { serveNotReadyError } from './readiness.js';
20
20
  import { compileSchema } from './schema.js';
@@ -353,9 +353,13 @@ async function* streamResponse(plan, ctx, schema, report) {
353
353
  const limits = clampPullLimits(plan.pull);
354
354
  const maxSeq = await ctx.storage.getMaxCommitSeq(ctx.partition);
355
355
  const horizonSeq = await ctx.storage.getHorizonSeq(ctx.partition);
356
- for (const subscription of plan.subscriptions) {
356
+ const prepared = await prepareSections(ctx, limits, plan.subscriptions, maxSeq, horizonSeq);
357
+ for (const [index, subscription] of plan.subscriptions.entries()) {
357
358
  const trace = summaries !== undefined ? { segments: [] } : undefined;
358
- const section = subscriptionSection(ctx, schema, limits, subscription, maxSeq, horizonSeq, trace, plan.logEpoch);
359
+ const reads = prepared[index];
360
+ if (reads === undefined)
361
+ throw new Error('prepareSections lost a plan');
362
+ const section = preparedSection(ctx, schema, limits, subscription, maxSeq, reads, trace, plan.logEpoch);
359
363
  let status = 'active';
360
364
  let bootstrap = false;
361
365
  let commits = 0;
@@ -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, 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';
8
+ import { assertPhysicalColumns, assertStoredLayouts, COMMIT_WINDOW_BATCH_SQL, deleteRowSql, dropTableDdl, indexRowPageStatement, layoutsOf, migratePayload, parseLayouts, retiredTableNames, rewritePlan, rewriteRowSql, rewriteValues, SCHEMA_META_DDL_POSTGRES, scanRowBatchSql, 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';
@@ -171,19 +171,21 @@ async function installCheckpointOn(client, partition, name, schemaVersion, nowMs
171
171
  }
172
172
  /** RFC 0007 serve gate on either the pool or a transaction's client. */
173
173
  async function readServeGateOn(client, partition, runningSchemaVersion) {
174
- const marker = await client.query('SELECT schema_version FROM sync_schema_meta WHERE id=1');
175
- const epoch = await client.query('SELECT log_epoch FROM sync_partition_registry WHERE partition=$1', [partition]);
176
- const pending = await client.query(`SELECT partition, name FROM sync_backfill_checkpoints
177
- WHERE schema_version=$1 AND state<>'activated'`, [runningSchemaVersion]);
174
+ // One statement: every request reads the gate twice (entry and verify),
175
+ // so its parts share one round trip and one snapshot.
176
+ const { rows } = await client.query(`SELECT (SELECT schema_version FROM sync_schema_meta WHERE id=1) AS schema_version,
177
+ (SELECT log_epoch FROM sync_partition_registry WHERE partition=$1) AS log_epoch,
178
+ (SELECT json_agg(json_build_object('partition', partition, 'name', name))
179
+ FROM sync_backfill_checkpoints
180
+ WHERE schema_version=$2 AND state<>'activated') AS pending`, [partition, runningSchemaVersion]);
181
+ const row = rows[0];
178
182
  return {
179
- storedSchemaVersion: marker.rows[0] === undefined
180
- ? 0
181
- : asNumber(marker.rows[0].schema_version),
182
- logEpoch: epoch.rows[0]?.log_epoch,
183
- pending: pending.rows.map((row) => ({
184
- partition: row.partition,
185
- name: row.name,
186
- })),
183
+ // asNumber maps the NULL of a missing marker row to 0.
184
+ storedSchemaVersion: asNumber(row?.schema_version),
185
+ logEpoch: row?.log_epoch ?? undefined,
186
+ pending: row?.pending === null || row?.pending === undefined
187
+ ? []
188
+ : asJson(row.pending),
187
189
  };
188
190
  }
189
191
  async function hasSourceChangesAboveOn(client, partition, tables, seq) {
@@ -725,9 +727,53 @@ export class PostgresServerStorage {
725
727
  /** Set by `ensureSchema`: app-table lookup for the relational row store. */
726
728
  #tables;
727
729
  #schemaVersion;
730
+ /**
731
+ * Page reads waiting for the end of the current microtask turn, keyed by
732
+ * statement and fixed params. A pull starts the window read (or first
733
+ * snapshot page) of every subscription before awaiting any of them, so the
734
+ * reads of one table leave as ONE set-based statement instead of one round
735
+ * trip per subscription (SYNCULAR-PULL-ROUNDTRIPS-001).
736
+ */
737
+ #pageBatches = new Map();
728
738
  constructor(exec) {
729
739
  this.#exec = exec;
730
740
  }
741
+ /**
742
+ * Queue one page read into the batch for (`sql`, `partition`, `table`).
743
+ * The batch runs as a single statement with the queued specs bound as a
744
+ * JSON array (`$2`); each spec gets its position as `i`, and the result
745
+ * rows are split back by their `sub` column.
746
+ */
747
+ #batchedPage(sql, partition, table, spec) {
748
+ const key = `${sql}\u0000${partition}\u0000${table}`;
749
+ let batch = this.#pageBatches.get(key);
750
+ if (batch === undefined) {
751
+ const queued = [];
752
+ batch = queued;
753
+ this.#pageBatches.set(key, queued);
754
+ queueMicrotask(() => {
755
+ this.#pageBatches.delete(key);
756
+ const specs = JSON.stringify(queued.map((entry, i) => ({ ...entry.spec, i })));
757
+ this.#exec.query(sql, [partition, specs, table]).then(({ rows }) => {
758
+ const grouped = queued.map(() => []);
759
+ for (const row of rows)
760
+ grouped[asNumber(row.sub)]?.push(row);
761
+ queued.forEach((entry, i) => entry.resolve(grouped[i] ?? []));
762
+ }, (error) => {
763
+ for (const entry of queued)
764
+ entry.reject(error);
765
+ });
766
+ });
767
+ }
768
+ const target = batch;
769
+ return new Promise((resolve, reject) => {
770
+ target.push({
771
+ spec,
772
+ resolve: (rows) => resolve(rows),
773
+ reject,
774
+ });
775
+ });
776
+ }
731
777
  /** Apply the schema DDL (idempotent). Call once before use. */
732
778
  async migrate() {
733
779
  // Split on the statement boundary so drivers that reject multi-statement
@@ -1313,25 +1359,22 @@ FOR EACH ROW EXECUTE FUNCTION syncular_writer_fence()`);
1313
1359
  // Candidates via the inverted index (one variable): the (partition, tbl,
1314
1360
  // var, value, commit_seq) PK makes the candidate subquery an index range
1315
1361
  // scan returning commit_seq already ascending (see postgres-explain
1316
- // .test.ts), LEFT JOINed to the commit meta + the table's changes — one
1317
- // round trip per page, never two per candidate (see
1318
- // `commitWindowPageSql`). Exact multi-variable verification against the
1319
- // stored scope map happens below.
1320
- const sql = commitWindowPageSql(firstValues.length, 'postgres');
1362
+ // .test.ts), LEFT JOINed to the commit meta + the table's changes. Pages
1363
+ // of concurrent window reads on the same table share one statement (see
1364
+ // `COMMIT_WINDOW_BATCH_SQL`). Exact multi-variable verification against
1365
+ // the stored scope map happens below.
1321
1366
  const commits = [];
1322
1367
  let deliveredChanges = 0;
1323
1368
  let afterSeq = query.afterSeq;
1324
1369
  const batchSize = Math.max(64, query.limitChanges);
1325
1370
  while (deliveredChanges < query.limitChanges) {
1326
- const { rows: records } = await this.#exec.query(sql, [
1327
- partition,
1328
- query.table,
1329
- firstVariable,
1330
- ...firstValues,
1331
- afterSeq,
1332
- query.throughSeq,
1333
- batchSize,
1334
- ]);
1371
+ const records = await this.#batchedPage(COMMIT_WINDOW_BATCH_SQL, partition, query.table, {
1372
+ var: firstVariable,
1373
+ vals: firstValues,
1374
+ after: afterSeq,
1375
+ through: query.throughSeq,
1376
+ lim: batchSize,
1377
+ });
1335
1378
  if (records.length === 0)
1336
1379
  break;
1337
1380
  // Fold the page: rows arrive ordered (commit_seq, idx); consecutive
@@ -1397,21 +1440,20 @@ FOR EACH ROW EXECUTE FUNCTION syncular_writer_fence()`);
1397
1440
  return [];
1398
1441
  // Candidates via the inverted index (ordered + LIMITed at the covering
1399
1442
  // PK) LEFT JOINed to the row table — one round trip per page, never one
1400
- // per row (see `scanRowPageSql`). Exact multi-variable verification
1443
+ // per row, and pages of concurrent scans on the same table share one
1444
+ // statement (see `scanRowBatchSql`). Exact multi-variable verification
1401
1445
  // against the stored scope map happens below.
1402
- const sql = scanRowPageSql(this.table(query.table), firstValues.length, 'postgres');
1446
+ const sql = scanRowBatchSql(this.table(query.table));
1403
1447
  const rows = [];
1404
1448
  let afterRowId = query.afterRowId ?? '';
1405
1449
  const batchSize = Math.max(64, query.limit);
1406
1450
  while (rows.length < query.limit) {
1407
- const { rows: records } = await this.#exec.query(sql, [
1408
- partition,
1409
- query.table,
1410
- firstVariable,
1411
- ...firstValues,
1412
- afterRowId,
1413
- batchSize,
1414
- ]);
1451
+ const records = await this.#batchedPage(sql, partition, query.table, {
1452
+ var: firstVariable,
1453
+ vals: firstValues,
1454
+ after: afterRowId,
1455
+ lim: batchSize,
1456
+ });
1415
1457
  if (records.length === 0)
1416
1458
  break;
1417
1459
  for (const record of records) {
package/dist/pull.d.ts CHANGED
@@ -6,6 +6,7 @@ import { type PullHeaderFrame, type ResponseFrame, type ScopeMap, type Subscript
6
6
  import type { SyncRequestContext } from './context.js';
7
7
  import type { PullSegmentSummary } from './events.js';
8
8
  import type { CompiledSchema, CompiledTable } from './schema.js';
9
+ import type { StoredCommit, StoredRow } from './storage.js';
9
10
  /** §4.2 accept bitmask. */
10
11
  export declare const ACCEPT_INLINE_ROWS: number;
11
12
  export declare const ACCEPT_EXTERNAL_ROWS: number;
@@ -37,8 +38,26 @@ export interface SubscriptionResult {
37
38
  export interface PullSectionTrace {
38
39
  readonly segments: PullSegmentSummary[];
39
40
  }
41
+ /**
42
+ * The storage reads a section needs before its first frame. A pull starts
43
+ * them for every subscription before awaiting any, so a storage that batches
44
+ * concurrent page reads (PostgreSQL) answers all subscriptions of one table
45
+ * with one statement, and the horizon re-check runs once per pull.
46
+ */
47
+ export interface PreparedSection {
48
+ /** §4.5 window read for an incremental cursor; empty when none was read. */
49
+ readonly commits: StoredCommit[];
50
+ /** Horizon to judge the cursor against, re-read after any window read. */
51
+ readonly horizonSeq: number;
52
+ /** First rows-lane snapshot page, read when the sqlite lane is excluded. */
53
+ readonly firstPage: StoredRow[] | undefined;
54
+ }
55
+ /** Run the pre-frame storage reads of every subscription concurrently. */
56
+ export declare function prepareSections(ctx: SyncRequestContext, limits: PullLimits, plans: readonly SubscriptionPlan[], maxSeq: number, horizonSeq: number): Promise<PreparedSection[]>;
40
57
  /**
41
58
  * Produce the `SUB_START … SUB_END` section for one subscription (§1.6),
42
59
  * returning the cursor recorded for the retention watermark (§4.5).
43
60
  */
44
61
  export declare function subscriptionSection(ctx: SyncRequestContext, schema: CompiledSchema, limits: PullLimits, plan: SubscriptionPlan, maxSeq: number, horizonSeq: number, trace?: PullSectionTrace, logEpoch?: string): AsyncGenerator<ResponseFrame, SubscriptionResult>;
62
+ /** The section of one subscription whose reads `prepareSections` ran. */
63
+ export declare function preparedSection(ctx: SyncRequestContext, schema: CompiledSchema, limits: PullLimits, plan: SubscriptionPlan, maxSeq: number, prepared: PreparedSection, trace?: PullSectionTrace, logEpoch?: string): AsyncGenerator<ResponseFrame, SubscriptionResult>;
package/dist/pull.js CHANGED
@@ -132,6 +132,19 @@ function segmentRefFrame(record, scopeDigest, extra) {
132
132
  : {}),
133
133
  };
134
134
  }
135
+ /**
136
+ * §5.3: the sqlite-image lane — whole-table, chosen only at the start of a
137
+ * table (a mid-table resume stays on the rows lane, never switching lanes),
138
+ * and only when the client advertised bit 2. §5.11: a table with any
139
+ * encrypted column is image-INELIGIBLE — an image copies ciphertext
140
+ * wholesale with no per-row decrypt pass, so it MUST be served on the rows
141
+ * lane (which decrypts per row on the client).
142
+ */
143
+ function sqliteLaneEligible(limits, plan, startRowCursor) {
144
+ return ((limits.accept & ACCEPT_SQLITE) !== 0 &&
145
+ startRowCursor === null &&
146
+ plan.table.encryptedColumnIndices.length === 0);
147
+ }
135
148
  /**
136
149
  * §5.3 sqlite-image lane: only at the start of a table, only when the
137
150
  * client advertised bit 2, and only when the snapshot exceeds one rows
@@ -260,32 +273,26 @@ async function* sqliteImageSegment(ctx, schema, limits, plan, asOf, digest, trac
260
273
  yield segmentRefFrame(record, digest, await signedUrlFields(ctx, limits, record.segmentId, digest, clockOf(ctx)()));
261
274
  return true;
262
275
  }
263
- async function* bootstrapSegments(ctx, schema, limits, plan, asOf, startRowCursor, trace, logEpoch) {
276
+ async function* bootstrapSegments(ctx, schema, limits, plan, asOf, startRowCursor, trace, logEpoch, firstPage) {
264
277
  const { storage, segments, partition } = ctx;
265
278
  const serverLimits = limitsOf(ctx);
266
279
  const digest = await scopeDigest(plan.effective);
267
280
  const now = clockOf(ctx)();
268
- // §5.3: the sqlite-image lane — whole-table, chosen only at the start
269
- // of a table (a mid-table resume stays on the rows lane, never
270
- // switching lanes), and only when the client advertised bit 2.
271
- // §5.11: a table with any encrypted column is image-INELIGIBLE — an image
272
- // copies ciphertext wholesale with no per-row decrypt pass, so it MUST be
273
- // served on the rows lane (which decrypts per row on the client).
274
- if ((limits.accept & ACCEPT_SQLITE) !== 0 &&
275
- startRowCursor === null &&
276
- plan.table.encryptedColumnIndices.length === 0) {
281
+ if (sqliteLaneEligible(limits, plan, startRowCursor)) {
277
282
  const imaged = yield* sqliteImageSegment(ctx, schema, limits, plan, asOf, digest, trace, logEpoch);
278
283
  if (imaged)
279
284
  return { complete: true, rowCursor: null };
280
285
  }
281
286
  let rowCursor = startRowCursor;
282
287
  for (let page = 0; page < limits.maxSnapshotPages; page++) {
283
- const scanned = await storage.scanRows(partition, {
284
- table: plan.table.name,
285
- scopeFilter: plan.effective,
286
- afterRowId: rowCursor,
287
- limit: limits.limitSnapshotRows + 1,
288
- });
288
+ const scanned = page === 0 && firstPage !== undefined
289
+ ? firstPage
290
+ : await storage.scanRows(partition, {
291
+ table: plan.table.name,
292
+ scopeFilter: plan.effective,
293
+ afterRowId: rowCursor,
294
+ limit: limits.limitSnapshotRows + 1,
295
+ });
289
296
  const pageRows = scanned.slice(0, limits.limitSnapshotRows);
290
297
  const hasMore = scanned.length > limits.limitSnapshotRows;
291
298
  // §5.2: every row record carries the row's current server_version.
@@ -343,11 +350,73 @@ async function* bootstrapSegments(ctx, schema, limits, plan, asOf, startRowCurso
343
350
  }
344
351
  return { complete: false, rowCursor };
345
352
  }
353
+ /** Run the pre-frame storage reads of every subscription concurrently. */
354
+ export async function prepareSections(ctx, limits, plans, maxSeq, horizonSeq) {
355
+ const reads = plans.map((plan) => {
356
+ const sub = plan.frame;
357
+ if (plan.status === 'revoked')
358
+ return {};
359
+ const token = parseBootstrapToken(sub.bootstrapState, sub.table);
360
+ if (token === undefined &&
361
+ sub.cursor >= 0 &&
362
+ sub.cursor >= horizonSeq &&
363
+ sub.cursor <= maxSeq) {
364
+ return {
365
+ commits: ctx.storage.readCommitWindow(ctx.partition, {
366
+ table: sub.table,
367
+ scopeFilter: plan.effective,
368
+ afterSeq: sub.cursor,
369
+ throughSeq: maxSeq,
370
+ limitChanges: limits.limitCommits + 1,
371
+ }),
372
+ };
373
+ }
374
+ // §4.6 reset: no reads.
375
+ if (token === undefined && sub.cursor >= 0 && sub.cursor < horizonSeq) {
376
+ return {};
377
+ }
378
+ // Bootstrapping: the same resume decision `preparedSection` makes (§4.7).
379
+ const startCursor = token !== undefined && token.asOfCommitSeq >= horizonSeq
380
+ ? token.rowCursor
381
+ : null;
382
+ if (sqliteLaneEligible(limits, plan, startCursor))
383
+ return {};
384
+ return {
385
+ firstPage: ctx.storage.scanRows(ctx.partition, {
386
+ table: plan.table.name,
387
+ scopeFilter: plan.effective,
388
+ afterRowId: startCursor,
389
+ limit: limits.limitSnapshotRows + 1,
390
+ }),
391
+ };
392
+ });
393
+ const settled = await Promise.all(reads.map(async (read) => ({
394
+ commits: read.commits === undefined ? undefined : await read.commits,
395
+ firstPage: await read.firstPage,
396
+ })));
397
+ // Validate continuity before committing to an active section. A prune
398
+ // during a paged read can otherwise make an incomplete window look empty.
399
+ const windowHorizon = settled.some((read) => read.commits !== undefined)
400
+ ? Math.max(horizonSeq, await ctx.storage.getHorizonSeq(ctx.partition))
401
+ : horizonSeq;
402
+ return settled.map((read) => ({
403
+ commits: read.commits ?? [],
404
+ horizonSeq: read.commits === undefined ? horizonSeq : windowHorizon,
405
+ firstPage: read.firstPage,
406
+ }));
407
+ }
346
408
  /**
347
409
  * Produce the `SUB_START … SUB_END` section for one subscription (§1.6),
348
410
  * returning the cursor recorded for the retention watermark (§4.5).
349
411
  */
350
412
  export async function* subscriptionSection(ctx, schema, limits, plan, maxSeq, horizonSeq, trace, logEpoch) {
413
+ const [prepared] = await prepareSections(ctx, limits, [plan], maxSeq, horizonSeq);
414
+ if (prepared === undefined)
415
+ throw new Error('prepareSections lost a plan');
416
+ return yield* preparedSection(ctx, schema, limits, plan, maxSeq, prepared, trace, logEpoch);
417
+ }
418
+ /** The section of one subscription whose reads `prepareSections` ran. */
419
+ export async function* preparedSection(ctx, schema, limits, plan, maxSeq, prepared, trace, logEpoch) {
351
420
  const sub = plan.frame;
352
421
  if (logEpoch === undefined || logEpoch.length === 0) {
353
422
  throw new Error('subscriptionSection requires a non-empty log epoch');
@@ -365,22 +434,7 @@ export async function* subscriptionSection(ctx, schema, limits, plan, maxSeq, ho
365
434
  return { nextCursor: sub.cursor, active: false };
366
435
  }
367
436
  const token = parseBootstrapToken(sub.bootstrapState, sub.table);
368
- let commits = [];
369
- if (token === undefined &&
370
- sub.cursor >= 0 &&
371
- sub.cursor >= horizonSeq &&
372
- sub.cursor <= maxSeq) {
373
- commits = await ctx.storage.readCommitWindow(ctx.partition, {
374
- table: sub.table,
375
- scopeFilter: plan.effective,
376
- afterSeq: sub.cursor,
377
- throughSeq: maxSeq,
378
- limitChanges: limits.limitCommits + 1,
379
- });
380
- // Validate continuity before committing to an active section. A prune
381
- // during a paged read can otherwise make an incomplete window look empty.
382
- horizonSeq = Math.max(horizonSeq, await ctx.storage.getHorizonSeq(ctx.partition));
383
- }
437
+ const { commits, horizonSeq } = prepared;
384
438
  // §4.6: a cursor behind the horizon (and not resuming a bootstrap)
385
439
  // cannot compute deltas — answer `reset` and echo the cursor.
386
440
  if (token === undefined && sub.cursor >= 0 && sub.cursor < horizonSeq) {
@@ -412,7 +466,7 @@ export async function* subscriptionSection(ctx, schema, limits, plan, maxSeq, ho
412
466
  effectiveScopes: plan.effective,
413
467
  bootstrap: true,
414
468
  };
415
- const outcome = yield* bootstrapSegments(ctx, schema, limits, plan, asOf, startCursor, trace, logEpoch);
469
+ const outcome = yield* bootstrapSegments(ctx, schema, limits, plan, asOf, startCursor, trace, logEpoch, prepared.firstPage);
416
470
  if (outcome.complete) {
417
471
  yield { type: 'SUB_END', nextCursor: asOf };
418
472
  }
@@ -225,6 +225,30 @@ export declare function scanRowPageSql(table: CompiledTable, valueCount: number,
225
225
  * again for the joins).
226
226
  */
227
227
  export declare function commitWindowPageSql(valueCount: number, dialect: RelationalDialect): string;
228
+ /**
229
+ * PostgreSQL set-based form of `scanRowPageSql`: one statement answers the
230
+ * page of every subscription scanning `table` in a pull. `$2` is a JSON array
231
+ * of `{i, var, vals, after, lim}` records; each record runs the same
232
+ * index-first candidate subquery through a LATERAL join, so every
233
+ * subscription keeps its own keyset cursor and LIMIT. Rows arrive ordered
234
+ * by (i, row_id).
235
+ *
236
+ * Params: [partition, specs (JSON text), tbl]. The specs bind as text before
237
+ * the JSONB cast, so a driver that JSON-encodes a string bound to a `jsonb`
238
+ * parameter (Bun.sql) cannot turn the array into a JSON string.
239
+ */
240
+ export declare function scanRowBatchSql(table: CompiledTable): string;
241
+ /**
242
+ * PostgreSQL set-based form of `commitWindowPageSql`: one statement answers
243
+ * the window page of every subscription reading `tbl` in a pull. `$2` is a
244
+ * JSON array of `{i, var, vals, after, through, lim}` records, each running
245
+ * the same index-first candidate subquery through a LATERAL join. Rows
246
+ * arrive ordered by (i, commit_seq, idx).
247
+ *
248
+ * Params: [partition, specs (JSON text), tbl], the specs bound as text like
249
+ * `scanRowBatchSql`.
250
+ */
251
+ export declare const COMMIT_WINDOW_BATCH_SQL = "SELECT s.i AS sub, c.commit_seq AS commit_seq, m.actor_id AS actor_id, m.created_at_ms AS created_at_ms,\n ch.tbl AS tbl, ch.row_id AS row_id, ch.op AS op, ch.row_version AS row_version, ch.scopes AS scopes, ch.payload AS payload\n FROM jsonb_to_recordset($2::text::jsonb) AS s(i int, var text, vals text[], after bigint, through bigint, lim int)\n CROSS JOIN LATERAL (SELECT DISTINCT commit_seq FROM sync_change_scopes\n WHERE partition=$1 AND tbl=$3 AND var=s.var AND value=ANY(s.vals)\n AND commit_seq>s.after AND commit_seq<=s.through\n ORDER BY commit_seq LIMIT s.lim) c\n LEFT JOIN sync_commits m\n ON m.partition=$1 AND m.commit_seq=c.commit_seq\n LEFT JOIN sync_changes ch\n ON ch.partition=$1 AND ch.commit_seq=c.commit_seq AND ch.tbl=$3\n ORDER BY s.i, c.commit_seq, ch.idx";
228
252
  /** SELECT a row's version + scope map (admin/blob authz). Params: [partition, rowId]. */
229
253
  export declare function selectRowScopesSql(table: CompiledTable, dialect: RelationalDialect): string;
230
254
  /** DELETE one stored row. Params: [partition, rowId]. */
@@ -548,6 +548,51 @@ export function commitWindowPageSql(valueCount, dialect) {
548
548
  ON ch.partition=${joinPartition} AND ch.commit_seq=c.commit_seq AND ch.tbl=${joinTbl}
549
549
  ORDER BY c.commit_seq, ch.idx`;
550
550
  }
551
+ /**
552
+ * PostgreSQL set-based form of `scanRowPageSql`: one statement answers the
553
+ * page of every subscription scanning `table` in a pull. `$2` is a JSON array
554
+ * of `{i, var, vals, after, lim}` records; each record runs the same
555
+ * index-first candidate subquery through a LATERAL join, so every
556
+ * subscription keeps its own keyset cursor and LIMIT. Rows arrive ordered
557
+ * by (i, row_id).
558
+ *
559
+ * Params: [partition, specs (JSON text), tbl]. The specs bind as text before
560
+ * the JSONB cast, so a driver that JSON-encodes a string bound to a `jsonb`
561
+ * parameter (Bun.sql) cannot turn the array into a JSON string.
562
+ */
563
+ export function scanRowBatchSql(table) {
564
+ return `SELECT s.i AS sub, c.row_id AS row_id, r.${quoteIdent(SYNC_VERSION_COLUMN)} AS server_version, r.${quoteIdent(SYNC_SCOPES_COLUMN)} AS scopes, r.${quoteIdent(SYNC_PAYLOAD_COLUMN)} AS payload, r.${quoteIdent(SYNC_COLUMN_VERSIONS_COLUMN)} AS column_versions
565
+ FROM jsonb_to_recordset($2::text::jsonb) AS s(i int, var text, vals text[], after text, lim int)
566
+ CROSS JOIN LATERAL (SELECT DISTINCT row_id FROM sync_row_scopes
567
+ WHERE partition=$1 AND tbl=$3 AND var=s.var AND value=ANY(s.vals)
568
+ AND row_id>s.after
569
+ ORDER BY row_id LIMIT s.lim) c
570
+ LEFT JOIN ${quoteIdent(table.name)} r
571
+ ON r.${quoteIdent(SYNC_PARTITION_COLUMN)}=$1 AND r.${quoteIdent(SYNC_ROW_ID_COLUMN)}=c.row_id
572
+ ORDER BY s.i, c.row_id`;
573
+ }
574
+ /**
575
+ * PostgreSQL set-based form of `commitWindowPageSql`: one statement answers
576
+ * the window page of every subscription reading `tbl` in a pull. `$2` is a
577
+ * JSON array of `{i, var, vals, after, through, lim}` records, each running
578
+ * the same index-first candidate subquery through a LATERAL join. Rows
579
+ * arrive ordered by (i, commit_seq, idx).
580
+ *
581
+ * Params: [partition, specs (JSON text), tbl], the specs bound as text like
582
+ * `scanRowBatchSql`.
583
+ */
584
+ export const COMMIT_WINDOW_BATCH_SQL = `SELECT s.i AS sub, c.commit_seq AS commit_seq, m.actor_id AS actor_id, m.created_at_ms AS created_at_ms,
585
+ ch.tbl AS tbl, ch.row_id AS row_id, ch.op AS op, ch.row_version AS row_version, ch.scopes AS scopes, ch.payload AS payload
586
+ FROM jsonb_to_recordset($2::text::jsonb) AS s(i int, var text, vals text[], after bigint, through bigint, lim int)
587
+ CROSS JOIN LATERAL (SELECT DISTINCT commit_seq FROM sync_change_scopes
588
+ WHERE partition=$1 AND tbl=$3 AND var=s.var AND value=ANY(s.vals)
589
+ AND commit_seq>s.after AND commit_seq<=s.through
590
+ ORDER BY commit_seq LIMIT s.lim) c
591
+ LEFT JOIN sync_commits m
592
+ ON m.partition=$1 AND m.commit_seq=c.commit_seq
593
+ LEFT JOIN sync_changes ch
594
+ ON ch.partition=$1 AND ch.commit_seq=c.commit_seq AND ch.tbl=$3
595
+ ORDER BY s.i, c.commit_seq, ch.idx`;
551
596
  /** SELECT a row's version + scope map (admin/blob authz). Params: [partition, rowId]. */
552
597
  export function selectRowScopesSql(table, dialect) {
553
598
  const p = dialect === 'sqlite' ? ['?', '?'] : ['$1', '$2'];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@syncular/server",
3
- "version": "0.26.1",
3
+ "version": "0.26.4",
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.26.1"
71
+ "@syncular/core": "0.26.4"
72
72
  },
73
73
  "devDependencies": {
74
74
  "@electric-sql/pglite": "^0.5.4"