@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.
- 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 +110 -45
- package/dist/pull.d.ts +46 -6
- package/dist/pull.js +129 -34
- package/dist/push.js +19 -2
- package/dist/realtime.d.ts +3 -0
- package/dist/realtime.js +32 -26
- package/dist/relational-rows.d.ts +19 -0
- package/dist/relational-rows.js +38 -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 +20 -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 +145 -60
- package/src/pull.ts +212 -39
- package/src/push.ts +18 -2
- package/src/realtime.ts +45 -32
- package/src/relational-rows.ts +43 -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 +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
|
-
|
|
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 {
|
|
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
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
)
|
|
594
|
-
|
|
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
|
|
608
|
-
// `next()`, before any chunk is yielded
|
|
609
|
-
//
|
|
610
|
-
//
|
|
611
|
-
//
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
)
|
|
621
|
-
|
|
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
|
}
|
package/src/relational-rows.ts
CHANGED
|
@@ -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,
|
package/src/s3-segment-store.ts
CHANGED
|
@@ -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,
|
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
|
@@ -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
|
|