@syncular/server 0.26.5 → 0.27.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.
Files changed (47) hide show
  1. package/dist/content-encoding.d.ts +17 -0
  2. package/dist/content-encoding.js +22 -0
  3. package/dist/context.d.ts +9 -0
  4. package/dist/d1-storage.d.ts +46 -0
  5. package/dist/d1-storage.js +12 -1
  6. package/dist/errors.d.ts +19 -0
  7. package/dist/errors.js +29 -0
  8. package/dist/handler.js +1 -1
  9. package/dist/index.d.ts +1 -1
  10. package/dist/index.js +1 -1
  11. package/dist/operations-realtime.js +4 -1
  12. package/dist/operations.js +6 -1
  13. package/dist/postgres-storage.d.ts +42 -0
  14. package/dist/postgres-storage.js +12 -1
  15. package/dist/pull.d.ts +46 -6
  16. package/dist/pull.js +129 -34
  17. package/dist/realtime.d.ts +3 -0
  18. package/dist/realtime.js +32 -26
  19. package/dist/relational-rows.d.ts +13 -0
  20. package/dist/relational-rows.js +25 -0
  21. package/dist/s3-segment-store.d.ts +3 -1
  22. package/dist/s3-segment-store.js +16 -0
  23. package/dist/segment-download.d.ts +16 -0
  24. package/dist/segment-download.js +71 -21
  25. package/dist/segment-store.d.ts +16 -0
  26. package/dist/segment-store.js +19 -0
  27. package/dist/sqlite-storage.d.ts +5 -0
  28. package/dist/sqlite-storage.js +18 -1
  29. package/dist/storage.d.ts +12 -0
  30. package/package.json +2 -2
  31. package/src/content-encoding.ts +35 -0
  32. package/src/context.ts +9 -0
  33. package/src/d1-storage.ts +20 -1
  34. package/src/errors.ts +56 -0
  35. package/src/handler.ts +2 -0
  36. package/src/index.ts +1 -0
  37. package/src/operations-realtime.ts +4 -1
  38. package/src/operations.ts +6 -1
  39. package/src/postgres-storage.ts +22 -1
  40. package/src/pull.ts +212 -39
  41. package/src/realtime.ts +45 -32
  42. package/src/relational-rows.ts +29 -0
  43. package/src/s3-segment-store.ts +15 -0
  44. package/src/segment-download.ts +129 -21
  45. package/src/segment-store.ts +36 -0
  46. package/src/sqlite-storage.ts +27 -0
  47. package/src/storage.ts +13 -0
package/src/pull.ts CHANGED
@@ -9,13 +9,18 @@ import {
9
9
  type PullHeaderFrame,
10
10
  type ResponseFrame,
11
11
  type ScopeMap,
12
+ type SegmentRefFrame,
12
13
  type SegmentRow,
13
14
  type SubscriptionFrame,
14
15
  } from '@syncular/core';
15
- import type { SyncRequestContext } from './context';
16
+ import type { SyncRequestContext, SyncServerConfig } from './context';
16
17
  import { clockOf, limitsOf } from './context';
17
18
  import type { PullSegmentSummary } from './events';
18
- import type { CompiledSchema, CompiledTable } from './schema';
19
+ import {
20
+ type CompiledSchema,
21
+ type CompiledTable,
22
+ compileSchema,
23
+ } from './schema';
19
24
  import { scopeDigest } from './scopes';
20
25
  import type { SegmentRecord, SegmentStore } from './segment-store';
21
26
  import { issueSegmentUrl } from './signed-url';
@@ -244,21 +249,11 @@ async function* sqliteImageSegment(
244
249
  digest: string,
245
250
  trace: PullSectionTrace | undefined,
246
251
  logEpoch: string,
252
+ existing: SegmentRecord | undefined,
253
+ probe: StoredRow[],
247
254
  ): AsyncGenerator<ResponseFrame, boolean> {
248
255
  const { storage, segments, partition } = ctx;
249
256
  const now = clockOf(ctx)();
250
- const existing = await segments.find(
251
- {
252
- partition,
253
- logEpoch,
254
- table: plan.table.name,
255
- schemaVersion: schema.version,
256
- mediaType: 'sqlite',
257
- scopeDigest: digest,
258
- asOfCommitSeq: asOf,
259
- },
260
- now,
261
- );
262
257
  if (existing !== undefined) {
263
258
  trace?.segments.push({
264
259
  mediaType: 'sqlite',
@@ -276,12 +271,7 @@ async function* sqliteImageSegment(
276
271
  }
277
272
  // Eligibility probe (§5.3): image only when the snapshot exceeds one
278
273
  // rows page — smaller tables stay on the (typically inline) rows lane.
279
- const probe = await storage.scanRows(partition, {
280
- table: plan.table.name,
281
- scopeFilter: plan.effective,
282
- afterRowId: null,
283
- limit: limits.limitSnapshotRows + 1,
284
- });
274
+ // The probe is the rows lane's first page, read by `prepareSections`.
285
275
  if (probe.length <= limits.limitSnapshotRows) return false;
286
276
  // §5.3: building an image needs a SQLite engine. The host injects the
287
277
  // builder through `sqliteImageBuilder`; when omitted we default to the
@@ -389,7 +379,8 @@ async function* bootstrapSegments(
389
379
  startRowCursor: string | null,
390
380
  trace: PullSectionTrace | undefined,
391
381
  logEpoch: string,
392
- firstPage: StoredRow[] | undefined,
382
+ firstPage: StoredRow[],
383
+ image: SegmentRecord | undefined,
393
384
  ): AsyncGenerator<
394
385
  ResponseFrame,
395
386
  { complete: boolean; rowCursor: string | null }
@@ -408,13 +399,15 @@ async function* bootstrapSegments(
408
399
  digest,
409
400
  trace,
410
401
  logEpoch,
402
+ image,
403
+ firstPage,
411
404
  );
412
405
  if (imaged) return { complete: true, rowCursor: null };
413
406
  }
414
407
  let rowCursor = startRowCursor;
415
408
  for (let page = 0; page < limits.maxSnapshotPages; page++) {
416
409
  const scanned =
417
- page === 0 && firstPage !== undefined
410
+ page === 0
418
411
  ? firstPage
419
412
  : await storage.scanRows(partition, {
420
413
  table: plan.table.name,
@@ -492,27 +485,45 @@ async function* bootstrapSegments(
492
485
  /**
493
486
  * The storage reads a section needs before its first frame. A pull starts
494
487
  * 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.
488
+ * concurrent page reads (PostgreSQL, D1) answers all subscriptions of one
489
+ * table with one statement, and the horizon re-check runs once per pull.
497
490
  */
498
491
  export interface PreparedSection {
499
492
  /** §4.5 window read for an incremental cursor; empty when none was read. */
500
493
  readonly commits: StoredCommit[];
501
494
  /** Horizon to judge the cursor against, re-read after any window read. */
502
495
  readonly horizonSeq: number;
503
- /** First rows-lane snapshot page, read when the sqlite lane is excluded. */
496
+ /**
497
+ * A bootstrap's first snapshot page (`limitSnapshotRows + 1` rows from the
498
+ * resume cursor), which is also the §5.3 image eligibility probe.
499
+ */
504
500
  readonly firstPage: StoredRow[] | undefined;
501
+ /** §4.7 pin of a fresh bootstrap; absent ⇒ `maxCommitSeq`. */
502
+ readonly pin: number | undefined;
503
+ /** §5.3 stored image for a sqlite-lane bootstrap's reuse key, if any. */
504
+ readonly image: SegmentRecord | undefined;
505
+ }
506
+
507
+ interface PendingSection {
508
+ readonly commits?: Promise<StoredCommit[]>;
509
+ readonly firstPage?: Promise<StoredRow[]>;
510
+ readonly lane?: Promise<{
511
+ readonly pin: number | undefined;
512
+ readonly image: SegmentRecord | undefined;
513
+ }>;
505
514
  }
506
515
 
507
516
  /** Run the pre-frame storage reads of every subscription concurrently. */
508
517
  export async function prepareSections(
509
518
  ctx: SyncRequestContext,
519
+ schema: CompiledSchema,
510
520
  limits: PullLimits,
511
521
  plans: readonly SubscriptionPlan[],
512
522
  maxSeq: number,
513
523
  horizonSeq: number,
524
+ logEpoch: string,
514
525
  ): Promise<PreparedSection[]> {
515
- const reads = plans.map((plan) => {
526
+ const reads = plans.map((plan): PendingSection => {
516
527
  const sub = plan.frame;
517
528
  if (plan.status === 'revoked') return {};
518
529
  const token = parseBootstrapToken(sub.bootstrapState, sub.table);
@@ -537,24 +548,55 @@ export async function prepareSections(
537
548
  return {};
538
549
  }
539
550
  // Bootstrapping: the same resume decision `preparedSection` makes (§4.7).
540
- const startCursor =
551
+ const resume =
541
552
  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
+ ? token
554
+ : undefined;
555
+ const startCursor = resume?.rowCursor ?? null;
556
+ const firstPage = ctx.storage.scanRows(ctx.partition, {
557
+ table: plan.table.name,
558
+ scopeFilter: plan.effective,
559
+ afterRowId: startCursor,
560
+ limit: limits.limitSnapshotRows + 1,
561
+ });
562
+ if (!sqliteLaneEligible(limits, plan, startCursor)) return { firstPage };
563
+ // §4.7: a fresh sqlite-lane bootstrap pins at the newest change in its
564
+ // scope (never below the horizon, never above maxCommitSeq), so a stored
565
+ // image stays the reuse key's match until this scope changes (§5.3).
566
+ const pinned =
567
+ resume !== undefined
568
+ ? Promise.resolve({ asOf: resume.asOfCommitSeq, pin: undefined })
569
+ : ctx.storage
570
+ .latestScopedChangeSeq(ctx.partition, {
571
+ table: plan.table.name,
572
+ scopeFilter: plan.effective,
573
+ })
574
+ .then((latest) => {
575
+ const pin = Math.min(maxSeq, Math.max(latest, horizonSeq));
576
+ return { asOf: pin, pin };
577
+ });
578
+ const lane = pinned.then(async ({ asOf, pin }) => ({
579
+ pin,
580
+ image: await ctx.segments.find(
581
+ {
582
+ partition: ctx.partition,
583
+ logEpoch,
584
+ table: plan.table.name,
585
+ schemaVersion: schema.version,
586
+ mediaType: 'sqlite',
587
+ scopeDigest: await scopeDigest(plan.effective),
588
+ asOfCommitSeq: asOf,
589
+ },
590
+ clockOf(ctx)(),
591
+ ),
592
+ }));
593
+ return { firstPage, lane };
553
594
  });
554
595
  const settled = await Promise.all(
555
596
  reads.map(async (read) => ({
556
597
  commits: read.commits === undefined ? undefined : await read.commits,
557
598
  firstPage: await read.firstPage,
599
+ lane: await read.lane,
558
600
  })),
559
601
  );
560
602
  // Validate continuity before committing to an active section. A prune
@@ -566,6 +608,8 @@ export async function prepareSections(
566
608
  commits: read.commits ?? [],
567
609
  horizonSeq: read.commits === undefined ? horizonSeq : windowHorizon,
568
610
  firstPage: read.firstPage,
611
+ pin: read.lane?.pin,
612
+ image: read.lane?.image,
569
613
  }));
570
614
  }
571
615
 
@@ -583,12 +627,17 @@ export async function* subscriptionSection(
583
627
  trace?: PullSectionTrace,
584
628
  logEpoch?: string,
585
629
  ): AsyncGenerator<ResponseFrame, SubscriptionResult> {
630
+ if (logEpoch === undefined || logEpoch.length === 0) {
631
+ throw new Error('subscriptionSection requires a non-empty log epoch');
632
+ }
586
633
  const [prepared] = await prepareSections(
587
634
  ctx,
635
+ schema,
588
636
  limits,
589
637
  [plan],
590
638
  maxSeq,
591
639
  horizonSeq,
640
+ logEpoch,
592
641
  );
593
642
  if (prepared === undefined) throw new Error('prepareSections lost a plan');
594
643
  return yield* preparedSection(
@@ -660,8 +709,11 @@ export async function* preparedSection(
660
709
  token !== undefined && token.asOfCommitSeq >= horizonSeq
661
710
  ? token
662
711
  : undefined;
663
- const asOf = resume?.asOfCommitSeq ?? maxSeq;
712
+ const asOf = resume?.asOfCommitSeq ?? prepared.pin ?? maxSeq;
664
713
  const startCursor = resume?.rowCursor ?? null;
714
+ if (prepared.firstPage === undefined) {
715
+ throw new Error('prepareSections did not read the bootstrap page');
716
+ }
665
717
  yield {
666
718
  type: 'SUB_START',
667
719
  id: sub.id,
@@ -680,6 +732,7 @@ export async function* preparedSection(
680
732
  trace,
681
733
  logEpoch,
682
734
  prepared.firstPage,
735
+ prepared.image,
683
736
  );
684
737
  if (outcome.complete) {
685
738
  yield { type: 'SUB_END', nextCursor: asOf };
@@ -740,3 +793,123 @@ export async function* preparedSection(
740
793
  yield { type: 'SUB_END', nextCursor };
741
794
  return { nextCursor, active: true };
742
795
  }
796
+
797
+ export interface PublishSqliteImageInput {
798
+ /** The sync config the serving host runs, plus a `sqliteImageBuilder`. */
799
+ readonly config: SyncServerConfig;
800
+ readonly partition: string;
801
+ readonly table: string;
802
+ /**
803
+ * The effective scopes the subscribing clients hold for `table` (§3.2):
804
+ * the reuse key carries their digest, so publish once per distinct scope
805
+ * set that clients subscribe with.
806
+ */
807
+ readonly scopes: ScopeMap;
808
+ }
809
+
810
+ export interface PublishedSqliteImage {
811
+ readonly segmentId: string;
812
+ /** The §4.7 scope pin the image is stored under. */
813
+ readonly asOfCommitSeq: number;
814
+ readonly scopeDigest: string;
815
+ readonly rowCount: number;
816
+ readonly byteLength: number;
817
+ /** `reused` when an unexpired image for the same reuse key already existed. */
818
+ readonly origin: 'built' | 'reused';
819
+ }
820
+
821
+ /**
822
+ * Publish the §5.3 sqlite image that a pull for (`table`, `scopes`) looks
823
+ * up, from a host with a SQLite engine (a Bun process with the production
824
+ * storage). It runs the pull's own bootstrap path (the §4.7 scope pin, the
825
+ * reuse key, the build), so the stored identity is exactly the one a
826
+ * serving host without a builder finds. The image stays current until a
827
+ * change reaches the scope; its lifetime is the segment store's TTL at
828
+ * publication.
829
+ */
830
+ export async function publishSqliteImage(
831
+ input: PublishSqliteImageInput,
832
+ ): Promise<PublishedSqliteImage> {
833
+ const { config, partition } = input;
834
+ if (config.sqliteImageBuilder === undefined) {
835
+ throw new Error('publishSqliteImage requires config.sqliteImageBuilder');
836
+ }
837
+ const schema = compileSchema(config.schema);
838
+ const table = schema.tables.get(input.table);
839
+ if (table === undefined) {
840
+ throw new Error('publishSqliteImage: the schema has no such table');
841
+ }
842
+ await config.storage.ensureSchema(schema, config.checkpoints);
843
+ const logEpoch = await config.storage.getPartitionLogEpoch(partition);
844
+ if (logEpoch === undefined) {
845
+ throw new Error('publishSqliteImage: the partition has no log epoch');
846
+ }
847
+ const ctx: SyncRequestContext = {
848
+ ...config,
849
+ partition,
850
+ actorId: 'syncular.image-publisher',
851
+ };
852
+ const plan: SubscriptionPlan = {
853
+ frame: {
854
+ type: 'SUBSCRIPTION',
855
+ id: 'image',
856
+ table: input.table,
857
+ scopes: input.scopes,
858
+ cursor: -1,
859
+ },
860
+ table,
861
+ status: 'active',
862
+ effective: input.scopes,
863
+ };
864
+ // One-row pages make every table above one row image-eligible (§5.3).
865
+ const limits: PullLimits = {
866
+ limitCommits: 1,
867
+ limitSnapshotRows: 1,
868
+ maxSnapshotPages: 1,
869
+ accept: ACCEPT_SQLITE,
870
+ };
871
+ const maxSeq = await config.storage.getMaxCommitSeq(partition);
872
+ const horizonSeq = await config.storage.getHorizonSeq(partition);
873
+ const trace: PullSectionTrace = { segments: [] };
874
+ const [prepared] = await prepareSections(
875
+ ctx,
876
+ schema,
877
+ limits,
878
+ [plan],
879
+ maxSeq,
880
+ horizonSeq,
881
+ logEpoch,
882
+ );
883
+ if (prepared === undefined) throw new Error('prepareSections lost a plan');
884
+ let ref: SegmentRefFrame | undefined;
885
+ for await (const frame of preparedSection(
886
+ ctx,
887
+ schema,
888
+ limits,
889
+ plan,
890
+ maxSeq,
891
+ prepared,
892
+ trace,
893
+ logEpoch,
894
+ )) {
895
+ if (frame.type === 'SEGMENT_REF' && frame.mediaType === 'sqlite') {
896
+ ref = frame;
897
+ }
898
+ }
899
+ const image = trace.segments.find(
900
+ (segment) => segment.mediaType === 'sqlite',
901
+ );
902
+ if (ref === undefined || image === undefined) {
903
+ throw new Error(
904
+ 'publishSqliteImage: the scope holds at most one row, which pulls serve inline',
905
+ );
906
+ }
907
+ return {
908
+ segmentId: ref.segmentId,
909
+ asOfCommitSeq: ref.asOfCommitSeq,
910
+ scopeDigest: ref.scopeDigest,
911
+ rowCount: ref.rowCount,
912
+ byteLength: ref.byteLength,
913
+ origin: image.origin === 'reused' ? 'reused' : 'built',
914
+ };
915
+ }
package/src/realtime.ts CHANGED
@@ -39,7 +39,13 @@ import {
39
39
  RESOLVER_OUTAGE,
40
40
  touchAuthenticatedPartition,
41
41
  } from './context';
42
- import { SyncError, syncError } from './errors';
42
+ import {
43
+ adapterSyncError,
44
+ reportError,
45
+ SyncError,
46
+ type SyncularErrorHandler,
47
+ syncError,
48
+ } from './errors';
43
49
  import { emitEvent, type SyncularServerEvents } from './events';
44
50
  import { createSyncResponseStream } from './handler';
45
51
  import { type CompiledSchema, compileSchema } from './schema';
@@ -581,19 +587,18 @@ export class RealtimeSession {
581
587
  );
582
588
  } catch (error) {
583
589
  // §8.7 failures: what HTTP reports as status+JSON becomes a
584
- // minimal RESP_HEADER/ERROR/END response stream on the socket.
585
- if (error instanceof DecodeError || error instanceof SyncError) {
586
- const sync =
587
- error instanceof SyncError
588
- ? error
589
- : syncError(error.code, error.message);
590
- finishRound(); // END is in this one chunk
591
- await this.#sendRoundChunk(
592
- errorResponseBytes(sync, this.wireVersion, this.logEpoch),
593
- );
594
- return;
595
- }
596
- throw error;
590
+ // minimal RESP_HEADER/ERROR/END response stream on the socket. An
591
+ // exception outside the catalog is reported and answers
592
+ // `sync.internal_error`, exactly like the HTTP adapter's 500.
593
+ const sync =
594
+ error instanceof DecodeError
595
+ ? syncError(error.code, error.message)
596
+ : adapterSyncError(error, this.#hub.onError, 'realtime');
597
+ finishRound(); // END is in this one chunk
598
+ await this.#sendRoundChunk(
599
+ errorResponseBytes(sync, this.wireVersion, this.logEpoch),
600
+ );
601
+ return;
597
602
  }
598
603
  // Fetch-ahead so the round stops being "in flight" the moment the
599
604
  // chunk carrying END is handed to send: the client may legally
@@ -604,23 +609,22 @@ export class RealtimeSession {
604
609
  try {
605
610
  step = await iterator.next();
606
611
  } catch (error) {
607
- // The buffered read-verify refusal is thrown on the generator's first
608
- // `next()`, before any chunk is yielded (the generator builds the whole
609
- // response, then verifies). Nothing has escaped, so it is delivered as
610
- // the same §8.4 ERROR frame HTTP returns rather than a silent close. A
611
- // throw after the first chunk still takes the §8.7 violation path.
612
- if (error instanceof DecodeError || error instanceof SyncError) {
613
- const sync =
614
- error instanceof SyncError
615
- ? error
616
- : syncError(error.code, error.message);
617
- finishRound();
618
- await this.#sendRoundChunk(
619
- errorResponseBytes(sync, this.wireVersion, this.logEpoch),
620
- );
621
- return;
622
- }
623
- throw error;
612
+ // The generator builds the whole response (and runs the read-verify
613
+ // refusal) on its first `next()`, before any chunk is yielded.
614
+ // Nothing has escaped, so a failure there is delivered as the same
615
+ // §8.4 ERROR frame HTTP returns rather than a silent close; a
616
+ // non-catalog exception is reported and answers
617
+ // `sync.internal_error`. A throw after the first chunk still takes
618
+ // the §8.7 violation path.
619
+ const sync =
620
+ error instanceof DecodeError
621
+ ? syncError(error.code, error.message)
622
+ : adapterSyncError(error, this.#hub.onError, 'realtime');
623
+ finishRound();
624
+ await this.#sendRoundChunk(
625
+ errorResponseBytes(sync, this.wireVersion, this.logEpoch),
626
+ );
627
+ return;
624
628
  }
625
629
  while (!step.done) {
626
630
  const chunk = step.value;
@@ -628,9 +632,10 @@ export class RealtimeSession {
628
632
  if (step.done) finishRound();
629
633
  await this.#sendRoundChunk(chunk);
630
634
  }
631
- } catch {
635
+ } catch (error) {
632
636
  // A host failure mid-stream leaves the byte stream unfinishable —
633
637
  // fail loud, drop the connection (§8.7 / §1.4 abort rule).
638
+ reportError(error, this.#hub.onError, 'realtime');
634
639
  this.#violation('sync round failed mid-stream');
635
640
  } finally {
636
641
  finishRound();
@@ -1059,10 +1064,18 @@ export class RealtimeHub {
1059
1064
  ...(this.#config.events !== undefined
1060
1065
  ? { events: this.#config.events }
1061
1066
  : {}),
1067
+ ...(this.#config.onError !== undefined
1068
+ ? { onError: this.#config.onError }
1069
+ : {}),
1062
1070
  realtime: this,
1063
1071
  };
1064
1072
  }
1065
1073
 
1074
+ /** The host's error reporter (`SyncServerConfig.onError`), if any. */
1075
+ get onError(): SyncularErrorHandler | undefined {
1076
+ return this.#config.onError;
1077
+ }
1078
+
1066
1079
  requestContext(session: RealtimeSession): SyncRequestContext {
1067
1080
  return this.requestContextFor(session);
1068
1081
  }
@@ -705,6 +705,35 @@ export function prefetchRowsSql(table: CompiledTable): string {
705
705
  ON t.partition=$1 AND t.tbl=$3 AND t.row_id=ids.id`;
706
706
  }
707
707
 
708
+ /**
709
+ * SQLite/D1: the newest `commit_seq` in the inverted change-scope index for
710
+ * one variable's values, as one index-only max per value (a `(partition,
711
+ * tbl, var, value)` prefix), so the cost is independent of the table's
712
+ * history. Bind order: [partition, tbl, var, value] per value.
713
+ */
714
+ export function latestScopedChangeSeqSql(valueCount: number): string {
715
+ const perValue: string[] = [];
716
+ for (let i = 0; i < valueCount; i++) {
717
+ perValue.push(
718
+ 'SELECT (SELECT max(commit_seq) FROM sync_change_scopes WHERE partition=? AND tbl=? AND var=? AND value=?) AS seq',
719
+ );
720
+ }
721
+ return `SELECT max(seq) AS seq FROM (${perValue.join(' UNION ALL ')})`;
722
+ }
723
+
724
+ /**
725
+ * PostgreSQL set-based form of `latestScopedChangeSeqSql` for the
726
+ * subscriptions of one table in a pull. `$2` is a JSON array of
727
+ * `{i, var, vals}` records. Params: [partition, specs (JSON text), tbl].
728
+ */
729
+ export const LATEST_SCOPED_CHANGE_BATCH_SQL = `SELECT s.i AS sub,
730
+ (SELECT max(latest.seq) FROM (
731
+ SELECT (SELECT max(commit_seq) FROM sync_change_scopes
732
+ WHERE partition=$1 AND tbl=$3 AND var=s.var AND value=v.value) AS seq
733
+ FROM jsonb_array_elements_text(s.vals) AS v(value)) AS latest) AS seq
734
+ FROM jsonb_to_recordset($2::text::jsonb) AS s(i int, var text, vals jsonb)
735
+ ORDER BY s.i`;
736
+
708
737
  /** SELECT a row's version + scope map (admin/blob authz). Params: [partition, rowId]. */
709
738
  export function selectRowScopesSql(
710
739
  table: CompiledTable,
@@ -49,6 +49,7 @@ import type {
49
49
  SegmentRecord,
50
50
  SegmentStore,
51
51
  SegmentStoreStats,
52
+ SegmentStream,
52
53
  } from './segment-store';
53
54
  import {
54
55
  mergeSegmentRecord,
@@ -619,6 +620,20 @@ export class S3SegmentStore implements SegmentStore {
619
620
  };
620
621
  }
621
622
 
623
+ /** The record plus the object GET's body, never buffered (see `get`). */
624
+ async open(segmentId: string): Promise<SegmentStream | undefined> {
625
+ if (!SEGMENT_ID_PATTERN.test(segmentId)) return undefined;
626
+ const record = await this.#readRecord(segmentId);
627
+ if (record === undefined) return undefined;
628
+ const response = await this.#request('GET', this.objectKeyFor(segmentId));
629
+ if (response === undefined || response.body === null) return undefined;
630
+ return {
631
+ record: record.record,
632
+ body: response.body,
633
+ byteLength: record.record.byteLength,
634
+ };
635
+ }
636
+
622
637
  async find(
623
638
  key: SegmentFindKey,
624
639
  nowMs: number,