@syncular/server 0.26.5 → 0.27.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/content-encoding.d.ts +17 -0
- package/dist/content-encoding.js +22 -0
- package/dist/context.d.ts +9 -0
- package/dist/d1-storage.d.ts +46 -0
- package/dist/d1-storage.js +12 -1
- package/dist/errors.d.ts +19 -0
- package/dist/errors.js +29 -0
- package/dist/handler.js +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/operations-realtime.js +4 -1
- package/dist/operations.js +6 -1
- package/dist/postgres-storage.d.ts +42 -0
- package/dist/postgres-storage.js +12 -1
- package/dist/pull.d.ts +46 -6
- package/dist/pull.js +129 -34
- package/dist/realtime.d.ts +3 -0
- package/dist/realtime.js +32 -26
- package/dist/relational-rows.d.ts +13 -0
- package/dist/relational-rows.js +25 -0
- package/dist/s3-segment-store.d.ts +3 -1
- package/dist/s3-segment-store.js +16 -0
- package/dist/segment-download.d.ts +16 -0
- package/dist/segment-download.js +71 -21
- package/dist/segment-store.d.ts +16 -0
- package/dist/segment-store.js +19 -0
- package/dist/sqlite-storage.d.ts +5 -0
- package/dist/sqlite-storage.js +18 -1
- package/dist/storage.d.ts +12 -0
- package/package.json +2 -2
- package/src/content-encoding.ts +35 -0
- package/src/context.ts +9 -0
- package/src/d1-storage.ts +20 -1
- package/src/errors.ts +56 -0
- package/src/handler.ts +2 -0
- package/src/index.ts +1 -0
- package/src/operations-realtime.ts +4 -1
- package/src/operations.ts +6 -1
- package/src/postgres-storage.ts +22 -1
- package/src/pull.ts +212 -39
- package/src/realtime.ts +45 -32
- package/src/relational-rows.ts +29 -0
- package/src/s3-segment-store.ts +15 -0
- package/src/segment-download.ts +129 -21
- package/src/segment-store.ts +36 -0
- package/src/sqlite-storage.ts +27 -0
- package/src/storage.ts +13 -0
package/dist/pull.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import { decodeRow, encodeRowsSegment, } from '@syncular/core';
|
|
6
6
|
import { clockOf, limitsOf } from './context.js';
|
|
7
|
+
import { compileSchema, } from './schema.js';
|
|
7
8
|
import { scopeDigest } from './scopes.js';
|
|
8
9
|
import { issueSegmentUrl } from './signed-url.js';
|
|
9
10
|
// One artifact build per owning storage pair and complete immutable identity.
|
|
@@ -153,18 +154,9 @@ function sqliteLaneEligible(limits, plan, startRowCursor) {
|
|
|
153
154
|
* bootstrap-storm rule. Returns false when the table is not eligible
|
|
154
155
|
* (the rows lane takes over).
|
|
155
156
|
*/
|
|
156
|
-
async function* sqliteImageSegment(ctx, schema, limits, plan, asOf, digest, trace, logEpoch) {
|
|
157
|
+
async function* sqliteImageSegment(ctx, schema, limits, plan, asOf, digest, trace, logEpoch, existing, probe) {
|
|
157
158
|
const { storage, segments, partition } = ctx;
|
|
158
159
|
const now = clockOf(ctx)();
|
|
159
|
-
const existing = await segments.find({
|
|
160
|
-
partition,
|
|
161
|
-
logEpoch,
|
|
162
|
-
table: plan.table.name,
|
|
163
|
-
schemaVersion: schema.version,
|
|
164
|
-
mediaType: 'sqlite',
|
|
165
|
-
scopeDigest: digest,
|
|
166
|
-
asOfCommitSeq: asOf,
|
|
167
|
-
}, now);
|
|
168
160
|
if (existing !== undefined) {
|
|
169
161
|
trace?.segments.push({
|
|
170
162
|
mediaType: 'sqlite',
|
|
@@ -178,12 +170,7 @@ async function* sqliteImageSegment(ctx, schema, limits, plan, asOf, digest, trac
|
|
|
178
170
|
}
|
|
179
171
|
// Eligibility probe (§5.3): image only when the snapshot exceeds one
|
|
180
172
|
// rows page — smaller tables stay on the (typically inline) rows lane.
|
|
181
|
-
|
|
182
|
-
table: plan.table.name,
|
|
183
|
-
scopeFilter: plan.effective,
|
|
184
|
-
afterRowId: null,
|
|
185
|
-
limit: limits.limitSnapshotRows + 1,
|
|
186
|
-
});
|
|
173
|
+
// The probe is the rows lane's first page, read by `prepareSections`.
|
|
187
174
|
if (probe.length <= limits.limitSnapshotRows)
|
|
188
175
|
return false;
|
|
189
176
|
// §5.3: building an image needs a SQLite engine. The host injects the
|
|
@@ -273,19 +260,19 @@ async function* sqliteImageSegment(ctx, schema, limits, plan, asOf, digest, trac
|
|
|
273
260
|
yield segmentRefFrame(record, digest, await signedUrlFields(ctx, limits, record.segmentId, digest, clockOf(ctx)()));
|
|
274
261
|
return true;
|
|
275
262
|
}
|
|
276
|
-
async function* bootstrapSegments(ctx, schema, limits, plan, asOf, startRowCursor, trace, logEpoch, firstPage) {
|
|
263
|
+
async function* bootstrapSegments(ctx, schema, limits, plan, asOf, startRowCursor, trace, logEpoch, firstPage, image) {
|
|
277
264
|
const { storage, segments, partition } = ctx;
|
|
278
265
|
const serverLimits = limitsOf(ctx);
|
|
279
266
|
const digest = await scopeDigest(plan.effective);
|
|
280
267
|
const now = clockOf(ctx)();
|
|
281
268
|
if (sqliteLaneEligible(limits, plan, startRowCursor)) {
|
|
282
|
-
const imaged = yield* sqliteImageSegment(ctx, schema, limits, plan, asOf, digest, trace, logEpoch);
|
|
269
|
+
const imaged = yield* sqliteImageSegment(ctx, schema, limits, plan, asOf, digest, trace, logEpoch, image, firstPage);
|
|
283
270
|
if (imaged)
|
|
284
271
|
return { complete: true, rowCursor: null };
|
|
285
272
|
}
|
|
286
273
|
let rowCursor = startRowCursor;
|
|
287
274
|
for (let page = 0; page < limits.maxSnapshotPages; page++) {
|
|
288
|
-
const scanned = page === 0
|
|
275
|
+
const scanned = page === 0
|
|
289
276
|
? firstPage
|
|
290
277
|
: await storage.scanRows(partition, {
|
|
291
278
|
table: plan.table.name,
|
|
@@ -351,7 +338,7 @@ async function* bootstrapSegments(ctx, schema, limits, plan, asOf, startRowCurso
|
|
|
351
338
|
return { complete: false, rowCursor };
|
|
352
339
|
}
|
|
353
340
|
/** Run the pre-frame storage reads of every subscription concurrently. */
|
|
354
|
-
export async function prepareSections(ctx, limits, plans, maxSeq, horizonSeq) {
|
|
341
|
+
export async function prepareSections(ctx, schema, limits, plans, maxSeq, horizonSeq, logEpoch) {
|
|
355
342
|
const reads = plans.map((plan) => {
|
|
356
343
|
const sub = plan.frame;
|
|
357
344
|
if (plan.status === 'revoked')
|
|
@@ -376,23 +363,50 @@ export async function prepareSections(ctx, limits, plans, maxSeq, horizonSeq) {
|
|
|
376
363
|
return {};
|
|
377
364
|
}
|
|
378
365
|
// Bootstrapping: the same resume decision `preparedSection` makes (§4.7).
|
|
379
|
-
const
|
|
380
|
-
? token
|
|
381
|
-
:
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
366
|
+
const resume = token !== undefined && token.asOfCommitSeq >= horizonSeq
|
|
367
|
+
? token
|
|
368
|
+
: undefined;
|
|
369
|
+
const startCursor = resume?.rowCursor ?? null;
|
|
370
|
+
const firstPage = ctx.storage.scanRows(ctx.partition, {
|
|
371
|
+
table: plan.table.name,
|
|
372
|
+
scopeFilter: plan.effective,
|
|
373
|
+
afterRowId: startCursor,
|
|
374
|
+
limit: limits.limitSnapshotRows + 1,
|
|
375
|
+
});
|
|
376
|
+
if (!sqliteLaneEligible(limits, plan, startCursor))
|
|
377
|
+
return { firstPage };
|
|
378
|
+
// §4.7: a fresh sqlite-lane bootstrap pins at the newest change in its
|
|
379
|
+
// scope (never below the horizon, never above maxCommitSeq), so a stored
|
|
380
|
+
// image stays the reuse key's match until this scope changes (§5.3).
|
|
381
|
+
const pinned = resume !== undefined
|
|
382
|
+
? Promise.resolve({ asOf: resume.asOfCommitSeq, pin: undefined })
|
|
383
|
+
: ctx.storage
|
|
384
|
+
.latestScopedChangeSeq(ctx.partition, {
|
|
386
385
|
table: plan.table.name,
|
|
387
386
|
scopeFilter: plan.effective,
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
387
|
+
})
|
|
388
|
+
.then((latest) => {
|
|
389
|
+
const pin = Math.min(maxSeq, Math.max(latest, horizonSeq));
|
|
390
|
+
return { asOf: pin, pin };
|
|
391
|
+
});
|
|
392
|
+
const lane = pinned.then(async ({ asOf, pin }) => ({
|
|
393
|
+
pin,
|
|
394
|
+
image: await ctx.segments.find({
|
|
395
|
+
partition: ctx.partition,
|
|
396
|
+
logEpoch,
|
|
397
|
+
table: plan.table.name,
|
|
398
|
+
schemaVersion: schema.version,
|
|
399
|
+
mediaType: 'sqlite',
|
|
400
|
+
scopeDigest: await scopeDigest(plan.effective),
|
|
401
|
+
asOfCommitSeq: asOf,
|
|
402
|
+
}, clockOf(ctx)()),
|
|
403
|
+
}));
|
|
404
|
+
return { firstPage, lane };
|
|
392
405
|
});
|
|
393
406
|
const settled = await Promise.all(reads.map(async (read) => ({
|
|
394
407
|
commits: read.commits === undefined ? undefined : await read.commits,
|
|
395
408
|
firstPage: await read.firstPage,
|
|
409
|
+
lane: await read.lane,
|
|
396
410
|
})));
|
|
397
411
|
// Validate continuity before committing to an active section. A prune
|
|
398
412
|
// during a paged read can otherwise make an incomplete window look empty.
|
|
@@ -403,6 +417,8 @@ export async function prepareSections(ctx, limits, plans, maxSeq, horizonSeq) {
|
|
|
403
417
|
commits: read.commits ?? [],
|
|
404
418
|
horizonSeq: read.commits === undefined ? horizonSeq : windowHorizon,
|
|
405
419
|
firstPage: read.firstPage,
|
|
420
|
+
pin: read.lane?.pin,
|
|
421
|
+
image: read.lane?.image,
|
|
406
422
|
}));
|
|
407
423
|
}
|
|
408
424
|
/**
|
|
@@ -410,7 +426,10 @@ export async function prepareSections(ctx, limits, plans, maxSeq, horizonSeq) {
|
|
|
410
426
|
* returning the cursor recorded for the retention watermark (§4.5).
|
|
411
427
|
*/
|
|
412
428
|
export async function* subscriptionSection(ctx, schema, limits, plan, maxSeq, horizonSeq, trace, logEpoch) {
|
|
413
|
-
|
|
429
|
+
if (logEpoch === undefined || logEpoch.length === 0) {
|
|
430
|
+
throw new Error('subscriptionSection requires a non-empty log epoch');
|
|
431
|
+
}
|
|
432
|
+
const [prepared] = await prepareSections(ctx, schema, limits, [plan], maxSeq, horizonSeq, logEpoch);
|
|
414
433
|
if (prepared === undefined)
|
|
415
434
|
throw new Error('prepareSections lost a plan');
|
|
416
435
|
return yield* preparedSection(ctx, schema, limits, plan, maxSeq, prepared, trace, logEpoch);
|
|
@@ -456,8 +475,11 @@ export async function* preparedSection(ctx, schema, limits, plan, maxSeq, prepar
|
|
|
456
475
|
const resume = token !== undefined && token.asOfCommitSeq >= horizonSeq
|
|
457
476
|
? token
|
|
458
477
|
: undefined;
|
|
459
|
-
const asOf = resume?.asOfCommitSeq ?? maxSeq;
|
|
478
|
+
const asOf = resume?.asOfCommitSeq ?? prepared.pin ?? maxSeq;
|
|
460
479
|
const startCursor = resume?.rowCursor ?? null;
|
|
480
|
+
if (prepared.firstPage === undefined) {
|
|
481
|
+
throw new Error('prepareSections did not read the bootstrap page');
|
|
482
|
+
}
|
|
461
483
|
yield {
|
|
462
484
|
type: 'SUB_START',
|
|
463
485
|
id: sub.id,
|
|
@@ -466,7 +488,7 @@ export async function* preparedSection(ctx, schema, limits, plan, maxSeq, prepar
|
|
|
466
488
|
effectiveScopes: plan.effective,
|
|
467
489
|
bootstrap: true,
|
|
468
490
|
};
|
|
469
|
-
const outcome = yield* bootstrapSegments(ctx, schema, limits, plan, asOf, startCursor, trace, logEpoch, prepared.firstPage);
|
|
491
|
+
const outcome = yield* bootstrapSegments(ctx, schema, limits, plan, asOf, startCursor, trace, logEpoch, prepared.firstPage, prepared.image);
|
|
470
492
|
if (outcome.complete) {
|
|
471
493
|
yield { type: 'SUB_END', nextCursor: asOf };
|
|
472
494
|
}
|
|
@@ -524,3 +546,76 @@ export async function* preparedSection(ctx, schema, limits, plan, maxSeq, prepar
|
|
|
524
546
|
yield { type: 'SUB_END', nextCursor };
|
|
525
547
|
return { nextCursor, active: true };
|
|
526
548
|
}
|
|
549
|
+
/**
|
|
550
|
+
* Publish the §5.3 sqlite image that a pull for (`table`, `scopes`) looks
|
|
551
|
+
* up, from a host with a SQLite engine (a Bun process with the production
|
|
552
|
+
* storage). It runs the pull's own bootstrap path (the §4.7 scope pin, the
|
|
553
|
+
* reuse key, the build), so the stored identity is exactly the one a
|
|
554
|
+
* serving host without a builder finds. The image stays current until a
|
|
555
|
+
* change reaches the scope; its lifetime is the segment store's TTL at
|
|
556
|
+
* publication.
|
|
557
|
+
*/
|
|
558
|
+
export async function publishSqliteImage(input) {
|
|
559
|
+
const { config, partition } = input;
|
|
560
|
+
if (config.sqliteImageBuilder === undefined) {
|
|
561
|
+
throw new Error('publishSqliteImage requires config.sqliteImageBuilder');
|
|
562
|
+
}
|
|
563
|
+
const schema = compileSchema(config.schema);
|
|
564
|
+
const table = schema.tables.get(input.table);
|
|
565
|
+
if (table === undefined) {
|
|
566
|
+
throw new Error('publishSqliteImage: the schema has no such table');
|
|
567
|
+
}
|
|
568
|
+
await config.storage.ensureSchema(schema, config.checkpoints);
|
|
569
|
+
const logEpoch = await config.storage.getPartitionLogEpoch(partition);
|
|
570
|
+
if (logEpoch === undefined) {
|
|
571
|
+
throw new Error('publishSqliteImage: the partition has no log epoch');
|
|
572
|
+
}
|
|
573
|
+
const ctx = {
|
|
574
|
+
...config,
|
|
575
|
+
partition,
|
|
576
|
+
actorId: 'syncular.image-publisher',
|
|
577
|
+
};
|
|
578
|
+
const plan = {
|
|
579
|
+
frame: {
|
|
580
|
+
type: 'SUBSCRIPTION',
|
|
581
|
+
id: 'image',
|
|
582
|
+
table: input.table,
|
|
583
|
+
scopes: input.scopes,
|
|
584
|
+
cursor: -1,
|
|
585
|
+
},
|
|
586
|
+
table,
|
|
587
|
+
status: 'active',
|
|
588
|
+
effective: input.scopes,
|
|
589
|
+
};
|
|
590
|
+
// One-row pages make every table above one row image-eligible (§5.3).
|
|
591
|
+
const limits = {
|
|
592
|
+
limitCommits: 1,
|
|
593
|
+
limitSnapshotRows: 1,
|
|
594
|
+
maxSnapshotPages: 1,
|
|
595
|
+
accept: ACCEPT_SQLITE,
|
|
596
|
+
};
|
|
597
|
+
const maxSeq = await config.storage.getMaxCommitSeq(partition);
|
|
598
|
+
const horizonSeq = await config.storage.getHorizonSeq(partition);
|
|
599
|
+
const trace = { segments: [] };
|
|
600
|
+
const [prepared] = await prepareSections(ctx, schema, limits, [plan], maxSeq, horizonSeq, logEpoch);
|
|
601
|
+
if (prepared === undefined)
|
|
602
|
+
throw new Error('prepareSections lost a plan');
|
|
603
|
+
let ref;
|
|
604
|
+
for await (const frame of preparedSection(ctx, schema, limits, plan, maxSeq, prepared, trace, logEpoch)) {
|
|
605
|
+
if (frame.type === 'SEGMENT_REF' && frame.mediaType === 'sqlite') {
|
|
606
|
+
ref = frame;
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
const image = trace.segments.find((segment) => segment.mediaType === 'sqlite');
|
|
610
|
+
if (ref === undefined || image === undefined) {
|
|
611
|
+
throw new Error('publishSqliteImage: the scope holds at most one row, which pulls serve inline');
|
|
612
|
+
}
|
|
613
|
+
return {
|
|
614
|
+
segmentId: ref.segmentId,
|
|
615
|
+
asOfCommitSeq: ref.asOfCommitSeq,
|
|
616
|
+
scopeDigest: ref.scopeDigest,
|
|
617
|
+
rowCount: ref.rowCount,
|
|
618
|
+
byteLength: ref.byteLength,
|
|
619
|
+
origin: image.origin === 'reused' ? 'reused' : 'built',
|
|
620
|
+
};
|
|
621
|
+
}
|
package/dist/realtime.d.ts
CHANGED
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
*/
|
|
19
19
|
import { type PresenceKind, type ScopeMap, type WakeReason } from '@syncular/core';
|
|
20
20
|
import type { SyncRequestContext, SyncServerConfig } from './context.js';
|
|
21
|
+
import { type SyncularErrorHandler } from './errors.js';
|
|
21
22
|
import { type SyncularServerEvents } from './events.js';
|
|
22
23
|
import type { ServerStorage, StoredCommit } from './storage.js';
|
|
23
24
|
/**
|
|
@@ -198,6 +199,8 @@ export declare class RealtimeHub {
|
|
|
198
199
|
readonly partition: string;
|
|
199
200
|
readonly actorId: string;
|
|
200
201
|
}): SyncRequestContext;
|
|
202
|
+
/** The host's error reporter (`SyncServerConfig.onError`), if any. */
|
|
203
|
+
get onError(): SyncularErrorHandler | undefined;
|
|
201
204
|
requestContext(session: RealtimeSession): SyncRequestContext;
|
|
202
205
|
/**
|
|
203
206
|
* Register a connected socket (§8.1): load the client's last pull's
|
package/dist/realtime.js
CHANGED
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
*/
|
|
19
19
|
import { DecodeError, decodeMessage, encodeMessage, encodePresenceError, encodePresenceFanout, MessageStreamScanner, PROTOCOL_WIRE_VERSION, parseRealtimePresencePublish, REALTIME_TAG_DELTA, REALTIME_TAG_ROUND, } from '@syncular/core';
|
|
20
20
|
import { REMOTE_COMMAND_CLIENT_ID_PREFIX, RESOLVER_OUTAGE, touchAuthenticatedPartition, } from './context.js';
|
|
21
|
-
import { SyncError, syncError } from './errors.js';
|
|
21
|
+
import { adapterSyncError, reportError, SyncError, syncError, } from './errors.js';
|
|
22
22
|
import { emitEvent } from './events.js';
|
|
23
23
|
import { createSyncResponseStream } from './handler.js';
|
|
24
24
|
import { compileSchema } from './schema.js';
|
|
@@ -430,16 +430,15 @@ export class RealtimeSession {
|
|
|
430
430
|
}
|
|
431
431
|
catch (error) {
|
|
432
432
|
// §8.7 failures: what HTTP reports as status+JSON becomes a
|
|
433
|
-
// minimal RESP_HEADER/ERROR/END response stream on the socket.
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
throw error;
|
|
433
|
+
// minimal RESP_HEADER/ERROR/END response stream on the socket. An
|
|
434
|
+
// exception outside the catalog is reported and answers
|
|
435
|
+
// `sync.internal_error`, exactly like the HTTP adapter's 500.
|
|
436
|
+
const sync = error instanceof DecodeError
|
|
437
|
+
? syncError(error.code, error.message)
|
|
438
|
+
: adapterSyncError(error, this.#hub.onError, 'realtime');
|
|
439
|
+
finishRound(); // END is in this one chunk
|
|
440
|
+
await this.#sendRoundChunk(errorResponseBytes(sync, this.wireVersion, this.logEpoch));
|
|
441
|
+
return;
|
|
443
442
|
}
|
|
444
443
|
// Fetch-ahead so the round stops being "in flight" the moment the
|
|
445
444
|
// chunk carrying END is handed to send: the client may legally
|
|
@@ -451,20 +450,19 @@ export class RealtimeSession {
|
|
|
451
450
|
step = await iterator.next();
|
|
452
451
|
}
|
|
453
452
|
catch (error) {
|
|
454
|
-
// The
|
|
455
|
-
// `next()`, before any chunk is yielded
|
|
456
|
-
//
|
|
457
|
-
//
|
|
458
|
-
//
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
throw error;
|
|
453
|
+
// The generator builds the whole response (and runs the read-verify
|
|
454
|
+
// refusal) on its first `next()`, before any chunk is yielded.
|
|
455
|
+
// Nothing has escaped, so a failure there is delivered as the same
|
|
456
|
+
// §8.4 ERROR frame HTTP returns rather than a silent close; a
|
|
457
|
+
// non-catalog exception is reported and answers
|
|
458
|
+
// `sync.internal_error`. A throw after the first chunk still takes
|
|
459
|
+
// the §8.7 violation path.
|
|
460
|
+
const sync = error instanceof DecodeError
|
|
461
|
+
? syncError(error.code, error.message)
|
|
462
|
+
: adapterSyncError(error, this.#hub.onError, 'realtime');
|
|
463
|
+
finishRound();
|
|
464
|
+
await this.#sendRoundChunk(errorResponseBytes(sync, this.wireVersion, this.logEpoch));
|
|
465
|
+
return;
|
|
468
466
|
}
|
|
469
467
|
while (!step.done) {
|
|
470
468
|
const chunk = step.value;
|
|
@@ -474,9 +472,10 @@ export class RealtimeSession {
|
|
|
474
472
|
await this.#sendRoundChunk(chunk);
|
|
475
473
|
}
|
|
476
474
|
}
|
|
477
|
-
catch {
|
|
475
|
+
catch (error) {
|
|
478
476
|
// A host failure mid-stream leaves the byte stream unfinishable —
|
|
479
477
|
// fail loud, drop the connection (§8.7 / §1.4 abort rule).
|
|
478
|
+
reportError(error, this.#hub.onError, 'realtime');
|
|
480
479
|
this.#violation('sync round failed mid-stream');
|
|
481
480
|
}
|
|
482
481
|
finally {
|
|
@@ -851,9 +850,16 @@ export class RealtimeHub {
|
|
|
851
850
|
...(this.#config.events !== undefined
|
|
852
851
|
? { events: this.#config.events }
|
|
853
852
|
: {}),
|
|
853
|
+
...(this.#config.onError !== undefined
|
|
854
|
+
? { onError: this.#config.onError }
|
|
855
|
+
: {}),
|
|
854
856
|
realtime: this,
|
|
855
857
|
};
|
|
856
858
|
}
|
|
859
|
+
/** The host's error reporter (`SyncServerConfig.onError`), if any. */
|
|
860
|
+
get onError() {
|
|
861
|
+
return this.#config.onError;
|
|
862
|
+
}
|
|
857
863
|
requestContext(session) {
|
|
858
864
|
return this.requestContextFor(session);
|
|
859
865
|
}
|
|
@@ -255,6 +255,19 @@ export declare const COMMIT_WINDOW_BATCH_SQL = "SELECT s.i AS sub, c.commit_seq
|
|
|
255
255
|
* tombstone. Params: [partition, rowIds as JSON array text, tbl].
|
|
256
256
|
*/
|
|
257
257
|
export declare function prefetchRowsSql(table: CompiledTable): string;
|
|
258
|
+
/**
|
|
259
|
+
* SQLite/D1: the newest `commit_seq` in the inverted change-scope index for
|
|
260
|
+
* one variable's values, as one index-only max per value (a `(partition,
|
|
261
|
+
* tbl, var, value)` prefix), so the cost is independent of the table's
|
|
262
|
+
* history. Bind order: [partition, tbl, var, value] per value.
|
|
263
|
+
*/
|
|
264
|
+
export declare function latestScopedChangeSeqSql(valueCount: number): string;
|
|
265
|
+
/**
|
|
266
|
+
* PostgreSQL set-based form of `latestScopedChangeSeqSql` for the
|
|
267
|
+
* subscriptions of one table in a pull. `$2` is a JSON array of
|
|
268
|
+
* `{i, var, vals}` records. Params: [partition, specs (JSON text), tbl].
|
|
269
|
+
*/
|
|
270
|
+
export declare const LATEST_SCOPED_CHANGE_BATCH_SQL = "SELECT s.i AS sub,\n (SELECT max(latest.seq) FROM (\n SELECT (SELECT max(commit_seq) FROM sync_change_scopes\n WHERE partition=$1 AND tbl=$3 AND var=s.var AND value=v.value) AS seq\n FROM jsonb_array_elements_text(s.vals) AS v(value)) AS latest) AS seq\n FROM jsonb_to_recordset($2::text::jsonb) AS s(i int, var text, vals jsonb)\n ORDER BY s.i";
|
|
258
271
|
/** SELECT a row's version + scope map (admin/blob authz). Params: [partition, rowId]. */
|
|
259
272
|
export declare function selectRowScopesSql(table: CompiledTable, dialect: RelationalDialect): string;
|
|
260
273
|
/** DELETE one stored row. Params: [partition, rowId]. */
|
package/dist/relational-rows.js
CHANGED
|
@@ -606,6 +606,31 @@ export function prefetchRowsSql(table) {
|
|
|
606
606
|
LEFT JOIN sync_tombstones t
|
|
607
607
|
ON t.partition=$1 AND t.tbl=$3 AND t.row_id=ids.id`;
|
|
608
608
|
}
|
|
609
|
+
/**
|
|
610
|
+
* SQLite/D1: the newest `commit_seq` in the inverted change-scope index for
|
|
611
|
+
* one variable's values, as one index-only max per value (a `(partition,
|
|
612
|
+
* tbl, var, value)` prefix), so the cost is independent of the table's
|
|
613
|
+
* history. Bind order: [partition, tbl, var, value] per value.
|
|
614
|
+
*/
|
|
615
|
+
export function latestScopedChangeSeqSql(valueCount) {
|
|
616
|
+
const perValue = [];
|
|
617
|
+
for (let i = 0; i < valueCount; i++) {
|
|
618
|
+
perValue.push('SELECT (SELECT max(commit_seq) FROM sync_change_scopes WHERE partition=? AND tbl=? AND var=? AND value=?) AS seq');
|
|
619
|
+
}
|
|
620
|
+
return `SELECT max(seq) AS seq FROM (${perValue.join(' UNION ALL ')})`;
|
|
621
|
+
}
|
|
622
|
+
/**
|
|
623
|
+
* PostgreSQL set-based form of `latestScopedChangeSeqSql` for the
|
|
624
|
+
* subscriptions of one table in a pull. `$2` is a JSON array of
|
|
625
|
+
* `{i, var, vals}` records. Params: [partition, specs (JSON text), tbl].
|
|
626
|
+
*/
|
|
627
|
+
export const LATEST_SCOPED_CHANGE_BATCH_SQL = `SELECT s.i AS sub,
|
|
628
|
+
(SELECT max(latest.seq) FROM (
|
|
629
|
+
SELECT (SELECT max(commit_seq) FROM sync_change_scopes
|
|
630
|
+
WHERE partition=$1 AND tbl=$3 AND var=s.var AND value=v.value) AS seq
|
|
631
|
+
FROM jsonb_array_elements_text(s.vals) AS v(value)) AS latest) AS seq
|
|
632
|
+
FROM jsonb_to_recordset($2::text::jsonb) AS s(i int, var text, vals jsonb)
|
|
633
|
+
ORDER BY s.i`;
|
|
609
634
|
/** SELECT a row's version + scope map (admin/blob authz). Params: [partition, rowId]. */
|
|
610
635
|
export function selectRowScopesSql(table, dialect) {
|
|
611
636
|
const p = dialect === 'sqlite' ? ['?', '?'] : ['$1', '$2'];
|
|
@@ -42,7 +42,7 @@
|
|
|
42
42
|
* precise, retryable `sync.segment_expired` and hit `sync.not_found` only
|
|
43
43
|
* long after. Never set lifecycle below `ttlMs`.
|
|
44
44
|
*/
|
|
45
|
-
import type { SegmentFindKey, SegmentMetadata, SegmentRecord, SegmentStore, SegmentStoreStats } from './segment-store.js';
|
|
45
|
+
import type { SegmentFindKey, SegmentMetadata, SegmentRecord, SegmentStore, SegmentStoreStats, SegmentStream } from './segment-store.js';
|
|
46
46
|
import type { DelegatedPresignConfig, SegmentUrlIssue } from './signed-url.js';
|
|
47
47
|
export interface S3SegmentStoreConfig {
|
|
48
48
|
/**
|
|
@@ -75,6 +75,8 @@ export declare class S3SegmentStore implements SegmentStore {
|
|
|
75
75
|
record: SegmentRecord;
|
|
76
76
|
bytes: Uint8Array;
|
|
77
77
|
} | undefined>;
|
|
78
|
+
/** The record plus the object GET's body, never buffered (see `get`). */
|
|
79
|
+
open(segmentId: string): Promise<SegmentStream | undefined>;
|
|
78
80
|
find(key: SegmentFindKey, nowMs: number): Promise<SegmentRecord | undefined>;
|
|
79
81
|
/**
|
|
80
82
|
* Store-wide counters from the pointer-object accumulator (never a LIST).
|
package/dist/s3-segment-store.js
CHANGED
|
@@ -450,6 +450,22 @@ export class S3SegmentStore {
|
|
|
450
450
|
bytes: new Uint8Array(await response.arrayBuffer()),
|
|
451
451
|
};
|
|
452
452
|
}
|
|
453
|
+
/** The record plus the object GET's body, never buffered (see `get`). */
|
|
454
|
+
async open(segmentId) {
|
|
455
|
+
if (!SEGMENT_ID_PATTERN.test(segmentId))
|
|
456
|
+
return undefined;
|
|
457
|
+
const record = await this.#readRecord(segmentId);
|
|
458
|
+
if (record === undefined)
|
|
459
|
+
return undefined;
|
|
460
|
+
const response = await this.#request('GET', this.objectKeyFor(segmentId));
|
|
461
|
+
if (response === undefined || response.body === null)
|
|
462
|
+
return undefined;
|
|
463
|
+
return {
|
|
464
|
+
record: record.record,
|
|
465
|
+
body: response.body,
|
|
466
|
+
byteLength: record.record.byteLength,
|
|
467
|
+
};
|
|
468
|
+
}
|
|
453
469
|
async find(key, nowMs) {
|
|
454
470
|
const response = await this.#request('GET', await this.#findKeyFor(key));
|
|
455
471
|
if (response === undefined)
|
|
@@ -11,4 +11,20 @@ export interface SegmentDownloadResult {
|
|
|
11
11
|
/** Response headers per §5.5. */
|
|
12
12
|
readonly headers: Record<string, string>;
|
|
13
13
|
}
|
|
14
|
+
/** `openSegmentDownload`: the authorized segment as a byte stream. */
|
|
15
|
+
export interface SegmentStreamResult {
|
|
16
|
+
readonly record: SegmentRecord;
|
|
17
|
+
readonly body: ReadableStream<Uint8Array<ArrayBuffer>>;
|
|
18
|
+
readonly byteLength: number;
|
|
19
|
+
/** Response headers per §5.5. */
|
|
20
|
+
readonly headers: Record<string, string>;
|
|
21
|
+
}
|
|
14
22
|
export declare function handleSegmentDownload(ctx: SyncRequestContext, request: SegmentDownloadRequest): Promise<SegmentDownloadResult>;
|
|
23
|
+
/**
|
|
24
|
+
* The §5.5 download as a stream: the same authorization as
|
|
25
|
+
* `handleSegmentDownload`, with the bytes read through `SegmentStore.open`
|
|
26
|
+
* when the store provides it, so a host with bounded memory (a Worker)
|
|
27
|
+
* relays a large sqlite image without buffering it. A store without `open`
|
|
28
|
+
* serves its `get` bytes as a one-chunk stream.
|
|
29
|
+
*/
|
|
30
|
+
export declare function openSegmentDownload(ctx: SyncRequestContext, request: SegmentDownloadRequest): Promise<SegmentStreamResult>;
|
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
|
}
|