@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
@@ -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
@@ -805,6 +805,19 @@ export interface ServerStorage {
805
805
  query: CommitWindowQuery,
806
806
  ): Promise<StoredCommit[]>;
807
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
+
808
821
  /** Scope-filtered snapshot scan, ordered by rowId (bootstrap paging). */
809
822
  scanRows(partition: string, query: RowScanQuery): Promise<StoredRow[]>;
810
823