@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/dist/segment-download.js
CHANGED
|
@@ -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
|
|
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
|
|
45
|
+
return run();
|
|
32
46
|
const clock = clockOf(ctx);
|
|
33
47
|
const startedAtMs = clock();
|
|
34
48
|
try {
|
|
35
|
-
const result = await
|
|
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
|
|
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 =
|
|
73
|
-
publication.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
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
}
|
package/dist/segment-store.d.ts
CHANGED
|
@@ -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
|
}
|
package/dist/segment-store.js
CHANGED
|
@@ -146,6 +146,15 @@ export function mergeSegmentRecord(existing, metadata, segmentId, byteLength, no
|
|
|
146
146
|
expiresAtMs: Math.max(existing?.expiresAtMs ?? nowMs + ttlMs, nowMs + ttlMs),
|
|
147
147
|
};
|
|
148
148
|
}
|
|
149
|
+
/** One buffered segment as a single-chunk `SegmentStream` body. */
|
|
150
|
+
export function bytesStream(bytes) {
|
|
151
|
+
return new ReadableStream({
|
|
152
|
+
start(controller) {
|
|
153
|
+
controller.enqueue(new Uint8Array(bytes));
|
|
154
|
+
controller.close();
|
|
155
|
+
},
|
|
156
|
+
});
|
|
157
|
+
}
|
|
149
158
|
export async function segmentIdFor(bytes) {
|
|
150
159
|
return `sha256:${await sha256Hex(bytes)}`;
|
|
151
160
|
}
|
|
@@ -168,6 +177,16 @@ export class MemorySegmentStore {
|
|
|
168
177
|
async get(segmentId) {
|
|
169
178
|
return this.#entries.get(segmentId);
|
|
170
179
|
}
|
|
180
|
+
async open(segmentId) {
|
|
181
|
+
const entry = this.#entries.get(segmentId);
|
|
182
|
+
if (entry === undefined)
|
|
183
|
+
return undefined;
|
|
184
|
+
return {
|
|
185
|
+
record: entry.record,
|
|
186
|
+
body: bytesStream(entry.bytes),
|
|
187
|
+
byteLength: entry.bytes.length,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
171
190
|
async find(key, nowMs) {
|
|
172
191
|
for (const { record } of this.#entries.values()) {
|
|
173
192
|
const publication = selectSegmentPublication(record, {
|
package/dist/sqlite-storage.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { CommitPruneQuery, CommitPruneResult } from './storage.js';
|
|
2
|
+
import type { ScopeMap } from '@syncular/core';
|
|
2
3
|
import type { CompiledSchema, CompiledTable } from './schema.js';
|
|
3
4
|
import { type SqliteDatabase } from './sqlite-driver.js';
|
|
4
5
|
import type { AuthoritativeQueryRequest, AuthoritativeQueryResult, CheckpointDeclaration, ClientCursorInfo, ClientRecord, CommitMetadata, CommitMetadataQuery, CommitWindowQuery, IndexRowScanQuery, PartitionRegistryEntry, PrunedReactionCounts, ReactionClaimQuery, ReactionFailureUpdate, ReactionListQuery, ReactionPruneQuery, RowScanQuery, ScopeActivityQuery, ScopeCommitActivity, ServerStorage, ServeGate, StorageTransaction, StoredCommit, StoredCheckpoint, StoredPushResult, StoredReaction, StoredRow } from './storage.js';
|
|
@@ -47,6 +48,10 @@ export declare class SqliteServerStorage implements ServerStorage {
|
|
|
47
48
|
getReaction(partition: string, idempotencyKey: string): Promise<StoredReaction | undefined>;
|
|
48
49
|
listReactions(partition: string, query: ReactionListQuery): Promise<StoredReaction[]>;
|
|
49
50
|
pruneReactions(partition: string, query: ReactionPruneQuery): Promise<PrunedReactionCounts>;
|
|
51
|
+
latestScopedChangeSeq(partition: string, query: {
|
|
52
|
+
readonly table: string;
|
|
53
|
+
readonly scopeFilter: ScopeMap;
|
|
54
|
+
}): Promise<number>;
|
|
50
55
|
readCommitWindow(partition: string, query: CommitWindowQuery): Promise<StoredCommit[]>;
|
|
51
56
|
scanRows(partition: string, query: RowScanQuery): Promise<StoredRow[]>;
|
|
52
57
|
scanRowsByIndex(partition: string, query: IndexRowScanQuery): Promise<StoredRow[]>;
|
package/dist/sqlite-storage.js
CHANGED
|
@@ -12,7 +12,7 @@ import { serveNotReadyError } from './readiness.js';
|
|
|
12
12
|
*/
|
|
13
13
|
import { bindAuthoritativePartition, prepareAuthoritativeQuery, } from './authoritative-query.js';
|
|
14
14
|
import { syncError } from './errors.js';
|
|
15
|
-
import { assertPhysicalColumns, assertStoredLayouts, commitWindowPageSql, deleteRowSql, deleteSqliteRowScopesSql, dropTableDdl, indexRowPageStatement, layoutsOf, migratePayload, parseLayouts, retiredTableNames, rewritePlan, rewriteRowSql, rewriteValues, SCHEMA_META_DDL_SQLITE, scanRowPageSql, schemaDdl, selectRowScopesSql, selectRowSql, selectRowsForRewriteSql, upsertSql, upsertValues, } from './relational-rows.js';
|
|
15
|
+
import { assertPhysicalColumns, assertStoredLayouts, commitWindowPageSql, latestScopedChangeSeqSql, deleteRowSql, deleteSqliteRowScopesSql, dropTableDdl, indexRowPageStatement, layoutsOf, migratePayload, parseLayouts, retiredTableNames, rewritePlan, rewriteRowSql, rewriteValues, SCHEMA_META_DDL_SQLITE, scanRowPageSql, schemaDdl, selectRowScopesSql, selectRowSql, selectRowsForRewriteSql, upsertSql, upsertValues, } from './relational-rows.js';
|
|
16
16
|
import { matchesEffective } from './scopes.js';
|
|
17
17
|
import { collectCommitWindowPage, deserializePushResult, placeholders, SQLITE_DDL, serializePushResult, toStoredRow, } from './sqlite-dialect.js';
|
|
18
18
|
import { SqliteAdapterRequiredError, } from './sqlite-driver.js';
|
|
@@ -1009,6 +1009,23 @@ END`);
|
|
|
1009
1009
|
};
|
|
1010
1010
|
});
|
|
1011
1011
|
}
|
|
1012
|
+
async latestScopedChangeSeq(partition, query) {
|
|
1013
|
+
const firstVariable = Object.keys(query.scopeFilter).sort()[0];
|
|
1014
|
+
const values = firstVariable === undefined
|
|
1015
|
+
? []
|
|
1016
|
+
: (query.scopeFilter[firstVariable] ?? []);
|
|
1017
|
+
if (firstVariable === undefined || values.length === 0)
|
|
1018
|
+
return 0;
|
|
1019
|
+
const row = this.db
|
|
1020
|
+
.query(latestScopedChangeSeqSql(values.length))
|
|
1021
|
+
.get(...values.flatMap((value) => [
|
|
1022
|
+
partition,
|
|
1023
|
+
query.table,
|
|
1024
|
+
firstVariable,
|
|
1025
|
+
value,
|
|
1026
|
+
]));
|
|
1027
|
+
return row?.seq ?? 0;
|
|
1028
|
+
}
|
|
1012
1029
|
async readCommitWindow(partition, query) {
|
|
1013
1030
|
const variables = Object.keys(query.scopeFilter).sort();
|
|
1014
1031
|
const firstVariable = variables[0];
|
package/dist/storage.d.ts
CHANGED
|
@@ -344,6 +344,14 @@ export interface AuthoritativeQueryResult {
|
|
|
344
344
|
*/
|
|
345
345
|
export interface StorageTransaction {
|
|
346
346
|
getRow(table: string, rowId: string): Promise<StoredRow | undefined>;
|
|
347
|
+
/**
|
|
348
|
+
* Optional read-ahead: the push layer names every row a commit's
|
|
349
|
+
* operations target, per table, before it applies them. A storage with
|
|
350
|
+
* per-statement network cost reads them in one statement and answers the
|
|
351
|
+
* following `getRow` calls from that snapshot until the row is written in
|
|
352
|
+
* this transaction.
|
|
353
|
+
*/
|
|
354
|
+
prefetchRows?(table: string, rowIds: readonly string[]): Promise<void>;
|
|
347
355
|
/**
|
|
348
356
|
* §5 delete-precedence tombstone read: the `commit_seq` of the newest
|
|
349
357
|
* applied delete of this row still within the pruning horizon, or
|
|
@@ -581,6 +589,18 @@ export interface ServerStorage {
|
|
|
581
589
|
* reach `limitChanges` or the window is exhausted.
|
|
582
590
|
*/
|
|
583
591
|
readCommitWindow(partition: string, query: CommitWindowQuery): Promise<StoredCommit[]>;
|
|
592
|
+
/**
|
|
593
|
+
* Newest commitSeq whose change to `query.table` carries a value of the
|
|
594
|
+
* filter's first variable (keys sorted) in the inverted change-scope index:
|
|
595
|
+
* the `readCommitWindow` candidate rule, so no change a window read would
|
|
596
|
+
* deliver for this filter sits above it. 0 when the index holds none (never
|
|
597
|
+
* written, or pruned). The §4.7 sqlite-lane bootstrap pin reads it; it MUST
|
|
598
|
+
* be an index lookup per value, independent of the table's history.
|
|
599
|
+
*/
|
|
600
|
+
latestScopedChangeSeq(partition: string, query: {
|
|
601
|
+
readonly table: string;
|
|
602
|
+
readonly scopeFilter: ScopeMap;
|
|
603
|
+
}): Promise<number>;
|
|
584
604
|
/** Scope-filtered snapshot scan, ordered by rowId (bootstrap paging). */
|
|
585
605
|
scanRows(partition: string, query: RowScanQuery): Promise<StoredRow[]>;
|
|
586
606
|
/**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@syncular/server",
|
|
3
|
-
"version": "0.26.
|
|
3
|
+
"version": "0.26.6",
|
|
4
4
|
"description": "Syncular server: handleSyncRequest + storage/auth interfaces for the sync protocol",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Benjamin Kniffler",
|
|
@@ -68,7 +68,7 @@
|
|
|
68
68
|
"verify:node": "cd ../.. && bun build ./packages/server/test/sqlite-runtime/verify-node.mjs --target=node --conditions=bun --outfile=./packages/server/.verify-node.built.mjs && node ./packages/server/.verify-node.built.mjs"
|
|
69
69
|
},
|
|
70
70
|
"dependencies": {
|
|
71
|
-
"@syncular/core": "0.26.
|
|
71
|
+
"@syncular/core": "0.26.6"
|
|
72
72
|
},
|
|
73
73
|
"devDependencies": {
|
|
74
74
|
"@electric-sql/pglite": "^0.5.4"
|
package/src/content-encoding.ts
CHANGED
|
@@ -92,3 +92,38 @@ export function encodeSegmentBody(
|
|
|
92
92
|
}
|
|
93
93
|
return { bytes };
|
|
94
94
|
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Segments above this size are relayed as a stream instead of being
|
|
98
|
+
* buffered and compressed in memory: a sqlite image can exceed the memory of
|
|
99
|
+
* a Workers isolate (128 MB).
|
|
100
|
+
*/
|
|
101
|
+
export const SEGMENT_STREAM_THRESHOLD_BYTES = 16 * 1024 * 1024;
|
|
102
|
+
|
|
103
|
+
export interface EncodedSegmentStream {
|
|
104
|
+
readonly body: ReadableStream<Uint8Array<ArrayBuffer>>;
|
|
105
|
+
/** Absent ⇒ identity (no `Content-Encoding` header). */
|
|
106
|
+
readonly contentEncoding?: 'gzip';
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Compress a streamed segment body per §5.8 with the runtime's
|
|
111
|
+
* `CompressionStream`: gzip when the client accepts it, identity otherwise
|
|
112
|
+
* (zstd has no standard streaming codec).
|
|
113
|
+
*/
|
|
114
|
+
export function encodeSegmentStream(
|
|
115
|
+
body: ReadableStream<Uint8Array<ArrayBuffer>>,
|
|
116
|
+
acceptEncoding: string | undefined,
|
|
117
|
+
): EncodedSegmentStream {
|
|
118
|
+
if (
|
|
119
|
+
acceptEncoding === undefined ||
|
|
120
|
+
!accepts(acceptEncoding, 'gzip') ||
|
|
121
|
+
typeof CompressionStream !== 'function'
|
|
122
|
+
) {
|
|
123
|
+
return { body };
|
|
124
|
+
}
|
|
125
|
+
return {
|
|
126
|
+
body: body.pipeThrough(new CompressionStream('gzip')),
|
|
127
|
+
contentEncoding: 'gzip',
|
|
128
|
+
};
|
|
129
|
+
}
|
package/src/context.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import type { ScopeMap } from '@syncular/core';
|
|
8
8
|
import type { BlobStore } from './blob-store';
|
|
9
9
|
import type { CrdtMergerRegistry } from './crdt-merger';
|
|
10
|
+
import type { SyncularErrorHandler } from './errors';
|
|
10
11
|
import type { SyncularServerEvents } from './events';
|
|
11
12
|
import type { LeaseStore } from './lease-store';
|
|
12
13
|
import type { AnyReactionPlanner } from './reactions';
|
|
@@ -192,6 +193,14 @@ export interface SyncServerConfig {
|
|
|
192
193
|
* event objects are built. A throwing sink never affects processing.
|
|
193
194
|
*/
|
|
194
195
|
readonly events?: SyncularServerEvents;
|
|
196
|
+
/**
|
|
197
|
+
* Host error reporting (§10.2 `sync.internal_error`). Receives the original
|
|
198
|
+
* exception whenever a request, socket round, segment or blob download,
|
|
199
|
+
* or remote operation fails with something other than a `SyncError`; the
|
|
200
|
+
* client sees only the catalog code. Like the events sink, a throwing
|
|
201
|
+
* handler never changes the response.
|
|
202
|
+
*/
|
|
203
|
+
readonly onError?: SyncularErrorHandler;
|
|
195
204
|
}
|
|
196
205
|
|
|
197
206
|
/** Per-request context: the config plus host-authenticated identity. */
|
package/src/d1-storage.ts
CHANGED
|
@@ -42,7 +42,7 @@ import type { CommitPruneQuery, CommitPruneResult } from './storage';
|
|
|
42
42
|
* This mirrors PostgreSQL's per-partition row lock, achieved by placement
|
|
43
43
|
* rather than a lock D1 does not expose.
|
|
44
44
|
*/
|
|
45
|
-
import { decodeRow, type RowValue } from '@syncular/core';
|
|
45
|
+
import { decodeRow, type RowValue, type ScopeMap } from '@syncular/core';
|
|
46
46
|
import {
|
|
47
47
|
bindAuthoritativePartition,
|
|
48
48
|
type BoundAuthoritativeQuery,
|
|
@@ -54,6 +54,7 @@ import {
|
|
|
54
54
|
assertPhysicalColumns,
|
|
55
55
|
assertStoredLayouts,
|
|
56
56
|
commitWindowPageSql,
|
|
57
|
+
latestScopedChangeSeqSql,
|
|
57
58
|
deleteRowSql,
|
|
58
59
|
deleteSqliteRowScopesSql,
|
|
59
60
|
dropTableDdl,
|
|
@@ -2146,6 +2147,24 @@ export class D1ServerStorage implements ServerStorage {
|
|
|
2146
2147
|
};
|
|
2147
2148
|
}
|
|
2148
2149
|
|
|
2150
|
+
async latestScopedChangeSeq(
|
|
2151
|
+
partition: string,
|
|
2152
|
+
query: { readonly table: string; readonly scopeFilter: ScopeMap },
|
|
2153
|
+
): Promise<number> {
|
|
2154
|
+
const firstVariable = Object.keys(query.scopeFilter).sort()[0];
|
|
2155
|
+
const values =
|
|
2156
|
+
firstVariable === undefined
|
|
2157
|
+
? []
|
|
2158
|
+
: (query.scopeFilter[firstVariable] ?? []);
|
|
2159
|
+
if (firstVariable === undefined || values.length === 0) return 0;
|
|
2160
|
+
// Concurrent reads of a pull share one `db.batch` round trip.
|
|
2161
|
+
const [row] = await this.#batchedAll<{ seq: number | null }>(
|
|
2162
|
+
latestScopedChangeSeqSql(values.length),
|
|
2163
|
+
values.flatMap((value) => [partition, query.table, firstVariable, value]),
|
|
2164
|
+
);
|
|
2165
|
+
return row?.seq ?? 0;
|
|
2166
|
+
}
|
|
2167
|
+
|
|
2149
2168
|
async readCommitWindow(
|
|
2150
2169
|
partition: string,
|
|
2151
2170
|
query: CommitWindowQuery,
|
package/src/errors.ts
CHANGED
|
@@ -171,6 +171,14 @@ export const ERROR_CATALOG: Readonly<Record<string, ErrorCatalogEntry>> = {
|
|
|
171
171
|
// checkpoint is incomplete or the database is newer than the running build.
|
|
172
172
|
// Formerly host-only; promoted to the §10.2 catalogue so an in-flight
|
|
173
173
|
// request can be refused with a distinguishable identity.
|
|
174
|
+
// An exception outside this catalog (host code, storage, network) reached
|
|
175
|
+
// an adapter; the original goes to `onError`, never onto the wire.
|
|
176
|
+
'sync.internal_error': {
|
|
177
|
+
category: 'internal',
|
|
178
|
+
retryable: true,
|
|
179
|
+
recommendedAction: 'retryLater',
|
|
180
|
+
httpStatus: 500,
|
|
181
|
+
},
|
|
174
182
|
'sync.schema_not_ready': {
|
|
175
183
|
category: 'internal',
|
|
176
184
|
retryable: true,
|
|
@@ -310,3 +318,51 @@ export function errorBody(error: SyncError): {
|
|
|
310
318
|
: {}),
|
|
311
319
|
};
|
|
312
320
|
}
|
|
321
|
+
|
|
322
|
+
/** The adapter surface that caught an exception reported through `onError`. */
|
|
323
|
+
export type SyncularErrorRoute =
|
|
324
|
+
| 'sync'
|
|
325
|
+
| 'operations'
|
|
326
|
+
| 'segments'
|
|
327
|
+
| 'blobs'
|
|
328
|
+
| 'admin'
|
|
329
|
+
| 'realtime';
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Host error reporting: receives every exception that is not a `SyncError`
|
|
333
|
+
* before the adapter answers `sync.internal_error` (or the remote-operation
|
|
334
|
+
* `operation.*` failure code), with the surface that caught it.
|
|
335
|
+
*/
|
|
336
|
+
export type SyncularErrorHandler = (
|
|
337
|
+
error: unknown,
|
|
338
|
+
context: { readonly route: SyncularErrorRoute },
|
|
339
|
+
) => void;
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* The protocol error for an exception an adapter caught. A `SyncError`
|
|
343
|
+
* passes through unchanged. Anything else goes to `onError` and becomes
|
|
344
|
+
* `sync.internal_error` (§10.2), whose message never carries the exception
|
|
345
|
+
* text.
|
|
346
|
+
*/
|
|
347
|
+
export function adapterSyncError(
|
|
348
|
+
error: unknown,
|
|
349
|
+
onError: SyncularErrorHandler | undefined,
|
|
350
|
+
route: SyncularErrorRoute,
|
|
351
|
+
): SyncError {
|
|
352
|
+
if (error instanceof SyncError) return error;
|
|
353
|
+
reportError(error, onError, route);
|
|
354
|
+
return new SyncError('sync.internal_error', 'internal server error');
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** Hand `error` to the host's `onError`; a throwing handler is contained. */
|
|
358
|
+
export function reportError(
|
|
359
|
+
error: unknown,
|
|
360
|
+
onError: SyncularErrorHandler | undefined,
|
|
361
|
+
route: SyncularErrorRoute,
|
|
362
|
+
): void {
|
|
363
|
+
try {
|
|
364
|
+
onError?.(error, { route });
|
|
365
|
+
} catch {
|
|
366
|
+
// The report is best effort; the response stays the catalog error.
|
|
367
|
+
}
|
|
368
|
+
}
|
package/src/handler.ts
CHANGED
|
@@ -545,10 +545,12 @@ async function* streamResponse(
|
|
|
545
545
|
const horizonSeq = await ctx.storage.getHorizonSeq(ctx.partition);
|
|
546
546
|
const prepared = await prepareSections(
|
|
547
547
|
ctx,
|
|
548
|
+
schema,
|
|
548
549
|
limits,
|
|
549
550
|
plan.subscriptions,
|
|
550
551
|
maxSeq,
|
|
551
552
|
horizonSeq,
|
|
553
|
+
plan.logEpoch,
|
|
552
554
|
);
|
|
553
555
|
for (const [index, subscription] of plan.subscriptions.entries()) {
|
|
554
556
|
const trace: PullSectionTrace | undefined =
|
package/src/index.ts
CHANGED
|
@@ -5,7 +5,7 @@ import {
|
|
|
5
5
|
} from '@syncular/core';
|
|
6
6
|
import type { RealtimeNotifier, SyncRequestContext } from './context';
|
|
7
7
|
import { REMOTE_COMMAND_CLIENT_ID_PREFIX } from './context';
|
|
8
|
-
import { SyncError, syncError } from './errors';
|
|
8
|
+
import { reportError, SyncError, syncError } from './errors';
|
|
9
9
|
import type {
|
|
10
10
|
RegisteredRemoteQuery,
|
|
11
11
|
RemoteOperationRegistry,
|
|
@@ -181,6 +181,9 @@ class WatchSession implements RemoteOperationWatchSession {
|
|
|
181
181
|
if (this.#isClosed || this.#watches.get(state.watchId) !== state) {
|
|
182
182
|
return;
|
|
183
183
|
}
|
|
184
|
+
if (!(error instanceof SyncError)) {
|
|
185
|
+
reportError(error, this.#ctx.onError, 'operations');
|
|
186
|
+
}
|
|
184
187
|
this.#sendError(
|
|
185
188
|
state.watchId,
|
|
186
189
|
error instanceof SyncError
|
package/src/operations.ts
CHANGED
|
@@ -18,7 +18,7 @@ import {
|
|
|
18
18
|
RESOLVER_OUTAGE,
|
|
19
19
|
touchAuthenticatedPartition,
|
|
20
20
|
} from './context';
|
|
21
|
-
import { SyncError, syncError } from './errors';
|
|
21
|
+
import { reportError, SyncError, syncError } from './errors';
|
|
22
22
|
import { processPushOperationsWithTrace } from './push';
|
|
23
23
|
import { compileSchema } from './schema';
|
|
24
24
|
import { authorizeWrite, type ResolvedScopes } from './scopes';
|
|
@@ -450,6 +450,7 @@ export function registerRemoteQuery<Params>(
|
|
|
450
450
|
);
|
|
451
451
|
} catch (error) {
|
|
452
452
|
if (error instanceof SyncError) throw error;
|
|
453
|
+
reportError(error, ctx.onError, 'operations');
|
|
453
454
|
throw syncError(
|
|
454
455
|
'operation.query_failed',
|
|
455
456
|
'registered query execution failed',
|
|
@@ -463,6 +464,7 @@ export function registerRemoteQuery<Params>(
|
|
|
463
464
|
rows = normalizeQueryRows(result.rows, descriptor.resultColumns);
|
|
464
465
|
} catch (error) {
|
|
465
466
|
if (error instanceof SyncError) throw error;
|
|
467
|
+
reportError(error, ctx.onError, 'operations');
|
|
466
468
|
throw syncError(
|
|
467
469
|
'operation.query_failed',
|
|
468
470
|
'registered query result decoding failed',
|
|
@@ -766,6 +768,9 @@ export async function handleRemoteOperation(
|
|
|
766
768
|
),
|
|
767
769
|
);
|
|
768
770
|
} catch (error) {
|
|
771
|
+
if (!(error instanceof SyncError)) {
|
|
772
|
+
reportError(error, ctx.onError, 'operations');
|
|
773
|
+
}
|
|
769
774
|
return encodeRemoteOperationError(error, 'operation.execution_failed');
|
|
770
775
|
}
|
|
771
776
|
}
|