@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/pull.d.ts
CHANGED
|
@@ -3,9 +3,10 @@
|
|
|
3
3
|
* (SPEC.md §4, §5).
|
|
4
4
|
*/
|
|
5
5
|
import { type PullHeaderFrame, type ResponseFrame, type ScopeMap, type SubscriptionFrame } from '@syncular/core';
|
|
6
|
-
import type { SyncRequestContext } from './context.js';
|
|
6
|
+
import type { SyncRequestContext, SyncServerConfig } from './context.js';
|
|
7
7
|
import type { PullSegmentSummary } from './events.js';
|
|
8
|
-
import type
|
|
8
|
+
import { type CompiledSchema, type CompiledTable } from './schema.js';
|
|
9
|
+
import type { SegmentRecord } from './segment-store.js';
|
|
9
10
|
import type { StoredCommit, StoredRow } from './storage.js';
|
|
10
11
|
/** §4.2 accept bitmask. */
|
|
11
12
|
export declare const ACCEPT_INLINE_ROWS: number;
|
|
@@ -41,19 +42,26 @@ export interface PullSectionTrace {
|
|
|
41
42
|
/**
|
|
42
43
|
* The storage reads a section needs before its first frame. A pull starts
|
|
43
44
|
* them for every subscription before awaiting any, so a storage that batches
|
|
44
|
-
* concurrent page reads (PostgreSQL) answers all subscriptions of one
|
|
45
|
-
* with one statement, and the horizon re-check runs once per pull.
|
|
45
|
+
* concurrent page reads (PostgreSQL, D1) answers all subscriptions of one
|
|
46
|
+
* table with one statement, and the horizon re-check runs once per pull.
|
|
46
47
|
*/
|
|
47
48
|
export interface PreparedSection {
|
|
48
49
|
/** §4.5 window read for an incremental cursor; empty when none was read. */
|
|
49
50
|
readonly commits: StoredCommit[];
|
|
50
51
|
/** Horizon to judge the cursor against, re-read after any window read. */
|
|
51
52
|
readonly horizonSeq: number;
|
|
52
|
-
/**
|
|
53
|
+
/**
|
|
54
|
+
* A bootstrap's first snapshot page (`limitSnapshotRows + 1` rows from the
|
|
55
|
+
* resume cursor), which is also the §5.3 image eligibility probe.
|
|
56
|
+
*/
|
|
53
57
|
readonly firstPage: StoredRow[] | undefined;
|
|
58
|
+
/** §4.7 pin of a fresh bootstrap; absent ⇒ `maxCommitSeq`. */
|
|
59
|
+
readonly pin: number | undefined;
|
|
60
|
+
/** §5.3 stored image for a sqlite-lane bootstrap's reuse key, if any. */
|
|
61
|
+
readonly image: SegmentRecord | undefined;
|
|
54
62
|
}
|
|
55
63
|
/** Run the pre-frame storage reads of every subscription concurrently. */
|
|
56
|
-
export declare function prepareSections(ctx: SyncRequestContext, limits: PullLimits, plans: readonly SubscriptionPlan[], maxSeq: number, horizonSeq: number): Promise<PreparedSection[]>;
|
|
64
|
+
export declare function prepareSections(ctx: SyncRequestContext, schema: CompiledSchema, limits: PullLimits, plans: readonly SubscriptionPlan[], maxSeq: number, horizonSeq: number, logEpoch: string): Promise<PreparedSection[]>;
|
|
57
65
|
/**
|
|
58
66
|
* Produce the `SUB_START … SUB_END` section for one subscription (§1.6),
|
|
59
67
|
* returning the cursor recorded for the retention watermark (§4.5).
|
|
@@ -61,3 +69,35 @@ export declare function prepareSections(ctx: SyncRequestContext, limits: PullLim
|
|
|
61
69
|
export declare function subscriptionSection(ctx: SyncRequestContext, schema: CompiledSchema, limits: PullLimits, plan: SubscriptionPlan, maxSeq: number, horizonSeq: number, trace?: PullSectionTrace, logEpoch?: string): AsyncGenerator<ResponseFrame, SubscriptionResult>;
|
|
62
70
|
/** The section of one subscription whose reads `prepareSections` ran. */
|
|
63
71
|
export declare function preparedSection(ctx: SyncRequestContext, schema: CompiledSchema, limits: PullLimits, plan: SubscriptionPlan, maxSeq: number, prepared: PreparedSection, trace?: PullSectionTrace, logEpoch?: string): AsyncGenerator<ResponseFrame, SubscriptionResult>;
|
|
72
|
+
export interface PublishSqliteImageInput {
|
|
73
|
+
/** The sync config the serving host runs, plus a `sqliteImageBuilder`. */
|
|
74
|
+
readonly config: SyncServerConfig;
|
|
75
|
+
readonly partition: string;
|
|
76
|
+
readonly table: string;
|
|
77
|
+
/**
|
|
78
|
+
* The effective scopes the subscribing clients hold for `table` (§3.2):
|
|
79
|
+
* the reuse key carries their digest, so publish once per distinct scope
|
|
80
|
+
* set that clients subscribe with.
|
|
81
|
+
*/
|
|
82
|
+
readonly scopes: ScopeMap;
|
|
83
|
+
}
|
|
84
|
+
export interface PublishedSqliteImage {
|
|
85
|
+
readonly segmentId: string;
|
|
86
|
+
/** The §4.7 scope pin the image is stored under. */
|
|
87
|
+
readonly asOfCommitSeq: number;
|
|
88
|
+
readonly scopeDigest: string;
|
|
89
|
+
readonly rowCount: number;
|
|
90
|
+
readonly byteLength: number;
|
|
91
|
+
/** `reused` when an unexpired image for the same reuse key already existed. */
|
|
92
|
+
readonly origin: 'built' | 'reused';
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Publish the §5.3 sqlite image that a pull for (`table`, `scopes`) looks
|
|
96
|
+
* up, from a host with a SQLite engine (a Bun process with the production
|
|
97
|
+
* storage). It runs the pull's own bootstrap path (the §4.7 scope pin, the
|
|
98
|
+
* reuse key, the build), so the stored identity is exactly the one a
|
|
99
|
+
* serving host without a builder finds. The image stays current until a
|
|
100
|
+
* change reaches the scope; its lifetime is the segment store's TTL at
|
|
101
|
+
* publication.
|
|
102
|
+
*/
|
|
103
|
+
export declare function publishSqliteImage(input: PublishSqliteImageInput): Promise<PublishedSqliteImage>;
|
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/push.js
CHANGED
|
@@ -420,8 +420,12 @@ async function applyOperation(tx, schema, resolved, op, opIndex, blobCtx, crdtMe
|
|
|
420
420
|
const insertReject = await runValidator(validators, table, 'upsert', op.rowId, values, undefined, opIndex, tx, partition, actorId);
|
|
421
421
|
if (insertReject !== undefined)
|
|
422
422
|
return insertReject;
|
|
423
|
-
// §5.2: an explicit insert recreates — the tombstone goes with it.
|
|
424
|
-
|
|
423
|
+
// §5.2: an explicit insert recreates — the tombstone goes with it. An
|
|
424
|
+
// unversioned insert already proved above, under the partition lock, that
|
|
425
|
+
// no tombstone exists (this commit writes its own only in `appendCommit`).
|
|
426
|
+
if (op.baseVersion !== undefined) {
|
|
427
|
+
await tx.clearTombstone(op.table, op.rowId);
|
|
428
|
+
}
|
|
425
429
|
const newRow = {
|
|
426
430
|
rowId: op.rowId,
|
|
427
431
|
serverVersion: 1,
|
|
@@ -915,6 +919,19 @@ export async function processPushOperationsWithTrace(ctx, schema, resolved, clie
|
|
|
915
919
|
const stagedWrites = [];
|
|
916
920
|
let terminated;
|
|
917
921
|
const operations = await buildOperations(tx);
|
|
922
|
+
if (tx.prefetchRows !== undefined) {
|
|
923
|
+
const targets = new Map();
|
|
924
|
+
for (const op of operations) {
|
|
925
|
+
if (!schema.tables.has(op.table))
|
|
926
|
+
continue;
|
|
927
|
+
const ids = targets.get(op.table) ?? [];
|
|
928
|
+
ids.push(op.rowId);
|
|
929
|
+
targets.set(op.table, ids);
|
|
930
|
+
}
|
|
931
|
+
for (const [table, rowIds] of targets) {
|
|
932
|
+
await tx.prefetchRows(table, rowIds);
|
|
933
|
+
}
|
|
934
|
+
}
|
|
918
935
|
for (let opIndex = 0; opIndex < operations.length; opIndex++) {
|
|
919
936
|
const op = operations[opIndex];
|
|
920
937
|
if (op === undefined)
|
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
|
}
|
|
@@ -249,6 +249,25 @@ export declare function scanRowBatchSql(table: CompiledTable): string;
|
|
|
249
249
|
* `scanRowBatchSql`.
|
|
250
250
|
*/
|
|
251
251
|
export declare const COMMIT_WINDOW_BATCH_SQL = "SELECT s.i AS sub, c.commit_seq AS commit_seq, m.actor_id AS actor_id, m.created_at_ms AS created_at_ms,\n ch.tbl AS tbl, ch.row_id AS row_id, ch.op AS op, ch.row_version AS row_version, ch.scopes AS scopes, ch.payload AS payload\n FROM jsonb_to_recordset($2::text::jsonb) AS s(i int, var text, vals text[], after bigint, through bigint, lim int)\n CROSS JOIN LATERAL (SELECT DISTINCT commit_seq FROM sync_change_scopes\n WHERE partition=$1 AND tbl=$3 AND var=s.var AND value=ANY(s.vals)\n AND commit_seq>s.after AND commit_seq<=s.through\n ORDER BY commit_seq LIMIT s.lim) c\n LEFT JOIN sync_commits m\n ON m.partition=$1 AND m.commit_seq=c.commit_seq\n LEFT JOIN sync_changes ch\n ON ch.partition=$1 AND ch.commit_seq=c.commit_seq AND ch.tbl=$3\n ORDER BY s.i, c.commit_seq, ch.idx";
|
|
252
|
+
/**
|
|
253
|
+
* PostgreSQL push read-ahead: every requested row id with its stored row
|
|
254
|
+
* (NULL columns when absent, `selectRowSql` shape) and its §5 delete
|
|
255
|
+
* tombstone. Params: [partition, rowIds as JSON array text, tbl].
|
|
256
|
+
*/
|
|
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";
|
|
252
271
|
/** SELECT a row's version + scope map (admin/blob authz). Params: [partition, rowId]. */
|
|
253
272
|
export declare function selectRowScopesSql(table: CompiledTable, dialect: RelationalDialect): string;
|
|
254
273
|
/** DELETE one stored row. Params: [partition, rowId]. */
|
package/dist/relational-rows.js
CHANGED
|
@@ -593,6 +593,44 @@ export const COMMIT_WINDOW_BATCH_SQL = `SELECT s.i AS sub, c.commit_seq AS commi
|
|
|
593
593
|
LEFT JOIN sync_changes ch
|
|
594
594
|
ON ch.partition=$1 AND ch.commit_seq=c.commit_seq AND ch.tbl=$3
|
|
595
595
|
ORDER BY s.i, c.commit_seq, ch.idx`;
|
|
596
|
+
/**
|
|
597
|
+
* PostgreSQL push read-ahead: every requested row id with its stored row
|
|
598
|
+
* (NULL columns when absent, `selectRowSql` shape) and its §5 delete
|
|
599
|
+
* tombstone. Params: [partition, rowIds as JSON array text, tbl].
|
|
600
|
+
*/
|
|
601
|
+
export function prefetchRowsSql(table) {
|
|
602
|
+
return `SELECT ids.id AS lookup_id, r.${quoteIdent(SYNC_ROW_ID_COLUMN)} AS row_id, r.${quoteIdent(SYNC_VERSION_COLUMN)} AS server_version, r.${quoteIdent(SYNC_SCOPES_COLUMN)} AS scopes, r.${quoteIdent(SYNC_PAYLOAD_COLUMN)} AS payload, r.${quoteIdent(SYNC_COLUMN_VERSIONS_COLUMN)} AS column_versions, t.commit_seq AS tombstone_seq
|
|
603
|
+
FROM jsonb_array_elements_text($2::text::jsonb) AS ids(id)
|
|
604
|
+
LEFT JOIN ${quoteIdent(table.name)} r
|
|
605
|
+
ON r.${quoteIdent(SYNC_PARTITION_COLUMN)}=$1 AND r.${quoteIdent(SYNC_ROW_ID_COLUMN)}=ids.id
|
|
606
|
+
LEFT JOIN sync_tombstones t
|
|
607
|
+
ON t.partition=$1 AND t.tbl=$3 AND t.row_id=ids.id`;
|
|
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`;
|
|
596
634
|
/** SELECT a row's version + scope map (admin/blob authz). Params: [partition, rowId]. */
|
|
597
635
|
export function selectRowScopesSql(table, dialect) {
|
|
598
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>;
|