@syncular/server 0.32.1 → 0.34.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/blob-handlers.js +3 -3
- package/dist/bucket-blob-store.d.ts +77 -0
- package/dist/bucket-blob-store.js +266 -0
- package/dist/bucket-segment-store.d.ts +77 -0
- package/dist/bucket-segment-store.js +445 -0
- package/dist/context.d.ts +7 -0
- package/dist/d1-storage.js +2 -2
- package/dist/handler.js +4 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/object-bucket.d.ts +78 -0
- package/dist/object-bucket.js +18 -0
- package/dist/operations-realtime.js +3 -0
- package/dist/operations.d.ts +20 -3
- package/dist/operations.js +23 -3
- package/dist/realtime.d.ts +11 -0
- package/dist/realtime.js +25 -58
- package/dist/relational-rows.d.ts +6 -3
- package/dist/relational-rows.js +8 -9
- package/dist/s3-blob-store.d.ts +9 -64
- package/dist/s3-blob-store.js +25 -359
- package/dist/s3-object-bucket.d.ts +39 -0
- package/dist/s3-object-bucket.js +164 -0
- package/dist/s3-segment-store.d.ts +9 -61
- package/dist/s3-segment-store.js +25 -503
- package/dist/sqlite-storage.js +3 -8
- package/package.json +2 -2
- package/src/blob-handlers.ts +4 -4
- package/src/bucket-blob-store.ts +351 -0
- package/src/bucket-segment-store.ts +582 -0
- package/src/context.ts +10 -0
- package/src/d1-storage.ts +3 -3
- package/src/handler.ts +11 -0
- package/src/index.ts +4 -0
- package/src/object-bucket.ts +83 -0
- package/src/operations-realtime.ts +7 -0
- package/src/operations.ts +52 -5
- package/src/realtime.ts +29 -58
- package/src/relational-rows.ts +8 -11
- package/src/s3-blob-store.ts +25 -466
- package/src/s3-object-bucket.ts +233 -0
- package/src/s3-segment-store.ts +25 -646
- package/src/sqlite-storage.ts +3 -10
|
@@ -0,0 +1,445 @@
|
|
|
1
|
+
import { mergeSegmentRecord, publicationRecord, publicationsForRecord, segmentBytesEqual, segmentIdFor, selectSegmentPublication, } from './segment-store.js';
|
|
2
|
+
import { sha256Hex } from './sigv4.js';
|
|
3
|
+
const DEFAULT_TTL_MS = 24 * 60 * 60 * 1000;
|
|
4
|
+
const SEGMENT_ID_PATTERN = /^sha256:[0-9a-f]{64}$/;
|
|
5
|
+
/** Legacy record carried in the bytes object's user metadata. */
|
|
6
|
+
const LEGACY_RECORD_META_KEY = 'syncular-record';
|
|
7
|
+
/**
|
|
8
|
+
* Stats accumulator (§ "S3 stats" in the README). The store keeps a small
|
|
9
|
+
* counter object under a fixed key, updated read-modify-write on `put`. It
|
|
10
|
+
* exists because a LIST-free store (every lookup is a GET/HEAD) has no cheap
|
|
11
|
+
* way to count objects on demand — so `stats()` reads the accumulator instead
|
|
12
|
+
* of enumerating the bucket.
|
|
13
|
+
*
|
|
14
|
+
* The write is guarded by an ETag compare-and-swap (`ifMatch`, or `createOnly`
|
|
15
|
+
* to create): the loop re-reads and retries on a failed precondition, so
|
|
16
|
+
* concurrent writers do not silently clobber each other's increment. The
|
|
17
|
+
* counters are still APPROXIMATE — a crash between the segment PUT and the
|
|
18
|
+
* accumulator CAS, or a writer against a backend that ignores conditional
|
|
19
|
+
* writes, can drift them — so the admin surface labels these stats
|
|
20
|
+
* `approximate: true`. They are a health gauge, not an invoice.
|
|
21
|
+
*/
|
|
22
|
+
const STATS_KEY_SUFFIX = 'stats/segments.json';
|
|
23
|
+
const CAS_ATTEMPTS = 16;
|
|
24
|
+
function emptyStats() {
|
|
25
|
+
return { count: 0, bytes: 0, rowsSegments: 0, sqliteSegments: 0 };
|
|
26
|
+
}
|
|
27
|
+
function parseStatsJson(json) {
|
|
28
|
+
const parsed = JSON.parse(json);
|
|
29
|
+
return {
|
|
30
|
+
count: Number(parsed.count ?? 0),
|
|
31
|
+
bytes: Number(parsed.bytes ?? 0),
|
|
32
|
+
rowsSegments: Number(parsed.rowsSegments ?? 0),
|
|
33
|
+
sqliteSegments: Number(parsed.sqliteSegments ?? 0),
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
// Runtime-neutral base64url: `Buffer` is not present on
|
|
37
|
+
// Cloudflare Workers without `nodejs_compat`, so a legacy object-metadata
|
|
38
|
+
// record header is decoded with `atob`, available in every runtime.
|
|
39
|
+
function base64urlToUtf8(value) {
|
|
40
|
+
const binary = atob(value.replaceAll('-', '+').replaceAll('_', '/'));
|
|
41
|
+
const bytes = new Uint8Array(binary.length);
|
|
42
|
+
for (let i = 0; i < binary.length; i++)
|
|
43
|
+
bytes[i] = binary.charCodeAt(i);
|
|
44
|
+
return new TextDecoder().decode(bytes);
|
|
45
|
+
}
|
|
46
|
+
function recordToJson(record) {
|
|
47
|
+
return JSON.stringify(record);
|
|
48
|
+
}
|
|
49
|
+
function isMediaType(value) {
|
|
50
|
+
return value === 'rows' || value === 'sqlite';
|
|
51
|
+
}
|
|
52
|
+
/** One publication inside a stored record (or a legacy materialized one). */
|
|
53
|
+
function parsePublication(value, source, label) {
|
|
54
|
+
if (typeof value !== 'object' || value === null) {
|
|
55
|
+
throw new Error(`${label}: corrupt publication in ${source}`);
|
|
56
|
+
}
|
|
57
|
+
const p = value;
|
|
58
|
+
const strings = ['partition', 'logEpoch', 'table', 'scopeDigest'];
|
|
59
|
+
const numbers = [
|
|
60
|
+
'schemaVersion',
|
|
61
|
+
'asOfCommitSeq',
|
|
62
|
+
'rowCount',
|
|
63
|
+
'byteLength',
|
|
64
|
+
'createdAtMs',
|
|
65
|
+
'expiresAtMs',
|
|
66
|
+
];
|
|
67
|
+
for (const field of strings) {
|
|
68
|
+
if (typeof p[field] !== 'string') {
|
|
69
|
+
throw new Error(`${label}: bad publication field ${field} in ${source}`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
for (const field of numbers) {
|
|
73
|
+
if (typeof p[field] !== 'number') {
|
|
74
|
+
throw new Error(`${label}: bad publication field ${field} in ${source}`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
if (!isMediaType(p.mediaType)) {
|
|
78
|
+
throw new Error(`${label}: bad publication mediaType in ${source}`);
|
|
79
|
+
}
|
|
80
|
+
const cursorOk = (v) => v === null || typeof v === 'string';
|
|
81
|
+
if (!cursorOk(p.rowCursor) || !cursorOk(p.nextRowCursor)) {
|
|
82
|
+
throw new Error(`${label}: bad publication cursor in ${source}`);
|
|
83
|
+
}
|
|
84
|
+
return {
|
|
85
|
+
partition: p.partition,
|
|
86
|
+
logEpoch: p.logEpoch,
|
|
87
|
+
table: p.table,
|
|
88
|
+
schemaVersion: p.schemaVersion,
|
|
89
|
+
mediaType: p.mediaType,
|
|
90
|
+
scopeDigest: p.scopeDigest,
|
|
91
|
+
asOfCommitSeq: p.asOfCommitSeq,
|
|
92
|
+
rowCount: p.rowCount,
|
|
93
|
+
rowCursor: p.rowCursor,
|
|
94
|
+
nextRowCursor: p.nextRowCursor,
|
|
95
|
+
byteLength: p.byteLength,
|
|
96
|
+
createdAtMs: p.createdAtMs,
|
|
97
|
+
expiresAtMs: p.expiresAtMs,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
function parseRecordJson(json, source, label) {
|
|
101
|
+
let parsed;
|
|
102
|
+
try {
|
|
103
|
+
parsed = JSON.parse(json);
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
throw new Error(`${label}: corrupt record JSON in ${source}`);
|
|
107
|
+
}
|
|
108
|
+
if (typeof parsed !== 'object' || parsed === null) {
|
|
109
|
+
throw new Error(`${label}: corrupt record in ${source}`);
|
|
110
|
+
}
|
|
111
|
+
const r = parsed;
|
|
112
|
+
const strings = [
|
|
113
|
+
'segmentId',
|
|
114
|
+
'partition',
|
|
115
|
+
'logEpoch',
|
|
116
|
+
'table',
|
|
117
|
+
'scopeDigest',
|
|
118
|
+
];
|
|
119
|
+
const numbers = [
|
|
120
|
+
'schemaVersion',
|
|
121
|
+
'asOfCommitSeq',
|
|
122
|
+
'rowCount',
|
|
123
|
+
'byteLength',
|
|
124
|
+
'createdAtMs',
|
|
125
|
+
'expiresAtMs',
|
|
126
|
+
];
|
|
127
|
+
for (const field of strings) {
|
|
128
|
+
if (typeof r[field] !== 'string') {
|
|
129
|
+
throw new Error(`${label}: bad record field ${field} in ${source}`);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
for (const field of numbers) {
|
|
133
|
+
if (typeof r[field] !== 'number') {
|
|
134
|
+
throw new Error(`${label}: bad record field ${field} in ${source}`);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
if (!isMediaType(r.mediaType)) {
|
|
138
|
+
throw new Error(`${label}: bad record mediaType in ${source}`);
|
|
139
|
+
}
|
|
140
|
+
const cursorOk = (v) => v === null || typeof v === 'string';
|
|
141
|
+
if (!cursorOk(r.rowCursor) || !cursorOk(r.nextRowCursor)) {
|
|
142
|
+
throw new Error(`${label}: bad record cursor in ${source}`);
|
|
143
|
+
}
|
|
144
|
+
const scopeDigest = r.scopeDigest;
|
|
145
|
+
// Records written before §5.1 recorded the digest set carry only the
|
|
146
|
+
// primary digest; the object is a cache entry with a TTL, so reading it
|
|
147
|
+
// back as a one-digest record is correct, not a degraded path.
|
|
148
|
+
const rawDigests = r.scopeDigests;
|
|
149
|
+
if (rawDigests !== undefined &&
|
|
150
|
+
(!Array.isArray(rawDigests) ||
|
|
151
|
+
rawDigests.some((digest) => typeof digest !== 'string'))) {
|
|
152
|
+
throw new Error(`${label}: bad record scopeDigests in ${source}`);
|
|
153
|
+
}
|
|
154
|
+
const rawPublications = r.publications;
|
|
155
|
+
if (rawPublications !== undefined && !Array.isArray(rawPublications)) {
|
|
156
|
+
throw new Error(`${label}: bad record publications in ${source}`);
|
|
157
|
+
}
|
|
158
|
+
const top = {
|
|
159
|
+
segmentId: r.segmentId,
|
|
160
|
+
partition: r.partition,
|
|
161
|
+
logEpoch: r.logEpoch,
|
|
162
|
+
table: r.table,
|
|
163
|
+
schemaVersion: r.schemaVersion,
|
|
164
|
+
mediaType: r.mediaType,
|
|
165
|
+
scopeDigest,
|
|
166
|
+
scopeDigests: [
|
|
167
|
+
scopeDigest,
|
|
168
|
+
...(rawDigests ?? []).filter((digest) => digest !== scopeDigest),
|
|
169
|
+
],
|
|
170
|
+
asOfCommitSeq: r.asOfCommitSeq,
|
|
171
|
+
rowCount: r.rowCount,
|
|
172
|
+
rowCursor: r.rowCursor,
|
|
173
|
+
nextRowCursor: r.nextRowCursor,
|
|
174
|
+
byteLength: r.byteLength,
|
|
175
|
+
createdAtMs: r.createdAtMs,
|
|
176
|
+
expiresAtMs: r.expiresAtMs,
|
|
177
|
+
};
|
|
178
|
+
return {
|
|
179
|
+
...top,
|
|
180
|
+
// A record from before per-publication provenance, or a legacy record
|
|
181
|
+
// whose union came from object metadata, materializes its publications
|
|
182
|
+
// from the merged top-level context — the old single-record read.
|
|
183
|
+
publications: rawPublications === undefined
|
|
184
|
+
? publicationsForRecord(top)
|
|
185
|
+
: rawPublications.map((value) => parsePublication(value, source, label)),
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
export class BucketSegmentStore {
|
|
189
|
+
#bucket;
|
|
190
|
+
#ttlMs;
|
|
191
|
+
#prefix;
|
|
192
|
+
/** Error prefix: the concrete store class the host constructed. */
|
|
193
|
+
#label;
|
|
194
|
+
constructor(bucket, options) {
|
|
195
|
+
this.#bucket = bucket;
|
|
196
|
+
this.#ttlMs = options?.ttlMs ?? DEFAULT_TTL_MS;
|
|
197
|
+
this.#prefix = options?.keyPrefix ?? '';
|
|
198
|
+
this.#label = this.constructor.name;
|
|
199
|
+
}
|
|
200
|
+
/** `{keyPrefix}seg/sha256/{hex}` — the §5.4 "key embeds the segmentId". */
|
|
201
|
+
objectKeyFor(segmentId) {
|
|
202
|
+
return `${this.#prefix}seg/${segmentId.replace(':', '/')}`;
|
|
203
|
+
}
|
|
204
|
+
/** `{keyPrefix}rec/sha256/{hex}.json` — the §5.1 record (§5.1 union). */
|
|
205
|
+
#recordKeyFor(segmentId) {
|
|
206
|
+
return `${this.#prefix}rec/${segmentId.replace(':', '/')}.json`;
|
|
207
|
+
}
|
|
208
|
+
async #findKeyFor(key) {
|
|
209
|
+
const canonical = JSON.stringify([
|
|
210
|
+
key.partition,
|
|
211
|
+
key.logEpoch,
|
|
212
|
+
key.table,
|
|
213
|
+
key.schemaVersion,
|
|
214
|
+
key.mediaType,
|
|
215
|
+
key.scopeDigest,
|
|
216
|
+
key.asOfCommitSeq,
|
|
217
|
+
]);
|
|
218
|
+
return `${this.#prefix}find/${await sha256Hex(canonical)}.json`;
|
|
219
|
+
}
|
|
220
|
+
#statsKey() {
|
|
221
|
+
return `${this.#prefix}${STATS_KEY_SUFFIX}`;
|
|
222
|
+
}
|
|
223
|
+
/** Read the stats accumulator + its ETag (for the CAS write), or none. */
|
|
224
|
+
async #readStats() {
|
|
225
|
+
const object = await this.#bucket.get(this.#statsKey());
|
|
226
|
+
if (object === undefined) {
|
|
227
|
+
return { stats: emptyStats(), etag: undefined };
|
|
228
|
+
}
|
|
229
|
+
return {
|
|
230
|
+
stats: parseStatsJson(await object.text()),
|
|
231
|
+
etag: object.etag,
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* Conditionally write the stats accumulator. `etag` present ⇒ `ifMatch`
|
|
236
|
+
* (update the object we read); absent ⇒ `createOnly` (create only). Returns
|
|
237
|
+
* `false` on a failed precondition so the caller retries.
|
|
238
|
+
*/
|
|
239
|
+
async #writeStatsCas(stats, etag) {
|
|
240
|
+
return this.#bucket.put(this.#statsKey(), {
|
|
241
|
+
body: new TextEncoder().encode(JSON.stringify(stats)),
|
|
242
|
+
contentType: 'application/json',
|
|
243
|
+
...(etag === undefined ? { createOnly: true } : { ifMatch: etag }),
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* Fold `delta` into the stats accumulator under an ETag compare-and-swap
|
|
248
|
+
* retry loop. Best-effort: exhausting the retry budget leaves the counters
|
|
249
|
+
* drifted (they are documented APPROXIMATE) rather than failing the `put`
|
|
250
|
+
* that already stored the bytes.
|
|
251
|
+
*/
|
|
252
|
+
async #bumpStats(delta) {
|
|
253
|
+
for (let attempt = 0; attempt < CAS_ATTEMPTS; attempt++) {
|
|
254
|
+
const { stats, etag } = await this.#readStats();
|
|
255
|
+
const next = {
|
|
256
|
+
count: stats.count + delta.count,
|
|
257
|
+
bytes: stats.bytes + delta.bytes,
|
|
258
|
+
rowsSegments: stats.rowsSegments + delta.rowsSegments,
|
|
259
|
+
sqliteSegments: stats.sqliteSegments + delta.sqliteSegments,
|
|
260
|
+
};
|
|
261
|
+
if (await this.#writeStatsCas(next, etag))
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
// Lost the CAS race `CAS_ATTEMPTS` times running — leave the
|
|
265
|
+
// counters as-is. `stats()` stays APPROXIMATE by contract.
|
|
266
|
+
}
|
|
267
|
+
async put(metadata, bytes, nowMs) {
|
|
268
|
+
const segmentId = await segmentIdFor(bytes);
|
|
269
|
+
await this.#putBytes(segmentId, bytes);
|
|
270
|
+
for (let attempt = 0; attempt < CAS_ATTEMPTS; attempt++) {
|
|
271
|
+
// The id IS the content address, so an existing record means a second
|
|
272
|
+
// scope published the same bytes: read it back and merge the digest set
|
|
273
|
+
// (§5.1).
|
|
274
|
+
const preexisting = await this.#readRecord(segmentId);
|
|
275
|
+
const record = mergeSegmentRecord(preexisting?.record, metadata, segmentId, bytes.length, nowMs, this.#ttlMs);
|
|
276
|
+
const recordJson = recordToJson(record);
|
|
277
|
+
// The digest set is monotone (§5.1) and this is a read-modify-write: a
|
|
278
|
+
// concurrent publisher of the same bytes computed its own union from the
|
|
279
|
+
// same pre-state, so an unconditional PUT would drop one of the digests
|
|
280
|
+
// and make that scope's download `sync.forbidden`. The write is
|
|
281
|
+
// conditional on the record body that was read — the record ETag is the
|
|
282
|
+
// body hash, so it changes whenever the union does — and a failed
|
|
283
|
+
// precondition re-reads and merges again. In-process dedupe cannot cover
|
|
284
|
+
// this: two scopes whose rows are byte-identical are two builds, and two
|
|
285
|
+
// server instances share one bucket.
|
|
286
|
+
const written = await this.#bucket.put(this.#recordKeyFor(segmentId), {
|
|
287
|
+
body: new TextEncoder().encode(recordJson),
|
|
288
|
+
contentType: 'application/json',
|
|
289
|
+
...(preexisting?.etag === undefined
|
|
290
|
+
? { createOnly: true }
|
|
291
|
+
: { ifMatch: preexisting.etag }),
|
|
292
|
+
});
|
|
293
|
+
if (!written)
|
|
294
|
+
continue;
|
|
295
|
+
if (preexisting === undefined) {
|
|
296
|
+
await this.#bumpStats({
|
|
297
|
+
count: 1,
|
|
298
|
+
bytes: bytes.length,
|
|
299
|
+
rowsSegments: metadata.mediaType === 'rows' ? 1 : 0,
|
|
300
|
+
sqliteSegments: metadata.mediaType === 'sqlite' ? 1 : 0,
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
if (metadata.rowCursor === null) {
|
|
304
|
+
await this.#bucket.put(await this.#findKeyFor(metadata), {
|
|
305
|
+
body: new TextEncoder().encode(recordJson),
|
|
306
|
+
contentType: 'application/json',
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
return record;
|
|
310
|
+
}
|
|
311
|
+
throw new Error(`${this.#label}: ${CAS_ATTEMPTS} conditional writes in a row lost the race for ${segmentId} — the stored digest set would have dropped a scope`);
|
|
312
|
+
}
|
|
313
|
+
/**
|
|
314
|
+
* Write the immutable bytes object. Create-only, so a second publisher of
|
|
315
|
+
* identical bytes is a no-op and a second publisher of DIFFERENT bytes under
|
|
316
|
+
* the same content address is the hash collision §5.1 requires to fail
|
|
317
|
+
* loudly instead of overwriting.
|
|
318
|
+
*/
|
|
319
|
+
async #putBytes(segmentId, bytes) {
|
|
320
|
+
const key = this.objectKeyFor(segmentId);
|
|
321
|
+
const written = await this.#bucket.put(key, {
|
|
322
|
+
body: bytes,
|
|
323
|
+
contentType: 'application/octet-stream',
|
|
324
|
+
createOnly: true,
|
|
325
|
+
});
|
|
326
|
+
if (written)
|
|
327
|
+
return;
|
|
328
|
+
const current = await this.#bucket.get(key);
|
|
329
|
+
if (current === undefined)
|
|
330
|
+
return;
|
|
331
|
+
const stored = new Uint8Array(await current.arrayBuffer());
|
|
332
|
+
if (!segmentBytesEqual(stored, bytes)) {
|
|
333
|
+
throw new Error(`${this.#label}: content-address collision at ${segmentId} (§5.1)`);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
/**
|
|
337
|
+
* Read the record object plus the ETag of the body that was read, so the
|
|
338
|
+
* union write can be made conditional on exactly that pre-state (§5.1).
|
|
339
|
+
*
|
|
340
|
+
* A record written before the record/bytes split rides in the bytes object's
|
|
341
|
+
* user metadata; it is read here and its union is materialized into a record
|
|
342
|
+
* object by the next `put`. That shape carries no usable ETag for the union
|
|
343
|
+
* (the bytes ETag is a content hash), which is exactly why it was replaced.
|
|
344
|
+
* A bytes object with neither a record object nor the legacy metadata is an
|
|
345
|
+
* entry whose record is missing — a cache miss, not corruption, so `get`
|
|
346
|
+
* reports it as absent and the client re-pulls.
|
|
347
|
+
*/
|
|
348
|
+
async #readRecord(segmentId) {
|
|
349
|
+
if (!SEGMENT_ID_PATTERN.test(segmentId))
|
|
350
|
+
return undefined;
|
|
351
|
+
const object = await this.#bucket.get(this.#recordKeyFor(segmentId));
|
|
352
|
+
if (object !== undefined) {
|
|
353
|
+
return {
|
|
354
|
+
record: parseRecordJson(await object.text(), 'segment record', this.#label),
|
|
355
|
+
etag: object.etag,
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
const legacy = await this.#bucket.get(this.objectKeyFor(segmentId));
|
|
359
|
+
if (legacy === undefined)
|
|
360
|
+
return undefined;
|
|
361
|
+
const meta = legacy.metadata[LEGACY_RECORD_META_KEY];
|
|
362
|
+
await legacy.arrayBuffer();
|
|
363
|
+
if (meta === undefined)
|
|
364
|
+
return undefined;
|
|
365
|
+
return {
|
|
366
|
+
record: parseRecordJson(base64urlToUtf8(meta), segmentId, this.#label),
|
|
367
|
+
etag: undefined,
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
async get(segmentId) {
|
|
371
|
+
if (!SEGMENT_ID_PATTERN.test(segmentId))
|
|
372
|
+
return undefined;
|
|
373
|
+
const record = await this.#readRecord(segmentId);
|
|
374
|
+
if (record === undefined)
|
|
375
|
+
return undefined;
|
|
376
|
+
const object = await this.#bucket.get(this.objectKeyFor(segmentId));
|
|
377
|
+
if (object === undefined)
|
|
378
|
+
return undefined;
|
|
379
|
+
return {
|
|
380
|
+
record: record.record,
|
|
381
|
+
bytes: new Uint8Array(await object.arrayBuffer()),
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
/** The record plus the object GET's body, never buffered (see `get`). */
|
|
385
|
+
async open(segmentId) {
|
|
386
|
+
if (!SEGMENT_ID_PATTERN.test(segmentId))
|
|
387
|
+
return undefined;
|
|
388
|
+
const record = await this.#readRecord(segmentId);
|
|
389
|
+
if (record === undefined)
|
|
390
|
+
return undefined;
|
|
391
|
+
const object = await this.#bucket.get(this.objectKeyFor(segmentId));
|
|
392
|
+
if (object === undefined || object.body === null)
|
|
393
|
+
return undefined;
|
|
394
|
+
return {
|
|
395
|
+
record: record.record,
|
|
396
|
+
body: object.body,
|
|
397
|
+
byteLength: record.record.byteLength,
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
async find(key, nowMs) {
|
|
401
|
+
const pointer = await this.#bucket.get(await this.#findKeyFor(key));
|
|
402
|
+
if (pointer === undefined)
|
|
403
|
+
return undefined;
|
|
404
|
+
const record = parseRecordJson(await pointer.text(), 'reuse pointer', this.#label);
|
|
405
|
+
// The pointer is keyed by the full context, but the record body is the
|
|
406
|
+
// whole merged entry: select the publication the key names, never the
|
|
407
|
+
// top-level fields or the digest union (§5.1).
|
|
408
|
+
const publication = selectSegmentPublication(record, {
|
|
409
|
+
partition: key.partition,
|
|
410
|
+
logEpoch: key.logEpoch,
|
|
411
|
+
table: key.table,
|
|
412
|
+
schemaVersion: key.schemaVersion,
|
|
413
|
+
mediaType: key.mediaType,
|
|
414
|
+
scopeDigest: key.scopeDigest,
|
|
415
|
+
asOfCommitSeq: key.asOfCommitSeq,
|
|
416
|
+
rowCursor: null,
|
|
417
|
+
nowMs,
|
|
418
|
+
});
|
|
419
|
+
if (publication === undefined)
|
|
420
|
+
return undefined;
|
|
421
|
+
// Lifecycle GC may have removed the object while the pointer survived.
|
|
422
|
+
const head = await this.#bucket.head(this.objectKeyFor(record.segmentId));
|
|
423
|
+
if (head === undefined)
|
|
424
|
+
return undefined;
|
|
425
|
+
return publicationRecord(record, publication);
|
|
426
|
+
}
|
|
427
|
+
/**
|
|
428
|
+
* Store-wide counters from the pointer-object accumulator (never a LIST).
|
|
429
|
+
* Marked `approximate: true`: the counters are maintained by a best-effort
|
|
430
|
+
* ETag-CAS read-modify-write on `put`, so a crash between the segment PUT
|
|
431
|
+
* and the accumulator write, or lifecycle GC that deletes objects the
|
|
432
|
+
* accumulator still counts, can drift them. Exact within a single writer;
|
|
433
|
+
* a health gauge under concurrency. See the README "S3 stats".
|
|
434
|
+
*/
|
|
435
|
+
async stats() {
|
|
436
|
+
const { stats } = await this.#readStats();
|
|
437
|
+
return {
|
|
438
|
+
count: stats.count,
|
|
439
|
+
bytes: stats.bytes,
|
|
440
|
+
rowsSegments: stats.rowsSegments,
|
|
441
|
+
sqliteSegments: stats.sqliteSegments,
|
|
442
|
+
approximate: true,
|
|
443
|
+
};
|
|
444
|
+
}
|
|
445
|
+
}
|
package/dist/context.d.ts
CHANGED
|
@@ -69,6 +69,13 @@ export declare const DEFAULT_LIMITS: ServerLimits;
|
|
|
69
69
|
/** Receives applied commits for realtime fanout (§8.2). */
|
|
70
70
|
export interface RealtimeNotifier {
|
|
71
71
|
notifyCommit(partition: string, commit: StoredCommit): void | Promise<void>;
|
|
72
|
+
/**
|
|
73
|
+
* A pull changed the client's persisted subscription list (§8.7). A hub
|
|
74
|
+
* re-registers that client's live sockets, so a client that runs its
|
|
75
|
+
* rounds over HTTP while a socket is open receives deltas for the new
|
|
76
|
+
* subscriptions.
|
|
77
|
+
*/
|
|
78
|
+
notifySubscriptions?(partition: string, clientId: string): void | Promise<void>;
|
|
72
79
|
}
|
|
73
80
|
export interface SyncServerConfig {
|
|
74
81
|
readonly schema: ServerSchema;
|
package/dist/d1-storage.js
CHANGED
|
@@ -45,7 +45,7 @@ import { StorageQueryError } from './storage-errors.js';
|
|
|
45
45
|
import { decodeRow } from '@syncular/core';
|
|
46
46
|
import { bindAuthoritativePartition, prepareAuthoritativeQuery, } from './authoritative-query.js';
|
|
47
47
|
import { syncError } from './errors.js';
|
|
48
|
-
import { assertAppendOnlyMigration, assertPhysicalColumns, assertStoredLayouts, commitWindowPageSql,
|
|
48
|
+
import { assertAppendOnlyMigration, assertPhysicalColumns, assertStoredLayouts, commitWindowPageSql, LATEST_SCOPED_CHANGE_SEQ_SQLITE_SQL, deleteRowSql, deleteSqliteRowScopesSql, dropTableDdl, indexRowPageStatement, layoutsOf, migratePayload, parseLayouts, quoteIdent, retiredTableNames, rewritePlan, rewriteRowSql, rewriteValues, SCHEMA_META_DDL_SQLITE, SYNC_COLUMN_VERSIONS_COLUMN, SYNC_PARTITION_COLUMN, SYNC_PAYLOAD_COLUMN, SYNC_ROW_ID_COLUMN, SYNC_SCOPES_COLUMN, SYNC_VERSION_COLUMN, scanRowPageSql, schemaDdl, selectRowScopesSql, selectRowSql, selectRowsForRewriteSql, tableColumnNames, toSqlValue, upsertSql, upsertValues, } from './relational-rows.js';
|
|
49
49
|
import { matchesEffective } from './scopes.js';
|
|
50
50
|
import { asUint8Array, collectCommitWindowPage, deserializePushResult, placeholders, serializePushResult, sqliteDdlStatements, toStoredChange, toStoredRow, } from './sqlite-dialect.js';
|
|
51
51
|
import { isD1ConstraintError, StorageConstraintError } from './storage-errors.js';
|
|
@@ -1456,7 +1456,7 @@ export class D1ServerStorage {
|
|
|
1456
1456
|
if (firstVariable === undefined || values.length === 0)
|
|
1457
1457
|
return 0;
|
|
1458
1458
|
// Concurrent reads of a pull share one `db.batch` round trip.
|
|
1459
|
-
const [row] = await this.#batchedAll(
|
|
1459
|
+
const [row] = await this.#batchedAll(LATEST_SCOPED_CHANGE_SEQ_SQLITE_SQL, [partition, query.table, firstVariable, JSON.stringify(values)]);
|
|
1460
1460
|
return row?.seq ?? 0;
|
|
1461
1461
|
}
|
|
1462
1462
|
async readCommitWindow(partition, query) {
|
package/dist/handler.js
CHANGED
|
@@ -484,6 +484,10 @@ async function* streamResponse(plan, ctx, schema, report) {
|
|
|
484
484
|
updatedAtMs: clockOf(ctx)(),
|
|
485
485
|
subscriptions,
|
|
486
486
|
});
|
|
487
|
+
if (ctx.realtime?.notifySubscriptions !== undefined &&
|
|
488
|
+
JSON.stringify((previous?.subscriptions ?? []).map((s) => [s.id, s.table, s.scopes])) !== JSON.stringify(subscriptions.map((s) => [s.id, s.table, s.scopes]))) {
|
|
489
|
+
await ctx.realtime.notifySubscriptions(ctx.partition, plan.header.clientId);
|
|
490
|
+
}
|
|
487
491
|
}
|
|
488
492
|
catch (error) {
|
|
489
493
|
if (error instanceof SyncError) {
|
package/dist/index.d.ts
CHANGED
|
@@ -11,6 +11,8 @@ export * from './admin.js';
|
|
|
11
11
|
export * from './authoritative-query.js';
|
|
12
12
|
export * from './blob-handlers.js';
|
|
13
13
|
export * from './blob-store.js';
|
|
14
|
+
export * from './bucket-blob-store.js';
|
|
15
|
+
export * from './bucket-segment-store.js';
|
|
14
16
|
export * from './content-encoding.js';
|
|
15
17
|
export * from './context.js';
|
|
16
18
|
export * from './crdt-merger.js';
|
|
@@ -33,8 +35,10 @@ export * from './readiness.js';
|
|
|
33
35
|
export { DEFAULT_REACTION_INITIAL_BACKOFF_MS, DEFAULT_REACTION_LEASE_MS, DEFAULT_REACTION_MAX_ATTEMPTS, DEFAULT_REACTION_MAX_BACKOFF_MS, DEFAULT_REACTION_RETENTION, MAX_REACTION_FAILURE_DETAILS_BYTES, MAX_REACTION_PAYLOAD_BYTES, MAX_REACTIONS_PER_COMMIT, PermanentReactionError, pruneReactions, ReactionRunner, reactionIdempotencyKey, retryDeadLetterReaction, RetryableReactionError, type PlannedReaction, type PruneReactionsOptions, type ReactionHandler, type ReactionHandlerInput, type ReactionHandlers, type ReactionPlan, type ReactionPlanner, type ReactionPlannerInput, type ReactionPruneResult, type ReactionRetentionPolicy, type ReactionRunnerOptions, type ReactionRunResult, type ReactionTypeMap, } from './reactions.js';
|
|
34
36
|
export * from './realtime.js';
|
|
35
37
|
export * from './restore.js';
|
|
38
|
+
export * from './object-bucket.js';
|
|
36
39
|
export * from './relational-rows.js';
|
|
37
40
|
export * from './s3-blob-store.js';
|
|
41
|
+
export * from './s3-object-bucket.js';
|
|
38
42
|
export * from './s3-segment-store.js';
|
|
39
43
|
export * from './schema.js';
|
|
40
44
|
export * from './scopes.js';
|
package/dist/index.js
CHANGED
|
@@ -11,6 +11,8 @@ export * from './admin.js';
|
|
|
11
11
|
export * from './authoritative-query.js';
|
|
12
12
|
export * from './blob-handlers.js';
|
|
13
13
|
export * from './blob-store.js';
|
|
14
|
+
export * from './bucket-blob-store.js';
|
|
15
|
+
export * from './bucket-segment-store.js';
|
|
14
16
|
export * from './content-encoding.js';
|
|
15
17
|
export * from './context.js';
|
|
16
18
|
export * from './crdt-merger.js';
|
|
@@ -37,8 +39,10 @@ export * from './readiness.js';
|
|
|
37
39
|
export { DEFAULT_REACTION_INITIAL_BACKOFF_MS, DEFAULT_REACTION_LEASE_MS, DEFAULT_REACTION_MAX_ATTEMPTS, DEFAULT_REACTION_MAX_BACKOFF_MS, DEFAULT_REACTION_RETENTION, MAX_REACTION_FAILURE_DETAILS_BYTES, MAX_REACTION_PAYLOAD_BYTES, MAX_REACTIONS_PER_COMMIT, PermanentReactionError, pruneReactions, ReactionRunner, reactionIdempotencyKey, retryDeadLetterReaction, RetryableReactionError, } from './reactions.js';
|
|
38
40
|
export * from './realtime.js';
|
|
39
41
|
export * from './restore.js';
|
|
42
|
+
export * from './object-bucket.js';
|
|
40
43
|
export * from './relational-rows.js';
|
|
41
44
|
export * from './s3-blob-store.js';
|
|
45
|
+
export * from './s3-object-bucket.js';
|
|
42
46
|
export * from './s3-segment-store.js';
|
|
43
47
|
export * from './schema.js';
|
|
44
48
|
export * from './scopes.js';
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The object-bucket seam: the handful of object-store operations the durable
|
|
3
|
+
* segment store (§5.1) and blob store (§5.9.2) actually perform, expressed
|
|
4
|
+
* without an HTTP transport.
|
|
5
|
+
*
|
|
6
|
+
* Two adapters implement it — `S3ObjectBucket` (`s3-object-bucket.ts`,
|
|
7
|
+
* hand-rolled SigV4 over `fetch`) and the R2 binding adapter in
|
|
8
|
+
* `@syncular/server-workers` (`r2-stores.ts`, the Worker's `env.BUCKET`) — so
|
|
9
|
+
* one implementation of the store logic (`bucket-segment-store.ts`,
|
|
10
|
+
* `bucket-blob-store.ts`) runs over both. The key layouts are the same on
|
|
11
|
+
* either transport, so a bucket can be served through the S3 API for one host
|
|
12
|
+
* and the binding for another.
|
|
13
|
+
*
|
|
14
|
+
* Everything here is structural: an adapter may be any object with these
|
|
15
|
+
* methods, and Cloudflare's `R2Bucket` is assignable to the declaration in
|
|
16
|
+
* `r2-stores.ts` without a dependency on `@cloudflare/workers-types`.
|
|
17
|
+
*/
|
|
18
|
+
/** One stored object, readable once. Mirrors `R2ObjectBody`. */
|
|
19
|
+
export interface BucketObject {
|
|
20
|
+
/** The body, streamed; `null` only if the backend served none. */
|
|
21
|
+
readonly body: ReadableStream<Uint8Array<ArrayBuffer>> | null;
|
|
22
|
+
/** Backend ETag of the stored object (opaque, used as a CAS token). */
|
|
23
|
+
readonly etag: string | undefined;
|
|
24
|
+
/** Stored size in bytes, when the backend reports one. */
|
|
25
|
+
readonly size: number | undefined;
|
|
26
|
+
/** User metadata: the `x-amz-meta-*` headers S3-side, `customMetadata` on R2. */
|
|
27
|
+
readonly metadata: Readonly<Record<string, string>>;
|
|
28
|
+
arrayBuffer(): Promise<ArrayBuffer>;
|
|
29
|
+
text(): Promise<string>;
|
|
30
|
+
}
|
|
31
|
+
/** One stored object's description without its body. Mirrors `R2Object`. */
|
|
32
|
+
export interface BucketObjectHead {
|
|
33
|
+
readonly etag: string | undefined;
|
|
34
|
+
readonly size: number | undefined;
|
|
35
|
+
readonly metadata: Readonly<Record<string, string>>;
|
|
36
|
+
}
|
|
37
|
+
export interface BucketWriteOptions {
|
|
38
|
+
readonly body: Uint8Array;
|
|
39
|
+
readonly contentType: string;
|
|
40
|
+
/** User metadata; values must be ASCII (both backends require it). */
|
|
41
|
+
readonly metadata?: Readonly<Record<string, string>> | undefined;
|
|
42
|
+
/**
|
|
43
|
+
* Create-only write (`If-None-Match: *`, R2 `etagDoesNotMatch: '*'`). The
|
|
44
|
+
* content-addressed bytes object is written this way so a second publisher
|
|
45
|
+
* of identical bytes is a no-op and a hash collision cannot overwrite.
|
|
46
|
+
*/
|
|
47
|
+
readonly createOnly?: boolean | undefined;
|
|
48
|
+
/**
|
|
49
|
+
* Compare-and-swap write (`If-Match`, R2 `etagMatches`): the write lands
|
|
50
|
+
* only if the stored ETag still equals this. The read-modify-write of the
|
|
51
|
+
* segment record and of the stats accumulator depend on it.
|
|
52
|
+
*/
|
|
53
|
+
readonly ifMatch?: string | undefined;
|
|
54
|
+
}
|
|
55
|
+
/** One page of keys under a prefix. `cursor` is set when more remain. */
|
|
56
|
+
export interface BucketListPage {
|
|
57
|
+
readonly keys: readonly string[];
|
|
58
|
+
readonly cursor: string | undefined;
|
|
59
|
+
}
|
|
60
|
+
export interface ObjectBucket {
|
|
61
|
+
/** The object plus its bytes, or `undefined` when the key is absent. */
|
|
62
|
+
get(key: string): Promise<BucketObject | undefined>;
|
|
63
|
+
head(key: string): Promise<BucketObjectHead | undefined>;
|
|
64
|
+
/**
|
|
65
|
+
* Write one object. Returns `false` when the `createOnly` / `ifMatch`
|
|
66
|
+
* precondition failed, so the caller can re-read and retry; a backend that
|
|
67
|
+
* ignores conditional writes would instead silently lose a concurrent
|
|
68
|
+
* update, which is why the return value is not optional.
|
|
69
|
+
*/
|
|
70
|
+
put(key: string, options: BucketWriteOptions): Promise<boolean>;
|
|
71
|
+
/** Delete one object. Deleting an absent key succeeds (idempotent). */
|
|
72
|
+
delete(key: string): Promise<void>;
|
|
73
|
+
/**
|
|
74
|
+
* List keys under a prefix, one page at a time. The blob orphan sweep
|
|
75
|
+
* (§5.9.2) is the only caller: everything on the hot path is a GET/HEAD.
|
|
76
|
+
*/
|
|
77
|
+
list(prefix: string, cursor?: string): Promise<BucketListPage>;
|
|
78
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The object-bucket seam: the handful of object-store operations the durable
|
|
3
|
+
* segment store (§5.1) and blob store (§5.9.2) actually perform, expressed
|
|
4
|
+
* without an HTTP transport.
|
|
5
|
+
*
|
|
6
|
+
* Two adapters implement it — `S3ObjectBucket` (`s3-object-bucket.ts`,
|
|
7
|
+
* hand-rolled SigV4 over `fetch`) and the R2 binding adapter in
|
|
8
|
+
* `@syncular/server-workers` (`r2-stores.ts`, the Worker's `env.BUCKET`) — so
|
|
9
|
+
* one implementation of the store logic (`bucket-segment-store.ts`,
|
|
10
|
+
* `bucket-blob-store.ts`) runs over both. The key layouts are the same on
|
|
11
|
+
* either transport, so a bucket can be served through the S3 API for one host
|
|
12
|
+
* and the binding for another.
|
|
13
|
+
*
|
|
14
|
+
* Everything here is structural: an adapter may be any object with these
|
|
15
|
+
* methods, and Cloudflare's `R2Bucket` is assignable to the declaration in
|
|
16
|
+
* `r2-stores.ts` without a dependency on `@cloudflare/workers-types`.
|
|
17
|
+
*/
|
|
18
|
+
export {};
|
|
@@ -195,5 +195,8 @@ export function composeRealtimeNotifiers(...notifiers) {
|
|
|
195
195
|
notifyCommit: async (partition, commit) => {
|
|
196
196
|
await Promise.all(notifiers.map((notifier) => notifier.notifyCommit(partition, commit)));
|
|
197
197
|
},
|
|
198
|
+
notifySubscriptions: async (partition, clientId) => {
|
|
199
|
+
await Promise.all(notifiers.map((notifier) => notifier.notifySubscriptions?.(partition, clientId)));
|
|
200
|
+
},
|
|
198
201
|
};
|
|
199
202
|
}
|
package/dist/operations.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { type AuthoritativeRelationPlan } from './authoritative-query.js';
|
|
2
|
-
import { type RemoteOperationResponse, type RowValue } from '@syncular/core';
|
|
3
|
-
import type { SyncRequestContext } from './context.js';
|
|
2
|
+
import { type RemoteOperationRequest, type RemoteOperationResponse, type RowValue } from '@syncular/core';
|
|
3
|
+
import type { ResolveScopes, SyncRequestContext } from './context.js';
|
|
4
4
|
import type { AuthoritativeQueryRequest, AuthoritativeQueryResult, AuthoritativeQueryValue } from './storage.js';
|
|
5
5
|
import { type ValidateRow } from './validate.js';
|
|
6
6
|
export interface RemoteQueryDependency {
|
|
@@ -101,5 +101,22 @@ export declare class RemoteOperationRegistry {
|
|
|
101
101
|
constructor(operations: readonly RegisteredRemoteOperation[]);
|
|
102
102
|
get(id: string): RegisteredRemoteOperation | undefined;
|
|
103
103
|
}
|
|
104
|
-
|
|
104
|
+
/**
|
|
105
|
+
* The registry and identity one remote operation request runs with
|
|
106
|
+
* (REMOTE.md §5.1). `actorId` is the commit's actor and defaults to the
|
|
107
|
+
* authenticated actor; `resolveScopes` replaces the configured resolver for
|
|
108
|
+
* this request.
|
|
109
|
+
*/
|
|
110
|
+
export interface RemoteOperationBinding {
|
|
111
|
+
readonly registry: RemoteOperationRegistry;
|
|
112
|
+
readonly actorId?: string;
|
|
113
|
+
readonly resolveScopes?: ResolveScopes;
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Chooses the binding of one decoded request (REMOTE.md §5.1). `ctx` carries
|
|
117
|
+
* the authenticated actor; the resolver runs after the client-id binding
|
|
118
|
+
* check and may throw a `SyncError` or a `ValidationRejection`.
|
|
119
|
+
*/
|
|
120
|
+
export type RemoteOperationResolver = (request: RemoteOperationRequest, ctx: SyncRequestContext) => RemoteOperationBinding | Promise<RemoteOperationBinding>;
|
|
121
|
+
export declare function handleRemoteOperation(bytes: Uint8Array, authenticated: SyncRequestContext, operations: RemoteOperationRegistry | RemoteOperationResolver): Promise<Uint8Array>;
|
|
105
122
|
export {};
|