@syncular/server 0.26.0 → 0.26.3

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/src/pull.ts CHANGED
@@ -207,6 +207,26 @@ function segmentRefFrame(
207
207
  };
208
208
  }
209
209
 
210
+ /**
211
+ * §5.3: the sqlite-image lane — whole-table, chosen only at the start of a
212
+ * table (a mid-table resume stays on the rows lane, never switching lanes),
213
+ * and only when the client advertised bit 2. §5.11: a table with any
214
+ * encrypted column is image-INELIGIBLE — an image copies ciphertext
215
+ * wholesale with no per-row decrypt pass, so it MUST be served on the rows
216
+ * lane (which decrypts per row on the client).
217
+ */
218
+ function sqliteLaneEligible(
219
+ limits: PullLimits,
220
+ plan: SubscriptionPlan,
221
+ startRowCursor: string | null,
222
+ ): boolean {
223
+ return (
224
+ (limits.accept & ACCEPT_SQLITE) !== 0 &&
225
+ startRowCursor === null &&
226
+ plan.table.encryptedColumnIndices.length === 0
227
+ );
228
+ }
229
+
210
230
  /**
211
231
  * §5.3 sqlite-image lane: only at the start of a table, only when the
212
232
  * client advertised bit 2, and only when the snapshot exceeds one rows
@@ -369,6 +389,7 @@ async function* bootstrapSegments(
369
389
  startRowCursor: string | null,
370
390
  trace: PullSectionTrace | undefined,
371
391
  logEpoch: string,
392
+ firstPage: StoredRow[] | undefined,
372
393
  ): AsyncGenerator<
373
394
  ResponseFrame,
374
395
  { complete: boolean; rowCursor: string | null }
@@ -377,17 +398,7 @@ async function* bootstrapSegments(
377
398
  const serverLimits = limitsOf(ctx);
378
399
  const digest = await scopeDigest(plan.effective);
379
400
  const now = clockOf(ctx)();
380
- // §5.3: the sqlite-image lane — whole-table, chosen only at the start
381
- // of a table (a mid-table resume stays on the rows lane, never
382
- // switching lanes), and only when the client advertised bit 2.
383
- // §5.11: a table with any encrypted column is image-INELIGIBLE — an image
384
- // copies ciphertext wholesale with no per-row decrypt pass, so it MUST be
385
- // served on the rows lane (which decrypts per row on the client).
386
- if (
387
- (limits.accept & ACCEPT_SQLITE) !== 0 &&
388
- startRowCursor === null &&
389
- plan.table.encryptedColumnIndices.length === 0
390
- ) {
401
+ if (sqliteLaneEligible(limits, plan, startRowCursor)) {
391
402
  const imaged = yield* sqliteImageSegment(
392
403
  ctx,
393
404
  schema,
@@ -402,12 +413,15 @@ async function* bootstrapSegments(
402
413
  }
403
414
  let rowCursor = startRowCursor;
404
415
  for (let page = 0; page < limits.maxSnapshotPages; page++) {
405
- const scanned = await storage.scanRows(partition, {
406
- table: plan.table.name,
407
- scopeFilter: plan.effective,
408
- afterRowId: rowCursor,
409
- limit: limits.limitSnapshotRows + 1,
410
- });
416
+ const scanned =
417
+ page === 0 && firstPage !== undefined
418
+ ? firstPage
419
+ : await storage.scanRows(partition, {
420
+ table: plan.table.name,
421
+ scopeFilter: plan.effective,
422
+ afterRowId: rowCursor,
423
+ limit: limits.limitSnapshotRows + 1,
424
+ });
411
425
  const pageRows = scanned.slice(0, limits.limitSnapshotRows);
412
426
  const hasMore = scanned.length > limits.limitSnapshotRows;
413
427
  // §5.2: every row record carries the row's current server_version.
@@ -475,6 +489,86 @@ async function* bootstrapSegments(
475
489
  return { complete: false, rowCursor };
476
490
  }
477
491
 
492
+ /**
493
+ * The storage reads a section needs before its first frame. A pull starts
494
+ * them for every subscription before awaiting any, so a storage that batches
495
+ * concurrent page reads (PostgreSQL) answers all subscriptions of one table
496
+ * with one statement, and the horizon re-check runs once per pull.
497
+ */
498
+ export interface PreparedSection {
499
+ /** §4.5 window read for an incremental cursor; empty when none was read. */
500
+ readonly commits: StoredCommit[];
501
+ /** Horizon to judge the cursor against, re-read after any window read. */
502
+ readonly horizonSeq: number;
503
+ /** First rows-lane snapshot page, read when the sqlite lane is excluded. */
504
+ readonly firstPage: StoredRow[] | undefined;
505
+ }
506
+
507
+ /** Run the pre-frame storage reads of every subscription concurrently. */
508
+ export async function prepareSections(
509
+ ctx: SyncRequestContext,
510
+ limits: PullLimits,
511
+ plans: readonly SubscriptionPlan[],
512
+ maxSeq: number,
513
+ horizonSeq: number,
514
+ ): Promise<PreparedSection[]> {
515
+ const reads = plans.map((plan) => {
516
+ const sub = plan.frame;
517
+ if (plan.status === 'revoked') return {};
518
+ const token = parseBootstrapToken(sub.bootstrapState, sub.table);
519
+ if (
520
+ token === undefined &&
521
+ sub.cursor >= 0 &&
522
+ sub.cursor >= horizonSeq &&
523
+ sub.cursor <= maxSeq
524
+ ) {
525
+ return {
526
+ commits: ctx.storage.readCommitWindow(ctx.partition, {
527
+ table: sub.table,
528
+ scopeFilter: plan.effective,
529
+ afterSeq: sub.cursor,
530
+ throughSeq: maxSeq,
531
+ limitChanges: limits.limitCommits + 1,
532
+ }),
533
+ };
534
+ }
535
+ // §4.6 reset: no reads.
536
+ if (token === undefined && sub.cursor >= 0 && sub.cursor < horizonSeq) {
537
+ return {};
538
+ }
539
+ // Bootstrapping: the same resume decision `preparedSection` makes (§4.7).
540
+ const startCursor =
541
+ token !== undefined && token.asOfCommitSeq >= horizonSeq
542
+ ? token.rowCursor
543
+ : null;
544
+ if (sqliteLaneEligible(limits, plan, startCursor)) return {};
545
+ return {
546
+ firstPage: ctx.storage.scanRows(ctx.partition, {
547
+ table: plan.table.name,
548
+ scopeFilter: plan.effective,
549
+ afterRowId: startCursor,
550
+ limit: limits.limitSnapshotRows + 1,
551
+ }),
552
+ };
553
+ });
554
+ const settled = await Promise.all(
555
+ reads.map(async (read) => ({
556
+ commits: read.commits === undefined ? undefined : await read.commits,
557
+ firstPage: await read.firstPage,
558
+ })),
559
+ );
560
+ // Validate continuity before committing to an active section. A prune
561
+ // during a paged read can otherwise make an incomplete window look empty.
562
+ const windowHorizon = settled.some((read) => read.commits !== undefined)
563
+ ? Math.max(horizonSeq, await ctx.storage.getHorizonSeq(ctx.partition))
564
+ : horizonSeq;
565
+ return settled.map((read) => ({
566
+ commits: read.commits ?? [],
567
+ horizonSeq: read.commits === undefined ? horizonSeq : windowHorizon,
568
+ firstPage: read.firstPage,
569
+ }));
570
+ }
571
+
478
572
  /**
479
573
  * Produce the `SUB_START … SUB_END` section for one subscription (§1.6),
480
574
  * returning the cursor recorded for the retention watermark (§4.5).
@@ -488,6 +582,37 @@ export async function* subscriptionSection(
488
582
  horizonSeq: number,
489
583
  trace?: PullSectionTrace,
490
584
  logEpoch?: string,
585
+ ): AsyncGenerator<ResponseFrame, SubscriptionResult> {
586
+ const [prepared] = await prepareSections(
587
+ ctx,
588
+ limits,
589
+ [plan],
590
+ maxSeq,
591
+ horizonSeq,
592
+ );
593
+ if (prepared === undefined) throw new Error('prepareSections lost a plan');
594
+ return yield* preparedSection(
595
+ ctx,
596
+ schema,
597
+ limits,
598
+ plan,
599
+ maxSeq,
600
+ prepared,
601
+ trace,
602
+ logEpoch,
603
+ );
604
+ }
605
+
606
+ /** The section of one subscription whose reads `prepareSections` ran. */
607
+ export async function* preparedSection(
608
+ ctx: SyncRequestContext,
609
+ schema: CompiledSchema,
610
+ limits: PullLimits,
611
+ plan: SubscriptionPlan,
612
+ maxSeq: number,
613
+ prepared: PreparedSection,
614
+ trace?: PullSectionTrace,
615
+ logEpoch?: string,
491
616
  ): AsyncGenerator<ResponseFrame, SubscriptionResult> {
492
617
  const sub = plan.frame;
493
618
  if (logEpoch === undefined || logEpoch.length === 0) {
@@ -508,28 +633,7 @@ export async function* subscriptionSection(
508
633
  }
509
634
 
510
635
  const token = parseBootstrapToken(sub.bootstrapState, sub.table);
511
-
512
- let commits: StoredCommit[] = [];
513
- if (
514
- token === undefined &&
515
- sub.cursor >= 0 &&
516
- sub.cursor >= horizonSeq &&
517
- sub.cursor <= maxSeq
518
- ) {
519
- commits = await ctx.storage.readCommitWindow(ctx.partition, {
520
- table: sub.table,
521
- scopeFilter: plan.effective,
522
- afterSeq: sub.cursor,
523
- throughSeq: maxSeq,
524
- limitChanges: limits.limitCommits + 1,
525
- });
526
- // Validate continuity before committing to an active section. A prune
527
- // during a paged read can otherwise make an incomplete window look empty.
528
- horizonSeq = Math.max(
529
- horizonSeq,
530
- await ctx.storage.getHorizonSeq(ctx.partition),
531
- );
532
- }
636
+ const { commits, horizonSeq } = prepared;
533
637
 
534
638
  // §4.6: a cursor behind the horizon (and not resuming a bootstrap)
535
639
  // cannot compute deltas — answer `reset` and echo the cursor.
@@ -575,6 +679,7 @@ export async function* subscriptionSection(
575
679
  startCursor,
576
680
  trace,
577
681
  logEpoch,
682
+ prepared.firstPage,
578
683
  );
579
684
  if (outcome.complete) {
580
685
  yield { type: 'SUB_END', nextCursor: asOf };
@@ -644,6 +644,50 @@ export function commitWindowPageSql(
644
644
  ORDER BY c.commit_seq, ch.idx`;
645
645
  }
646
646
 
647
+ /**
648
+ * PostgreSQL set-based form of `scanRowPageSql`: one statement answers the
649
+ * page of every subscription scanning `table` in a pull. `$2` is a JSON array
650
+ * of `{i, var, vals, after, lim}` records; each record runs the same
651
+ * index-first candidate subquery through a LATERAL join, so every
652
+ * subscription keeps its own keyset cursor and LIMIT. Rows arrive ordered
653
+ * by (i, row_id).
654
+ *
655
+ * Params: [partition, specs (jsonb text), tbl].
656
+ */
657
+ export function scanRowBatchSql(table: CompiledTable): string {
658
+ 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
659
+ FROM jsonb_to_recordset($2::jsonb) AS s(i int, var text, vals text[], after text, lim int)
660
+ CROSS JOIN LATERAL (SELECT DISTINCT row_id FROM sync_row_scopes
661
+ WHERE partition=$1 AND tbl=$3 AND var=s.var AND value=ANY(s.vals)
662
+ AND row_id>s.after
663
+ ORDER BY row_id LIMIT s.lim) c
664
+ LEFT JOIN ${quoteIdent(table.name)} r
665
+ ON r.${quoteIdent(SYNC_PARTITION_COLUMN)}=$1 AND r.${quoteIdent(SYNC_ROW_ID_COLUMN)}=c.row_id
666
+ ORDER BY s.i, c.row_id`;
667
+ }
668
+
669
+ /**
670
+ * PostgreSQL set-based form of `commitWindowPageSql`: one statement answers
671
+ * the window page of every subscription reading `tbl` in a pull. `$2` is a
672
+ * JSON array of `{i, var, vals, after, through, lim}` records, each running
673
+ * the same index-first candidate subquery through a LATERAL join. Rows
674
+ * arrive ordered by (i, commit_seq, idx).
675
+ *
676
+ * Params: [partition, specs (jsonb text), tbl].
677
+ */
678
+ 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,
679
+ 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
680
+ FROM jsonb_to_recordset($2::jsonb) AS s(i int, var text, vals text[], after bigint, through bigint, lim int)
681
+ CROSS JOIN LATERAL (SELECT DISTINCT commit_seq FROM sync_change_scopes
682
+ WHERE partition=$1 AND tbl=$3 AND var=s.var AND value=ANY(s.vals)
683
+ AND commit_seq>s.after AND commit_seq<=s.through
684
+ ORDER BY commit_seq LIMIT s.lim) c
685
+ LEFT JOIN sync_commits m
686
+ ON m.partition=$1 AND m.commit_seq=c.commit_seq
687
+ LEFT JOIN sync_changes ch
688
+ ON ch.partition=$1 AND ch.commit_seq=c.commit_seq AND ch.tbl=$3
689
+ ORDER BY s.i, c.commit_seq, ch.idx`;
690
+
647
691
  /** SELECT a row's version + scope map (admin/blob authz). Params: [partition, rowId]. */
648
692
  export function selectRowScopesSql(
649
693
  table: CompiledTable,