@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.
- package/dist/content-encoding.d.ts +17 -0
- package/dist/content-encoding.js +22 -0
- package/dist/context.d.ts +9 -0
- package/dist/d1-storage.d.ts +46 -0
- package/dist/d1-storage.js +12 -1
- package/dist/errors.d.ts +19 -0
- package/dist/errors.js +29 -0
- package/dist/handler.js +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/operations-realtime.js +4 -1
- package/dist/operations.js +6 -1
- package/dist/postgres-storage.d.ts +42 -0
- package/dist/postgres-storage.js +12 -1
- package/dist/pull.d.ts +46 -6
- package/dist/pull.js +129 -34
- package/dist/realtime.d.ts +3 -0
- package/dist/realtime.js +32 -26
- package/dist/relational-rows.d.ts +13 -0
- package/dist/relational-rows.js +25 -0
- package/dist/s3-segment-store.d.ts +3 -1
- package/dist/s3-segment-store.js +16 -0
- package/dist/segment-download.d.ts +16 -0
- package/dist/segment-download.js +71 -21
- package/dist/segment-store.d.ts +16 -0
- package/dist/segment-store.js +19 -0
- package/dist/sqlite-storage.d.ts +5 -0
- package/dist/sqlite-storage.js +18 -1
- package/dist/storage.d.ts +12 -0
- package/package.json +2 -2
- package/src/content-encoding.ts +35 -0
- package/src/context.ts +9 -0
- package/src/d1-storage.ts +20 -1
- package/src/errors.ts +56 -0
- package/src/handler.ts +2 -0
- package/src/index.ts +1 -0
- package/src/operations-realtime.ts +4 -1
- package/src/operations.ts +6 -1
- package/src/postgres-storage.ts +22 -1
- package/src/pull.ts +212 -39
- package/src/realtime.ts +45 -32
- package/src/relational-rows.ts +29 -0
- package/src/s3-segment-store.ts +15 -0
- package/src/segment-download.ts +129 -21
- package/src/segment-store.ts +36 -0
- package/src/sqlite-storage.ts +27 -0
- package/src/storage.ts +13 -0
package/src/segment-download.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
|
117
|
+
if (events === undefined) return run();
|
|
69
118
|
const clock = clockOf(ctx);
|
|
70
119
|
const startedAtMs = clock();
|
|
71
120
|
try {
|
|
72
|
-
const result = await
|
|
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
|
|
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 =
|
|
230
|
+
const inContext = stored.publications.filter(
|
|
113
231
|
(publication) =>
|
|
114
232
|
publication.partition === ctx.partition &&
|
|
115
|
-
publication.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
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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
|
}
|
package/src/segment-store.ts
CHANGED
|
@@ -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,
|
package/src/sqlite-storage.ts
CHANGED
|
@@ -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
|
|