@syncular/server 0.26.4 → 0.26.6

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 (49) 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 +110 -45
  15. package/dist/pull.d.ts +46 -6
  16. package/dist/pull.js +129 -34
  17. package/dist/push.js +19 -2
  18. package/dist/realtime.d.ts +3 -0
  19. package/dist/realtime.js +32 -26
  20. package/dist/relational-rows.d.ts +19 -0
  21. package/dist/relational-rows.js +38 -0
  22. package/dist/s3-segment-store.d.ts +3 -1
  23. package/dist/s3-segment-store.js +16 -0
  24. package/dist/segment-download.d.ts +16 -0
  25. package/dist/segment-download.js +71 -21
  26. package/dist/segment-store.d.ts +16 -0
  27. package/dist/segment-store.js +19 -0
  28. package/dist/sqlite-storage.d.ts +5 -0
  29. package/dist/sqlite-storage.js +18 -1
  30. package/dist/storage.d.ts +20 -0
  31. package/package.json +2 -2
  32. package/src/content-encoding.ts +35 -0
  33. package/src/context.ts +9 -0
  34. package/src/d1-storage.ts +20 -1
  35. package/src/errors.ts +56 -0
  36. package/src/handler.ts +2 -0
  37. package/src/index.ts +1 -0
  38. package/src/operations-realtime.ts +4 -1
  39. package/src/operations.ts +6 -1
  40. package/src/postgres-storage.ts +145 -60
  41. package/src/pull.ts +212 -39
  42. package/src/push.ts +18 -2
  43. package/src/realtime.ts +45 -32
  44. package/src/relational-rows.ts +43 -0
  45. package/src/s3-segment-store.ts +15 -0
  46. package/src/segment-download.ts +129 -21
  47. package/src/segment-store.ts +36 -0
  48. package/src/sqlite-storage.ts +27 -0
  49. package/src/storage.ts +21 -0
package/src/push.ts CHANGED
@@ -696,8 +696,12 @@ async function applyOperation(
696
696
  actorId,
697
697
  );
698
698
  if (insertReject !== undefined) return insertReject;
699
- // §5.2: an explicit insert recreates — the tombstone goes with it.
700
- await tx.clearTombstone(op.table, op.rowId);
699
+ // §5.2: an explicit insert recreates — the tombstone goes with it. An
700
+ // unversioned insert already proved above, under the partition lock, that
701
+ // no tombstone exists (this commit writes its own only in `appendCommit`).
702
+ if (op.baseVersion !== undefined) {
703
+ await tx.clearTombstone(op.table, op.rowId);
704
+ }
701
705
  const newRow = {
702
706
  rowId: op.rowId,
703
707
  serverVersion: 1,
@@ -1415,6 +1419,18 @@ export async function processPushOperationsWithTrace(
1415
1419
  const stagedWrites: StagedWrite[] = [];
1416
1420
  let terminated: PushOperationResult | undefined;
1417
1421
  const operations = await buildOperations(tx);
1422
+ if (tx.prefetchRows !== undefined) {
1423
+ const targets = new Map<string, string[]>();
1424
+ for (const op of operations) {
1425
+ if (!schema.tables.has(op.table)) continue;
1426
+ const ids = targets.get(op.table) ?? [];
1427
+ ids.push(op.rowId);
1428
+ targets.set(op.table, ids);
1429
+ }
1430
+ for (const [table, rowIds] of targets) {
1431
+ await tx.prefetchRows(table, rowIds);
1432
+ }
1433
+ }
1418
1434
  for (let opIndex = 0; opIndex < operations.length; opIndex++) {
1419
1435
  const op = operations[opIndex];
1420
1436
  if (op === undefined) continue;
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
  }
@@ -691,6 +691,49 @@ export const COMMIT_WINDOW_BATCH_SQL = `SELECT s.i AS sub, c.commit_seq AS commi
691
691
  ON ch.partition=$1 AND ch.commit_seq=c.commit_seq AND ch.tbl=$3
692
692
  ORDER BY s.i, c.commit_seq, ch.idx`;
693
693
 
694
+ /**
695
+ * PostgreSQL push read-ahead: every requested row id with its stored row
696
+ * (NULL columns when absent, `selectRowSql` shape) and its §5 delete
697
+ * tombstone. Params: [partition, rowIds as JSON array text, tbl].
698
+ */
699
+ export function prefetchRowsSql(table: CompiledTable): string {
700
+ return `SELECT ids.id AS lookup_id, r.${quoteIdent(SYNC_ROW_ID_COLUMN)} 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, t.commit_seq AS tombstone_seq
701
+ FROM jsonb_array_elements_text($2::text::jsonb) AS ids(id)
702
+ LEFT JOIN ${quoteIdent(table.name)} r
703
+ ON r.${quoteIdent(SYNC_PARTITION_COLUMN)}=$1 AND r.${quoteIdent(SYNC_ROW_ID_COLUMN)}=ids.id
704
+ LEFT JOIN sync_tombstones t
705
+ ON t.partition=$1 AND t.tbl=$3 AND t.row_id=ids.id`;
706
+ }
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
+
694
737
  /** SELECT a row's version + scope map (admin/blob authz). Params: [partition, rowId]. */
695
738
  export function selectRowScopesSql(
696
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,
@@ -19,7 +19,11 @@ import { SyncError, syncError } from './errors';
19
19
  import { emitEvent } from './events';
20
20
  import { compileSchema } from './schema';
21
21
  import { computeEffective, type ResolvedScopes, scopeDigest } from './scopes';
22
- import { publicationRecord, type SegmentRecord } from './segment-store';
22
+ import {
23
+ bytesStream,
24
+ publicationRecord,
25
+ type SegmentRecord,
26
+ } from './segment-store';
23
27
 
24
28
  export interface SegmentDownloadRequest {
25
29
  readonly segmentId: string;
@@ -34,6 +38,15 @@ export interface SegmentDownloadResult {
34
38
  readonly headers: Record<string, string>;
35
39
  }
36
40
 
41
+ /** `openSegmentDownload`: the authorized segment as a byte stream. */
42
+ export interface SegmentStreamResult {
43
+ readonly record: SegmentRecord;
44
+ readonly body: ReadableStream<Uint8Array<ArrayBuffer>>;
45
+ readonly byteLength: number;
46
+ /** Response headers per §5.5. */
47
+ readonly headers: Record<string, string>;
48
+ }
49
+
37
50
  function parseScopesHeader(header: string): ScopeMap {
38
51
  let parsed: unknown;
39
52
  try {
@@ -60,16 +73,52 @@ function parseScopesHeader(header: string): ScopeMap {
60
73
  return scopes;
61
74
  }
62
75
 
63
- export async function handleSegmentDownload(
76
+ export function handleSegmentDownload(
64
77
  ctx: SyncRequestContext,
65
78
  request: SegmentDownloadRequest,
66
79
  ): Promise<SegmentDownloadResult> {
80
+ return withDownloadEvent(
81
+ ctx,
82
+ request,
83
+ () => downloadSegment(ctx, request),
84
+ (result) => result.bytes.length,
85
+ );
86
+ }
87
+
88
+ /**
89
+ * The §5.5 download as a stream: the same authorization as
90
+ * `handleSegmentDownload`, with the bytes read through `SegmentStore.open`
91
+ * when the store provides it, so a host with bounded memory (a Worker)
92
+ * relays a large sqlite image without buffering it. A store without `open`
93
+ * serves its `get` bytes as a one-chunk stream.
94
+ */
95
+ export function openSegmentDownload(
96
+ ctx: SyncRequestContext,
97
+ request: SegmentDownloadRequest,
98
+ ): Promise<SegmentStreamResult> {
99
+ return withDownloadEvent(
100
+ ctx,
101
+ request,
102
+ () => openSegment(ctx, request),
103
+ (result) => result.byteLength,
104
+ );
105
+ }
106
+
107
+ /** Emit `segment.downloaded` around a download when an events sink exists. */
108
+ async function withDownloadEvent<
109
+ Result extends { readonly record: SegmentRecord },
110
+ >(
111
+ ctx: SyncRequestContext,
112
+ request: SegmentDownloadRequest,
113
+ run: () => Promise<Result>,
114
+ bytesOf: (result: Result) => number,
115
+ ): Promise<Result> {
67
116
  const events = ctx.events;
68
- if (events === undefined) return downloadSegment(ctx, request);
117
+ if (events === undefined) return run();
69
118
  const clock = clockOf(ctx);
70
119
  const startedAtMs = clock();
71
120
  try {
72
- const result = await downloadSegment(ctx, request);
121
+ const result = await run();
73
122
  emitEvent(events, {
74
123
  type: 'segment.downloaded',
75
124
  atMs: clock(),
@@ -78,7 +127,7 @@ export async function handleSegmentDownload(
78
127
  segmentId: request.segmentId,
79
128
  outcome: 'ok',
80
129
  mediaType: result.record.mediaType,
81
- bytes: result.bytes.length,
130
+ bytes: bytesOf(result),
82
131
  durationMs: clock() - startedAtMs,
83
132
  });
84
133
  return result;
@@ -106,13 +155,82 @@ async function downloadSegment(
106
155
  if (entry === undefined) {
107
156
  throw syncError('sync.not_found', 'unknown segment (§5.5)');
108
157
  }
158
+ const record = await authorizePublication(
159
+ ctx,
160
+ request,
161
+ registry.logEpoch,
162
+ entry.record,
163
+ );
164
+ return { record, bytes: entry.bytes, headers: segmentHeaders(record) };
165
+ }
166
+
167
+ async function openSegment(
168
+ ctx: SyncRequestContext,
169
+ request: SegmentDownloadRequest,
170
+ ): Promise<SegmentStreamResult> {
171
+ const registry = await touchAuthenticatedPartition(ctx);
172
+ const opened =
173
+ ctx.segments.open !== undefined
174
+ ? await ctx.segments.open(request.segmentId)
175
+ : await ctx.segments.get(request.segmentId).then((entry) =>
176
+ entry === undefined
177
+ ? undefined
178
+ : {
179
+ record: entry.record,
180
+ body: bytesStream(entry.bytes),
181
+ byteLength: entry.bytes.length,
182
+ },
183
+ );
184
+ if (opened === undefined) {
185
+ throw syncError('sync.not_found', 'unknown segment (§5.5)');
186
+ }
187
+ let record: SegmentRecord;
188
+ try {
189
+ record = await authorizePublication(
190
+ ctx,
191
+ request,
192
+ registry.logEpoch,
193
+ opened.record,
194
+ );
195
+ } catch (error) {
196
+ await opened.body.cancel();
197
+ throw error;
198
+ }
199
+ return {
200
+ record,
201
+ body: opened.body,
202
+ byteLength: opened.byteLength,
203
+ headers: segmentHeaders(record),
204
+ };
205
+ }
206
+
207
+ function segmentHeaders(record: SegmentRecord): Record<string, string> {
208
+ return {
209
+ 'Content-Type': 'application/octet-stream',
210
+ ETag: `"${record.segmentId}"`,
211
+ 'Cache-Control': 'private, max-age=0',
212
+ // Accept-Encoding: the body may be served compressed (§5.8).
213
+ Vary: 'Authorization, X-Syncular-Scopes, Accept-Encoding',
214
+ };
215
+ }
216
+
217
+ /**
218
+ * Select and authorize the caller's publication of a stored segment
219
+ * (§5.5), projected onto that publication.
220
+ */
221
+ async function authorizePublication(
222
+ ctx: SyncRequestContext,
223
+ request: SegmentDownloadRequest,
224
+ logEpoch: string,
225
+ stored: SegmentRecord,
226
+ ): Promise<SegmentRecord> {
109
227
  // Select the caller's own publication context. A publication under a
110
228
  // different partition or a stale log epoch is not this caller's grant, and
111
229
  // the entry's existence must not leak across that boundary (§5.5).
112
- const inContext = entry.record.publications.filter(
230
+ const inContext = stored.publications.filter(
113
231
  (publication) =>
114
232
  publication.partition === ctx.partition &&
115
- publication.logEpoch === registry.logEpoch,
233
+ publication.logEpoch === logEpoch,
116
234
  );
117
235
  if (inContext.length === 0) {
118
236
  throw syncError('sync.not_found', 'unknown segment (§5.5)');
@@ -212,18 +330,8 @@ async function downloadSegment(
212
330
  );
213
331
  }
214
332
 
215
- return {
216
- // The returned record is projected onto the selected publication, so its
217
- // `scopeDigests`/`publications` compatibility view cannot union a grant
218
- // from another partition (§5.1).
219
- record: publicationRecord(entry.record, publication),
220
- bytes: entry.bytes,
221
- headers: {
222
- 'Content-Type': 'application/octet-stream',
223
- ETag: `"${entry.record.segmentId}"`,
224
- 'Cache-Control': 'private, max-age=0',
225
- // Accept-Encoding: the body may be served compressed (§5.8).
226
- Vary: 'Authorization, X-Syncular-Scopes, Accept-Encoding',
227
- },
228
- };
333
+ // The returned record is projected onto the selected publication, so its
334
+ // `scopeDigests`/`publications` compatibility view cannot union a grant
335
+ // from another partition (§5.1).
336
+ return publicationRecord(stored, publication);
229
337
  }
@@ -283,6 +283,25 @@ export interface SegmentStoreStats {
283
283
  readonly approximate?: boolean;
284
284
  }
285
285
 
286
+ /** `SegmentStore.open`: a stored segment with its bytes as a stream. */
287
+ export interface SegmentStream {
288
+ readonly record: SegmentRecord;
289
+ readonly body: ReadableStream<Uint8Array<ArrayBuffer>>;
290
+ readonly byteLength: number;
291
+ }
292
+
293
+ /** One buffered segment as a single-chunk `SegmentStream` body. */
294
+ export function bytesStream(
295
+ bytes: Uint8Array,
296
+ ): ReadableStream<Uint8Array<ArrayBuffer>> {
297
+ return new ReadableStream({
298
+ start(controller) {
299
+ controller.enqueue(new Uint8Array(bytes));
300
+ controller.close();
301
+ },
302
+ });
303
+ }
304
+
286
305
  export interface SegmentStore {
287
306
  put(
288
307
  metadata: SegmentMetadata,
@@ -307,6 +326,13 @@ export interface SegmentStore {
307
326
  * against the descriptor.
308
327
  */
309
328
  find(key: SegmentFindKey, nowMs: number): Promise<SegmentRecord | undefined>;
329
+ /**
330
+ * Optional streaming read: the record plus the bytes as a stream, so the
331
+ * download route relays a segment larger than the host's memory (a
332
+ * sqlite image through a Worker). Returns expired records like `get`.
333
+ * Stores without it are served through `get`.
334
+ */
335
+ open?(segmentId: string): Promise<SegmentStream | undefined>;
310
336
  /** Admin/console counters: ADDITIVE, optional. */
311
337
  stats?(): Promise<SegmentStoreStats>;
312
338
  }
@@ -353,6 +379,16 @@ export class MemorySegmentStore implements SegmentStore {
353
379
  return this.#entries.get(segmentId);
354
380
  }
355
381
 
382
+ async open(segmentId: string): Promise<SegmentStream | undefined> {
383
+ const entry = this.#entries.get(segmentId);
384
+ if (entry === undefined) return undefined;
385
+ return {
386
+ record: entry.record,
387
+ body: bytesStream(entry.bytes),
388
+ byteLength: entry.bytes.length,
389
+ };
390
+ }
391
+
356
392
  async find(
357
393
  key: SegmentFindKey,
358
394
  nowMs: number,
@@ -15,11 +15,13 @@ import {
15
15
  bindAuthoritativePartition,
16
16
  prepareAuthoritativeQuery,
17
17
  } from './authoritative-query';
18
+ import type { ScopeMap } from '@syncular/core';
18
19
  import { syncError } from './errors';
19
20
  import {
20
21
  assertPhysicalColumns,
21
22
  assertStoredLayouts,
22
23
  commitWindowPageSql,
24
+ latestScopedChangeSeqSql,
23
25
  deleteRowSql,
24
26
  deleteSqliteRowScopesSql,
25
27
  dropTableDdl,
@@ -1591,6 +1593,31 @@ END`);
1591
1593
  });
1592
1594
  }
1593
1595
 
1596
+ async latestScopedChangeSeq(
1597
+ partition: string,
1598
+ query: { readonly table: string; readonly scopeFilter: ScopeMap },
1599
+ ): Promise<number> {
1600
+ const firstVariable = Object.keys(query.scopeFilter).sort()[0];
1601
+ const values =
1602
+ firstVariable === undefined
1603
+ ? []
1604
+ : (query.scopeFilter[firstVariable] ?? []);
1605
+ if (firstVariable === undefined || values.length === 0) return 0;
1606
+ const row = this.db
1607
+ .query<{ seq: number | null }, string[]>(
1608
+ latestScopedChangeSeqSql(values.length),
1609
+ )
1610
+ .get(
1611
+ ...values.flatMap((value) => [
1612
+ partition,
1613
+ query.table,
1614
+ firstVariable,
1615
+ value,
1616
+ ]),
1617
+ );
1618
+ return row?.seq ?? 0;
1619
+ }
1620
+
1594
1621
  async readCommitWindow(
1595
1622
  partition: string,
1596
1623
  query: CommitWindowQuery,
package/src/storage.ts CHANGED
@@ -433,6 +433,14 @@ export interface AuthoritativeQueryResult {
433
433
  */
434
434
  export interface StorageTransaction {
435
435
  getRow(table: string, rowId: string): Promise<StoredRow | undefined>;
436
+ /**
437
+ * Optional read-ahead: the push layer names every row a commit's
438
+ * operations target, per table, before it applies them. A storage with
439
+ * per-statement network cost reads them in one statement and answers the
440
+ * following `getRow` calls from that snapshot until the row is written in
441
+ * this transaction.
442
+ */
443
+ prefetchRows?(table: string, rowIds: readonly string[]): Promise<void>;
436
444
  /**
437
445
  * §5 delete-precedence tombstone read: the `commit_seq` of the newest
438
446
  * applied delete of this row still within the pruning horizon, or
@@ -797,6 +805,19 @@ export interface ServerStorage {
797
805
  query: CommitWindowQuery,
798
806
  ): Promise<StoredCommit[]>;
799
807
 
808
+ /**
809
+ * Newest commitSeq whose change to `query.table` carries a value of the
810
+ * filter's first variable (keys sorted) in the inverted change-scope index:
811
+ * the `readCommitWindow` candidate rule, so no change a window read would
812
+ * deliver for this filter sits above it. 0 when the index holds none (never
813
+ * written, or pruned). The §4.7 sqlite-lane bootstrap pin reads it; it MUST
814
+ * be an index lookup per value, independent of the table's history.
815
+ */
816
+ latestScopedChangeSeq(
817
+ partition: string,
818
+ query: { readonly table: string; readonly scopeFilter: ScopeMap },
819
+ ): Promise<number>;
820
+
800
821
  /** Scope-filtered snapshot scan, ordered by rowId (bootstrap paging). */
801
822
  scanRows(partition: string, query: RowScanQuery): Promise<StoredRow[]>;
802
823