@syncular/server 0.26.5 → 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 (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/dist/pull.js CHANGED
@@ -4,6 +4,7 @@
4
4
  */
5
5
  import { decodeRow, encodeRowsSegment, } from '@syncular/core';
6
6
  import { clockOf, limitsOf } from './context.js';
7
+ import { compileSchema, } from './schema.js';
7
8
  import { scopeDigest } from './scopes.js';
8
9
  import { issueSegmentUrl } from './signed-url.js';
9
10
  // One artifact build per owning storage pair and complete immutable identity.
@@ -153,18 +154,9 @@ function sqliteLaneEligible(limits, plan, startRowCursor) {
153
154
  * bootstrap-storm rule. Returns false when the table is not eligible
154
155
  * (the rows lane takes over).
155
156
  */
156
- async function* sqliteImageSegment(ctx, schema, limits, plan, asOf, digest, trace, logEpoch) {
157
+ async function* sqliteImageSegment(ctx, schema, limits, plan, asOf, digest, trace, logEpoch, existing, probe) {
157
158
  const { storage, segments, partition } = ctx;
158
159
  const now = clockOf(ctx)();
159
- const existing = await segments.find({
160
- partition,
161
- logEpoch,
162
- table: plan.table.name,
163
- schemaVersion: schema.version,
164
- mediaType: 'sqlite',
165
- scopeDigest: digest,
166
- asOfCommitSeq: asOf,
167
- }, now);
168
160
  if (existing !== undefined) {
169
161
  trace?.segments.push({
170
162
  mediaType: 'sqlite',
@@ -178,12 +170,7 @@ async function* sqliteImageSegment(ctx, schema, limits, plan, asOf, digest, trac
178
170
  }
179
171
  // Eligibility probe (§5.3): image only when the snapshot exceeds one
180
172
  // rows page — smaller tables stay on the (typically inline) rows lane.
181
- const probe = await storage.scanRows(partition, {
182
- table: plan.table.name,
183
- scopeFilter: plan.effective,
184
- afterRowId: null,
185
- limit: limits.limitSnapshotRows + 1,
186
- });
173
+ // The probe is the rows lane's first page, read by `prepareSections`.
187
174
  if (probe.length <= limits.limitSnapshotRows)
188
175
  return false;
189
176
  // §5.3: building an image needs a SQLite engine. The host injects the
@@ -273,19 +260,19 @@ async function* sqliteImageSegment(ctx, schema, limits, plan, asOf, digest, trac
273
260
  yield segmentRefFrame(record, digest, await signedUrlFields(ctx, limits, record.segmentId, digest, clockOf(ctx)()));
274
261
  return true;
275
262
  }
276
- async function* bootstrapSegments(ctx, schema, limits, plan, asOf, startRowCursor, trace, logEpoch, firstPage) {
263
+ async function* bootstrapSegments(ctx, schema, limits, plan, asOf, startRowCursor, trace, logEpoch, firstPage, image) {
277
264
  const { storage, segments, partition } = ctx;
278
265
  const serverLimits = limitsOf(ctx);
279
266
  const digest = await scopeDigest(plan.effective);
280
267
  const now = clockOf(ctx)();
281
268
  if (sqliteLaneEligible(limits, plan, startRowCursor)) {
282
- const imaged = yield* sqliteImageSegment(ctx, schema, limits, plan, asOf, digest, trace, logEpoch);
269
+ const imaged = yield* sqliteImageSegment(ctx, schema, limits, plan, asOf, digest, trace, logEpoch, image, firstPage);
283
270
  if (imaged)
284
271
  return { complete: true, rowCursor: null };
285
272
  }
286
273
  let rowCursor = startRowCursor;
287
274
  for (let page = 0; page < limits.maxSnapshotPages; page++) {
288
- const scanned = page === 0 && firstPage !== undefined
275
+ const scanned = page === 0
289
276
  ? firstPage
290
277
  : await storage.scanRows(partition, {
291
278
  table: plan.table.name,
@@ -351,7 +338,7 @@ async function* bootstrapSegments(ctx, schema, limits, plan, asOf, startRowCurso
351
338
  return { complete: false, rowCursor };
352
339
  }
353
340
  /** Run the pre-frame storage reads of every subscription concurrently. */
354
- export async function prepareSections(ctx, limits, plans, maxSeq, horizonSeq) {
341
+ export async function prepareSections(ctx, schema, limits, plans, maxSeq, horizonSeq, logEpoch) {
355
342
  const reads = plans.map((plan) => {
356
343
  const sub = plan.frame;
357
344
  if (plan.status === 'revoked')
@@ -376,23 +363,50 @@ export async function prepareSections(ctx, limits, plans, maxSeq, horizonSeq) {
376
363
  return {};
377
364
  }
378
365
  // 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, {
366
+ const resume = token !== undefined && token.asOfCommitSeq >= horizonSeq
367
+ ? token
368
+ : undefined;
369
+ const startCursor = resume?.rowCursor ?? null;
370
+ const firstPage = ctx.storage.scanRows(ctx.partition, {
371
+ table: plan.table.name,
372
+ scopeFilter: plan.effective,
373
+ afterRowId: startCursor,
374
+ limit: limits.limitSnapshotRows + 1,
375
+ });
376
+ if (!sqliteLaneEligible(limits, plan, startCursor))
377
+ return { firstPage };
378
+ // §4.7: a fresh sqlite-lane bootstrap pins at the newest change in its
379
+ // scope (never below the horizon, never above maxCommitSeq), so a stored
380
+ // image stays the reuse key's match until this scope changes (§5.3).
381
+ const pinned = resume !== undefined
382
+ ? Promise.resolve({ asOf: resume.asOfCommitSeq, pin: undefined })
383
+ : ctx.storage
384
+ .latestScopedChangeSeq(ctx.partition, {
386
385
  table: plan.table.name,
387
386
  scopeFilter: plan.effective,
388
- afterRowId: startCursor,
389
- limit: limits.limitSnapshotRows + 1,
390
- }),
391
- };
387
+ })
388
+ .then((latest) => {
389
+ const pin = Math.min(maxSeq, Math.max(latest, horizonSeq));
390
+ return { asOf: pin, pin };
391
+ });
392
+ const lane = pinned.then(async ({ asOf, pin }) => ({
393
+ pin,
394
+ image: await ctx.segments.find({
395
+ partition: ctx.partition,
396
+ logEpoch,
397
+ table: plan.table.name,
398
+ schemaVersion: schema.version,
399
+ mediaType: 'sqlite',
400
+ scopeDigest: await scopeDigest(plan.effective),
401
+ asOfCommitSeq: asOf,
402
+ }, clockOf(ctx)()),
403
+ }));
404
+ return { firstPage, lane };
392
405
  });
393
406
  const settled = await Promise.all(reads.map(async (read) => ({
394
407
  commits: read.commits === undefined ? undefined : await read.commits,
395
408
  firstPage: await read.firstPage,
409
+ lane: await read.lane,
396
410
  })));
397
411
  // Validate continuity before committing to an active section. A prune
398
412
  // during a paged read can otherwise make an incomplete window look empty.
@@ -403,6 +417,8 @@ export async function prepareSections(ctx, limits, plans, maxSeq, horizonSeq) {
403
417
  commits: read.commits ?? [],
404
418
  horizonSeq: read.commits === undefined ? horizonSeq : windowHorizon,
405
419
  firstPage: read.firstPage,
420
+ pin: read.lane?.pin,
421
+ image: read.lane?.image,
406
422
  }));
407
423
  }
408
424
  /**
@@ -410,7 +426,10 @@ export async function prepareSections(ctx, limits, plans, maxSeq, horizonSeq) {
410
426
  * returning the cursor recorded for the retention watermark (§4.5).
411
427
  */
412
428
  export async function* subscriptionSection(ctx, schema, limits, plan, maxSeq, horizonSeq, trace, logEpoch) {
413
- const [prepared] = await prepareSections(ctx, limits, [plan], maxSeq, horizonSeq);
429
+ if (logEpoch === undefined || logEpoch.length === 0) {
430
+ throw new Error('subscriptionSection requires a non-empty log epoch');
431
+ }
432
+ const [prepared] = await prepareSections(ctx, schema, limits, [plan], maxSeq, horizonSeq, logEpoch);
414
433
  if (prepared === undefined)
415
434
  throw new Error('prepareSections lost a plan');
416
435
  return yield* preparedSection(ctx, schema, limits, plan, maxSeq, prepared, trace, logEpoch);
@@ -456,8 +475,11 @@ export async function* preparedSection(ctx, schema, limits, plan, maxSeq, prepar
456
475
  const resume = token !== undefined && token.asOfCommitSeq >= horizonSeq
457
476
  ? token
458
477
  : undefined;
459
- const asOf = resume?.asOfCommitSeq ?? maxSeq;
478
+ const asOf = resume?.asOfCommitSeq ?? prepared.pin ?? maxSeq;
460
479
  const startCursor = resume?.rowCursor ?? null;
480
+ if (prepared.firstPage === undefined) {
481
+ throw new Error('prepareSections did not read the bootstrap page');
482
+ }
461
483
  yield {
462
484
  type: 'SUB_START',
463
485
  id: sub.id,
@@ -466,7 +488,7 @@ export async function* preparedSection(ctx, schema, limits, plan, maxSeq, prepar
466
488
  effectiveScopes: plan.effective,
467
489
  bootstrap: true,
468
490
  };
469
- const outcome = yield* bootstrapSegments(ctx, schema, limits, plan, asOf, startCursor, trace, logEpoch, prepared.firstPage);
491
+ const outcome = yield* bootstrapSegments(ctx, schema, limits, plan, asOf, startCursor, trace, logEpoch, prepared.firstPage, prepared.image);
470
492
  if (outcome.complete) {
471
493
  yield { type: 'SUB_END', nextCursor: asOf };
472
494
  }
@@ -524,3 +546,76 @@ export async function* preparedSection(ctx, schema, limits, plan, maxSeq, prepar
524
546
  yield { type: 'SUB_END', nextCursor };
525
547
  return { nextCursor, active: true };
526
548
  }
549
+ /**
550
+ * Publish the §5.3 sqlite image that a pull for (`table`, `scopes`) looks
551
+ * up, from a host with a SQLite engine (a Bun process with the production
552
+ * storage). It runs the pull's own bootstrap path (the §4.7 scope pin, the
553
+ * reuse key, the build), so the stored identity is exactly the one a
554
+ * serving host without a builder finds. The image stays current until a
555
+ * change reaches the scope; its lifetime is the segment store's TTL at
556
+ * publication.
557
+ */
558
+ export async function publishSqliteImage(input) {
559
+ const { config, partition } = input;
560
+ if (config.sqliteImageBuilder === undefined) {
561
+ throw new Error('publishSqliteImage requires config.sqliteImageBuilder');
562
+ }
563
+ const schema = compileSchema(config.schema);
564
+ const table = schema.tables.get(input.table);
565
+ if (table === undefined) {
566
+ throw new Error('publishSqliteImage: the schema has no such table');
567
+ }
568
+ await config.storage.ensureSchema(schema, config.checkpoints);
569
+ const logEpoch = await config.storage.getPartitionLogEpoch(partition);
570
+ if (logEpoch === undefined) {
571
+ throw new Error('publishSqliteImage: the partition has no log epoch');
572
+ }
573
+ const ctx = {
574
+ ...config,
575
+ partition,
576
+ actorId: 'syncular.image-publisher',
577
+ };
578
+ const plan = {
579
+ frame: {
580
+ type: 'SUBSCRIPTION',
581
+ id: 'image',
582
+ table: input.table,
583
+ scopes: input.scopes,
584
+ cursor: -1,
585
+ },
586
+ table,
587
+ status: 'active',
588
+ effective: input.scopes,
589
+ };
590
+ // One-row pages make every table above one row image-eligible (§5.3).
591
+ const limits = {
592
+ limitCommits: 1,
593
+ limitSnapshotRows: 1,
594
+ maxSnapshotPages: 1,
595
+ accept: ACCEPT_SQLITE,
596
+ };
597
+ const maxSeq = await config.storage.getMaxCommitSeq(partition);
598
+ const horizonSeq = await config.storage.getHorizonSeq(partition);
599
+ const trace = { segments: [] };
600
+ const [prepared] = await prepareSections(ctx, schema, limits, [plan], maxSeq, horizonSeq, logEpoch);
601
+ if (prepared === undefined)
602
+ throw new Error('prepareSections lost a plan');
603
+ let ref;
604
+ for await (const frame of preparedSection(ctx, schema, limits, plan, maxSeq, prepared, trace, logEpoch)) {
605
+ if (frame.type === 'SEGMENT_REF' && frame.mediaType === 'sqlite') {
606
+ ref = frame;
607
+ }
608
+ }
609
+ const image = trace.segments.find((segment) => segment.mediaType === 'sqlite');
610
+ if (ref === undefined || image === undefined) {
611
+ throw new Error('publishSqliteImage: the scope holds at most one row, which pulls serve inline');
612
+ }
613
+ return {
614
+ segmentId: ref.segmentId,
615
+ asOfCommitSeq: ref.asOfCommitSeq,
616
+ scopeDigest: ref.scopeDigest,
617
+ rowCount: ref.rowCount,
618
+ byteLength: ref.byteLength,
619
+ origin: image.origin === 'reused' ? 'reused' : 'built',
620
+ };
621
+ }
@@ -18,6 +18,7 @@
18
18
  */
19
19
  import { type PresenceKind, type ScopeMap, type WakeReason } from '@syncular/core';
20
20
  import type { SyncRequestContext, SyncServerConfig } from './context.js';
21
+ import { type SyncularErrorHandler } from './errors.js';
21
22
  import { type SyncularServerEvents } from './events.js';
22
23
  import type { ServerStorage, StoredCommit } from './storage.js';
23
24
  /**
@@ -198,6 +199,8 @@ export declare class RealtimeHub {
198
199
  readonly partition: string;
199
200
  readonly actorId: string;
200
201
  }): SyncRequestContext;
202
+ /** The host's error reporter (`SyncServerConfig.onError`), if any. */
203
+ get onError(): SyncularErrorHandler | undefined;
201
204
  requestContext(session: RealtimeSession): SyncRequestContext;
202
205
  /**
203
206
  * Register a connected socket (§8.1): load the client's last pull's
package/dist/realtime.js CHANGED
@@ -18,7 +18,7 @@
18
18
  */
19
19
  import { DecodeError, decodeMessage, encodeMessage, encodePresenceError, encodePresenceFanout, MessageStreamScanner, PROTOCOL_WIRE_VERSION, parseRealtimePresencePublish, REALTIME_TAG_DELTA, REALTIME_TAG_ROUND, } from '@syncular/core';
20
20
  import { REMOTE_COMMAND_CLIENT_ID_PREFIX, RESOLVER_OUTAGE, touchAuthenticatedPartition, } from './context.js';
21
- import { SyncError, syncError } from './errors.js';
21
+ import { adapterSyncError, reportError, SyncError, syncError, } from './errors.js';
22
22
  import { emitEvent } from './events.js';
23
23
  import { createSyncResponseStream } from './handler.js';
24
24
  import { compileSchema } from './schema.js';
@@ -430,16 +430,15 @@ export class RealtimeSession {
430
430
  }
431
431
  catch (error) {
432
432
  // §8.7 failures: what HTTP reports as status+JSON becomes a
433
- // minimal RESP_HEADER/ERROR/END response stream on the socket.
434
- if (error instanceof DecodeError || error instanceof SyncError) {
435
- const sync = error instanceof SyncError
436
- ? error
437
- : syncError(error.code, error.message);
438
- finishRound(); // END is in this one chunk
439
- await this.#sendRoundChunk(errorResponseBytes(sync, this.wireVersion, this.logEpoch));
440
- return;
441
- }
442
- throw error;
433
+ // minimal RESP_HEADER/ERROR/END response stream on the socket. An
434
+ // exception outside the catalog is reported and answers
435
+ // `sync.internal_error`, exactly like the HTTP adapter's 500.
436
+ const sync = error instanceof DecodeError
437
+ ? syncError(error.code, error.message)
438
+ : adapterSyncError(error, this.#hub.onError, 'realtime');
439
+ finishRound(); // END is in this one chunk
440
+ await this.#sendRoundChunk(errorResponseBytes(sync, this.wireVersion, this.logEpoch));
441
+ return;
443
442
  }
444
443
  // Fetch-ahead so the round stops being "in flight" the moment the
445
444
  // chunk carrying END is handed to send: the client may legally
@@ -451,20 +450,19 @@ export class RealtimeSession {
451
450
  step = await iterator.next();
452
451
  }
453
452
  catch (error) {
454
- // The buffered read-verify refusal is thrown on the generator's first
455
- // `next()`, before any chunk is yielded (the generator builds the whole
456
- // response, then verifies). Nothing has escaped, so it is delivered as
457
- // the same §8.4 ERROR frame HTTP returns rather than a silent close. A
458
- // throw after the first chunk still takes the §8.7 violation path.
459
- if (error instanceof DecodeError || error instanceof SyncError) {
460
- const sync = error instanceof SyncError
461
- ? error
462
- : syncError(error.code, error.message);
463
- finishRound();
464
- await this.#sendRoundChunk(errorResponseBytes(sync, this.wireVersion, this.logEpoch));
465
- return;
466
- }
467
- throw error;
453
+ // The generator builds the whole response (and runs the read-verify
454
+ // refusal) on its first `next()`, before any chunk is yielded.
455
+ // Nothing has escaped, so a failure there is delivered as the same
456
+ // §8.4 ERROR frame HTTP returns rather than a silent close; a
457
+ // non-catalog exception is reported and answers
458
+ // `sync.internal_error`. A throw after the first chunk still takes
459
+ // the §8.7 violation path.
460
+ const sync = error instanceof DecodeError
461
+ ? syncError(error.code, error.message)
462
+ : adapterSyncError(error, this.#hub.onError, 'realtime');
463
+ finishRound();
464
+ await this.#sendRoundChunk(errorResponseBytes(sync, this.wireVersion, this.logEpoch));
465
+ return;
468
466
  }
469
467
  while (!step.done) {
470
468
  const chunk = step.value;
@@ -474,9 +472,10 @@ export class RealtimeSession {
474
472
  await this.#sendRoundChunk(chunk);
475
473
  }
476
474
  }
477
- catch {
475
+ catch (error) {
478
476
  // A host failure mid-stream leaves the byte stream unfinishable —
479
477
  // fail loud, drop the connection (§8.7 / §1.4 abort rule).
478
+ reportError(error, this.#hub.onError, 'realtime');
480
479
  this.#violation('sync round failed mid-stream');
481
480
  }
482
481
  finally {
@@ -851,9 +850,16 @@ export class RealtimeHub {
851
850
  ...(this.#config.events !== undefined
852
851
  ? { events: this.#config.events }
853
852
  : {}),
853
+ ...(this.#config.onError !== undefined
854
+ ? { onError: this.#config.onError }
855
+ : {}),
854
856
  realtime: this,
855
857
  };
856
858
  }
859
+ /** The host's error reporter (`SyncServerConfig.onError`), if any. */
860
+ get onError() {
861
+ return this.#config.onError;
862
+ }
857
863
  requestContext(session) {
858
864
  return this.requestContextFor(session);
859
865
  }
@@ -255,6 +255,19 @@ export declare const COMMIT_WINDOW_BATCH_SQL = "SELECT s.i AS sub, c.commit_seq
255
255
  * tombstone. Params: [partition, rowIds as JSON array text, tbl].
256
256
  */
257
257
  export declare function prefetchRowsSql(table: CompiledTable): string;
258
+ /**
259
+ * SQLite/D1: the newest `commit_seq` in the inverted change-scope index for
260
+ * one variable's values, as one index-only max per value (a `(partition,
261
+ * tbl, var, value)` prefix), so the cost is independent of the table's
262
+ * history. Bind order: [partition, tbl, var, value] per value.
263
+ */
264
+ export declare function latestScopedChangeSeqSql(valueCount: number): string;
265
+ /**
266
+ * PostgreSQL set-based form of `latestScopedChangeSeqSql` for the
267
+ * subscriptions of one table in a pull. `$2` is a JSON array of
268
+ * `{i, var, vals}` records. Params: [partition, specs (JSON text), tbl].
269
+ */
270
+ export declare const LATEST_SCOPED_CHANGE_BATCH_SQL = "SELECT s.i AS sub,\n (SELECT max(latest.seq) FROM (\n SELECT (SELECT max(commit_seq) FROM sync_change_scopes\n WHERE partition=$1 AND tbl=$3 AND var=s.var AND value=v.value) AS seq\n FROM jsonb_array_elements_text(s.vals) AS v(value)) AS latest) AS seq\n FROM jsonb_to_recordset($2::text::jsonb) AS s(i int, var text, vals jsonb)\n ORDER BY s.i";
258
271
  /** SELECT a row's version + scope map (admin/blob authz). Params: [partition, rowId]. */
259
272
  export declare function selectRowScopesSql(table: CompiledTable, dialect: RelationalDialect): string;
260
273
  /** DELETE one stored row. Params: [partition, rowId]. */
@@ -606,6 +606,31 @@ export function prefetchRowsSql(table) {
606
606
  LEFT JOIN sync_tombstones t
607
607
  ON t.partition=$1 AND t.tbl=$3 AND t.row_id=ids.id`;
608
608
  }
609
+ /**
610
+ * SQLite/D1: the newest `commit_seq` in the inverted change-scope index for
611
+ * one variable's values, as one index-only max per value (a `(partition,
612
+ * tbl, var, value)` prefix), so the cost is independent of the table's
613
+ * history. Bind order: [partition, tbl, var, value] per value.
614
+ */
615
+ export function latestScopedChangeSeqSql(valueCount) {
616
+ const perValue = [];
617
+ for (let i = 0; i < valueCount; i++) {
618
+ perValue.push('SELECT (SELECT max(commit_seq) FROM sync_change_scopes WHERE partition=? AND tbl=? AND var=? AND value=?) AS seq');
619
+ }
620
+ return `SELECT max(seq) AS seq FROM (${perValue.join(' UNION ALL ')})`;
621
+ }
622
+ /**
623
+ * PostgreSQL set-based form of `latestScopedChangeSeqSql` for the
624
+ * subscriptions of one table in a pull. `$2` is a JSON array of
625
+ * `{i, var, vals}` records. Params: [partition, specs (JSON text), tbl].
626
+ */
627
+ export const LATEST_SCOPED_CHANGE_BATCH_SQL = `SELECT s.i AS sub,
628
+ (SELECT max(latest.seq) FROM (
629
+ SELECT (SELECT max(commit_seq) FROM sync_change_scopes
630
+ WHERE partition=$1 AND tbl=$3 AND var=s.var AND value=v.value) AS seq
631
+ FROM jsonb_array_elements_text(s.vals) AS v(value)) AS latest) AS seq
632
+ FROM jsonb_to_recordset($2::text::jsonb) AS s(i int, var text, vals jsonb)
633
+ ORDER BY s.i`;
609
634
  /** SELECT a row's version + scope map (admin/blob authz). Params: [partition, rowId]. */
610
635
  export function selectRowScopesSql(table, dialect) {
611
636
  const p = dialect === 'sqlite' ? ['?', '?'] : ['$1', '$2'];
@@ -42,7 +42,7 @@
42
42
  * precise, retryable `sync.segment_expired` and hit `sync.not_found` only
43
43
  * long after. Never set lifecycle below `ttlMs`.
44
44
  */
45
- import type { SegmentFindKey, SegmentMetadata, SegmentRecord, SegmentStore, SegmentStoreStats } from './segment-store.js';
45
+ import type { SegmentFindKey, SegmentMetadata, SegmentRecord, SegmentStore, SegmentStoreStats, SegmentStream } from './segment-store.js';
46
46
  import type { DelegatedPresignConfig, SegmentUrlIssue } from './signed-url.js';
47
47
  export interface S3SegmentStoreConfig {
48
48
  /**
@@ -75,6 +75,8 @@ export declare class S3SegmentStore implements SegmentStore {
75
75
  record: SegmentRecord;
76
76
  bytes: Uint8Array;
77
77
  } | undefined>;
78
+ /** The record plus the object GET's body, never buffered (see `get`). */
79
+ open(segmentId: string): Promise<SegmentStream | undefined>;
78
80
  find(key: SegmentFindKey, nowMs: number): Promise<SegmentRecord | undefined>;
79
81
  /**
80
82
  * Store-wide counters from the pointer-object accumulator (never a LIST).
@@ -450,6 +450,22 @@ export class S3SegmentStore {
450
450
  bytes: new Uint8Array(await response.arrayBuffer()),
451
451
  };
452
452
  }
453
+ /** The record plus the object GET's body, never buffered (see `get`). */
454
+ async open(segmentId) {
455
+ if (!SEGMENT_ID_PATTERN.test(segmentId))
456
+ return undefined;
457
+ const record = await this.#readRecord(segmentId);
458
+ if (record === undefined)
459
+ return undefined;
460
+ const response = await this.#request('GET', this.objectKeyFor(segmentId));
461
+ if (response === undefined || response.body === null)
462
+ return undefined;
463
+ return {
464
+ record: record.record,
465
+ body: response.body,
466
+ byteLength: record.record.byteLength,
467
+ };
468
+ }
453
469
  async find(key, nowMs) {
454
470
  const response = await this.#request('GET', await this.#findKeyFor(key));
455
471
  if (response === undefined)
@@ -11,4 +11,20 @@ export interface SegmentDownloadResult {
11
11
  /** Response headers per §5.5. */
12
12
  readonly headers: Record<string, string>;
13
13
  }
14
+ /** `openSegmentDownload`: the authorized segment as a byte stream. */
15
+ export interface SegmentStreamResult {
16
+ readonly record: SegmentRecord;
17
+ readonly body: ReadableStream<Uint8Array<ArrayBuffer>>;
18
+ readonly byteLength: number;
19
+ /** Response headers per §5.5. */
20
+ readonly headers: Record<string, string>;
21
+ }
14
22
  export declare function handleSegmentDownload(ctx: SyncRequestContext, request: SegmentDownloadRequest): Promise<SegmentDownloadResult>;
23
+ /**
24
+ * The §5.5 download as a stream: the same authorization as
25
+ * `handleSegmentDownload`, with the bytes read through `SegmentStore.open`
26
+ * when the store provides it, so a host with bounded memory (a Worker)
27
+ * relays a large sqlite image without buffering it. A store without `open`
28
+ * serves its `get` bytes as a one-chunk stream.
29
+ */
30
+ export declare function openSegmentDownload(ctx: SyncRequestContext, request: SegmentDownloadRequest): Promise<SegmentStreamResult>;
@@ -3,7 +3,7 @@ import { SyncError, syncError } from './errors.js';
3
3
  import { emitEvent } from './events.js';
4
4
  import { compileSchema } from './schema.js';
5
5
  import { computeEffective, scopeDigest } from './scopes.js';
6
- import { publicationRecord } from './segment-store.js';
6
+ import { bytesStream, publicationRecord, } from './segment-store.js';
7
7
  function parseScopesHeader(header) {
8
8
  let parsed;
9
9
  try {
@@ -25,14 +25,28 @@ function parseScopesHeader(header) {
25
25
  }
26
26
  return scopes;
27
27
  }
28
- export async function handleSegmentDownload(ctx, request) {
28
+ export function handleSegmentDownload(ctx, request) {
29
+ return withDownloadEvent(ctx, request, () => downloadSegment(ctx, request), (result) => result.bytes.length);
30
+ }
31
+ /**
32
+ * The §5.5 download as a stream: the same authorization as
33
+ * `handleSegmentDownload`, with the bytes read through `SegmentStore.open`
34
+ * when the store provides it, so a host with bounded memory (a Worker)
35
+ * relays a large sqlite image without buffering it. A store without `open`
36
+ * serves its `get` bytes as a one-chunk stream.
37
+ */
38
+ export function openSegmentDownload(ctx, request) {
39
+ return withDownloadEvent(ctx, request, () => openSegment(ctx, request), (result) => result.byteLength);
40
+ }
41
+ /** Emit `segment.downloaded` around a download when an events sink exists. */
42
+ async function withDownloadEvent(ctx, request, run, bytesOf) {
29
43
  const events = ctx.events;
30
44
  if (events === undefined)
31
- return downloadSegment(ctx, request);
45
+ return run();
32
46
  const clock = clockOf(ctx);
33
47
  const startedAtMs = clock();
34
48
  try {
35
- const result = await downloadSegment(ctx, request);
49
+ const result = await run();
36
50
  emitEvent(events, {
37
51
  type: 'segment.downloaded',
38
52
  atMs: clock(),
@@ -41,7 +55,7 @@ export async function handleSegmentDownload(ctx, request) {
41
55
  segmentId: request.segmentId,
42
56
  outcome: 'ok',
43
57
  mediaType: result.record.mediaType,
44
- bytes: result.bytes.length,
58
+ bytes: bytesOf(result),
45
59
  durationMs: clock() - startedAtMs,
46
60
  });
47
61
  return result;
@@ -66,11 +80,57 @@ async function downloadSegment(ctx, request) {
66
80
  if (entry === undefined) {
67
81
  throw syncError('sync.not_found', 'unknown segment (§5.5)');
68
82
  }
83
+ const record = await authorizePublication(ctx, request, registry.logEpoch, entry.record);
84
+ return { record, bytes: entry.bytes, headers: segmentHeaders(record) };
85
+ }
86
+ async function openSegment(ctx, request) {
87
+ const registry = await touchAuthenticatedPartition(ctx);
88
+ const opened = ctx.segments.open !== undefined
89
+ ? await ctx.segments.open(request.segmentId)
90
+ : await ctx.segments.get(request.segmentId).then((entry) => entry === undefined
91
+ ? undefined
92
+ : {
93
+ record: entry.record,
94
+ body: bytesStream(entry.bytes),
95
+ byteLength: entry.bytes.length,
96
+ });
97
+ if (opened === undefined) {
98
+ throw syncError('sync.not_found', 'unknown segment (§5.5)');
99
+ }
100
+ let record;
101
+ try {
102
+ record = await authorizePublication(ctx, request, registry.logEpoch, opened.record);
103
+ }
104
+ catch (error) {
105
+ await opened.body.cancel();
106
+ throw error;
107
+ }
108
+ return {
109
+ record,
110
+ body: opened.body,
111
+ byteLength: opened.byteLength,
112
+ headers: segmentHeaders(record),
113
+ };
114
+ }
115
+ function segmentHeaders(record) {
116
+ return {
117
+ 'Content-Type': 'application/octet-stream',
118
+ ETag: `"${record.segmentId}"`,
119
+ 'Cache-Control': 'private, max-age=0',
120
+ // Accept-Encoding: the body may be served compressed (§5.8).
121
+ Vary: 'Authorization, X-Syncular-Scopes, Accept-Encoding',
122
+ };
123
+ }
124
+ /**
125
+ * Select and authorize the caller's publication of a stored segment
126
+ * (§5.5), projected onto that publication.
127
+ */
128
+ async function authorizePublication(ctx, request, logEpoch, stored) {
69
129
  // Select the caller's own publication context. A publication under a
70
130
  // different partition or a stale log epoch is not this caller's grant, and
71
131
  // the entry's existence must not leak across that boundary (§5.5).
72
- const inContext = entry.record.publications.filter((publication) => publication.partition === ctx.partition &&
73
- publication.logEpoch === registry.logEpoch);
132
+ const inContext = stored.publications.filter((publication) => publication.partition === ctx.partition &&
133
+ publication.logEpoch === logEpoch);
74
134
  if (inContext.length === 0) {
75
135
  throw syncError('sync.not_found', 'unknown segment (§5.5)');
76
136
  }
@@ -144,18 +204,8 @@ async function downloadSegment(ctx, request) {
144
204
  if (checkpoints.some((checkpoint) => checkpoint.schemaVersion > publication.schemaVersion)) {
145
205
  throw syncError('sync.segment_expired', 'segment predates the partition backfill checkpoint — re-pull (§5.1)');
146
206
  }
147
- return {
148
- // The returned record is projected onto the selected publication, so its
149
- // `scopeDigests`/`publications` compatibility view cannot union a grant
150
- // from another partition (§5.1).
151
- record: publicationRecord(entry.record, publication),
152
- bytes: entry.bytes,
153
- headers: {
154
- 'Content-Type': 'application/octet-stream',
155
- ETag: `"${entry.record.segmentId}"`,
156
- 'Cache-Control': 'private, max-age=0',
157
- // Accept-Encoding: the body may be served compressed (§5.8).
158
- Vary: 'Authorization, X-Syncular-Scopes, Accept-Encoding',
159
- },
160
- };
207
+ // The returned record is projected onto the selected publication, so its
208
+ // `scopeDigests`/`publications` compatibility view cannot union a grant
209
+ // from another partition (§5.1).
210
+ return publicationRecord(stored, publication);
161
211
  }
@@ -130,6 +130,14 @@ export interface SegmentStoreStats {
130
130
  */
131
131
  readonly approximate?: boolean;
132
132
  }
133
+ /** `SegmentStore.open`: a stored segment with its bytes as a stream. */
134
+ export interface SegmentStream {
135
+ readonly record: SegmentRecord;
136
+ readonly body: ReadableStream<Uint8Array<ArrayBuffer>>;
137
+ readonly byteLength: number;
138
+ }
139
+ /** One buffered segment as a single-chunk `SegmentStream` body. */
140
+ export declare function bytesStream(bytes: Uint8Array): ReadableStream<Uint8Array<ArrayBuffer>>;
133
141
  export interface SegmentStore {
134
142
  put(metadata: SegmentMetadata, bytes: Uint8Array, nowMs: number): Promise<SegmentRecord>;
135
143
  /** Returns expired records too — expiry is the caller's check (§5.5). */
@@ -151,6 +159,13 @@ export interface SegmentStore {
151
159
  * against the descriptor.
152
160
  */
153
161
  find(key: SegmentFindKey, nowMs: number): Promise<SegmentRecord | undefined>;
162
+ /**
163
+ * Optional streaming read: the record plus the bytes as a stream, so the
164
+ * download route relays a segment larger than the host's memory (a
165
+ * sqlite image through a Worker). Returns expired records like `get`.
166
+ * Stores without it are served through `get`.
167
+ */
168
+ open?(segmentId: string): Promise<SegmentStream | undefined>;
154
169
  /** Admin/console counters: ADDITIVE, optional. */
155
170
  stats?(): Promise<SegmentStoreStats>;
156
171
  }
@@ -165,6 +180,7 @@ export declare class MemorySegmentStore implements SegmentStore {
165
180
  record: SegmentRecord;
166
181
  bytes: Uint8Array;
167
182
  } | undefined>;
183
+ open(segmentId: string): Promise<SegmentStream | undefined>;
168
184
  find(key: SegmentFindKey, nowMs: number): Promise<SegmentRecord | undefined>;
169
185
  stats(): Promise<SegmentStoreStats>;
170
186
  }