@syncular/server 0.26.5 → 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 +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/src/pull.ts
CHANGED
|
@@ -9,13 +9,18 @@ import {
|
|
|
9
9
|
type PullHeaderFrame,
|
|
10
10
|
type ResponseFrame,
|
|
11
11
|
type ScopeMap,
|
|
12
|
+
type SegmentRefFrame,
|
|
12
13
|
type SegmentRow,
|
|
13
14
|
type SubscriptionFrame,
|
|
14
15
|
} from '@syncular/core';
|
|
15
|
-
import type { SyncRequestContext } from './context';
|
|
16
|
+
import type { SyncRequestContext, SyncServerConfig } from './context';
|
|
16
17
|
import { clockOf, limitsOf } from './context';
|
|
17
18
|
import type { PullSegmentSummary } from './events';
|
|
18
|
-
import
|
|
19
|
+
import {
|
|
20
|
+
type CompiledSchema,
|
|
21
|
+
type CompiledTable,
|
|
22
|
+
compileSchema,
|
|
23
|
+
} from './schema';
|
|
19
24
|
import { scopeDigest } from './scopes';
|
|
20
25
|
import type { SegmentRecord, SegmentStore } from './segment-store';
|
|
21
26
|
import { issueSegmentUrl } from './signed-url';
|
|
@@ -244,21 +249,11 @@ async function* sqliteImageSegment(
|
|
|
244
249
|
digest: string,
|
|
245
250
|
trace: PullSectionTrace | undefined,
|
|
246
251
|
logEpoch: string,
|
|
252
|
+
existing: SegmentRecord | undefined,
|
|
253
|
+
probe: StoredRow[],
|
|
247
254
|
): AsyncGenerator<ResponseFrame, boolean> {
|
|
248
255
|
const { storage, segments, partition } = ctx;
|
|
249
256
|
const now = clockOf(ctx)();
|
|
250
|
-
const existing = await segments.find(
|
|
251
|
-
{
|
|
252
|
-
partition,
|
|
253
|
-
logEpoch,
|
|
254
|
-
table: plan.table.name,
|
|
255
|
-
schemaVersion: schema.version,
|
|
256
|
-
mediaType: 'sqlite',
|
|
257
|
-
scopeDigest: digest,
|
|
258
|
-
asOfCommitSeq: asOf,
|
|
259
|
-
},
|
|
260
|
-
now,
|
|
261
|
-
);
|
|
262
257
|
if (existing !== undefined) {
|
|
263
258
|
trace?.segments.push({
|
|
264
259
|
mediaType: 'sqlite',
|
|
@@ -276,12 +271,7 @@ async function* sqliteImageSegment(
|
|
|
276
271
|
}
|
|
277
272
|
// Eligibility probe (§5.3): image only when the snapshot exceeds one
|
|
278
273
|
// rows page — smaller tables stay on the (typically inline) rows lane.
|
|
279
|
-
|
|
280
|
-
table: plan.table.name,
|
|
281
|
-
scopeFilter: plan.effective,
|
|
282
|
-
afterRowId: null,
|
|
283
|
-
limit: limits.limitSnapshotRows + 1,
|
|
284
|
-
});
|
|
274
|
+
// The probe is the rows lane's first page, read by `prepareSections`.
|
|
285
275
|
if (probe.length <= limits.limitSnapshotRows) return false;
|
|
286
276
|
// §5.3: building an image needs a SQLite engine. The host injects the
|
|
287
277
|
// builder through `sqliteImageBuilder`; when omitted we default to the
|
|
@@ -389,7 +379,8 @@ async function* bootstrapSegments(
|
|
|
389
379
|
startRowCursor: string | null,
|
|
390
380
|
trace: PullSectionTrace | undefined,
|
|
391
381
|
logEpoch: string,
|
|
392
|
-
firstPage: StoredRow[]
|
|
382
|
+
firstPage: StoredRow[],
|
|
383
|
+
image: SegmentRecord | undefined,
|
|
393
384
|
): AsyncGenerator<
|
|
394
385
|
ResponseFrame,
|
|
395
386
|
{ complete: boolean; rowCursor: string | null }
|
|
@@ -408,13 +399,15 @@ async function* bootstrapSegments(
|
|
|
408
399
|
digest,
|
|
409
400
|
trace,
|
|
410
401
|
logEpoch,
|
|
402
|
+
image,
|
|
403
|
+
firstPage,
|
|
411
404
|
);
|
|
412
405
|
if (imaged) return { complete: true, rowCursor: null };
|
|
413
406
|
}
|
|
414
407
|
let rowCursor = startRowCursor;
|
|
415
408
|
for (let page = 0; page < limits.maxSnapshotPages; page++) {
|
|
416
409
|
const scanned =
|
|
417
|
-
page === 0
|
|
410
|
+
page === 0
|
|
418
411
|
? firstPage
|
|
419
412
|
: await storage.scanRows(partition, {
|
|
420
413
|
table: plan.table.name,
|
|
@@ -492,27 +485,45 @@ async function* bootstrapSegments(
|
|
|
492
485
|
/**
|
|
493
486
|
* The storage reads a section needs before its first frame. A pull starts
|
|
494
487
|
* them for every subscription before awaiting any, so a storage that batches
|
|
495
|
-
* concurrent page reads (PostgreSQL) answers all subscriptions of one
|
|
496
|
-
* with one statement, and the horizon re-check runs once per pull.
|
|
488
|
+
* concurrent page reads (PostgreSQL, D1) answers all subscriptions of one
|
|
489
|
+
* table with one statement, and the horizon re-check runs once per pull.
|
|
497
490
|
*/
|
|
498
491
|
export interface PreparedSection {
|
|
499
492
|
/** §4.5 window read for an incremental cursor; empty when none was read. */
|
|
500
493
|
readonly commits: StoredCommit[];
|
|
501
494
|
/** Horizon to judge the cursor against, re-read after any window read. */
|
|
502
495
|
readonly horizonSeq: number;
|
|
503
|
-
/**
|
|
496
|
+
/**
|
|
497
|
+
* A bootstrap's first snapshot page (`limitSnapshotRows + 1` rows from the
|
|
498
|
+
* resume cursor), which is also the §5.3 image eligibility probe.
|
|
499
|
+
*/
|
|
504
500
|
readonly firstPage: StoredRow[] | undefined;
|
|
501
|
+
/** §4.7 pin of a fresh bootstrap; absent ⇒ `maxCommitSeq`. */
|
|
502
|
+
readonly pin: number | undefined;
|
|
503
|
+
/** §5.3 stored image for a sqlite-lane bootstrap's reuse key, if any. */
|
|
504
|
+
readonly image: SegmentRecord | undefined;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
interface PendingSection {
|
|
508
|
+
readonly commits?: Promise<StoredCommit[]>;
|
|
509
|
+
readonly firstPage?: Promise<StoredRow[]>;
|
|
510
|
+
readonly lane?: Promise<{
|
|
511
|
+
readonly pin: number | undefined;
|
|
512
|
+
readonly image: SegmentRecord | undefined;
|
|
513
|
+
}>;
|
|
505
514
|
}
|
|
506
515
|
|
|
507
516
|
/** Run the pre-frame storage reads of every subscription concurrently. */
|
|
508
517
|
export async function prepareSections(
|
|
509
518
|
ctx: SyncRequestContext,
|
|
519
|
+
schema: CompiledSchema,
|
|
510
520
|
limits: PullLimits,
|
|
511
521
|
plans: readonly SubscriptionPlan[],
|
|
512
522
|
maxSeq: number,
|
|
513
523
|
horizonSeq: number,
|
|
524
|
+
logEpoch: string,
|
|
514
525
|
): Promise<PreparedSection[]> {
|
|
515
|
-
const reads = plans.map((plan) => {
|
|
526
|
+
const reads = plans.map((plan): PendingSection => {
|
|
516
527
|
const sub = plan.frame;
|
|
517
528
|
if (plan.status === 'revoked') return {};
|
|
518
529
|
const token = parseBootstrapToken(sub.bootstrapState, sub.table);
|
|
@@ -537,24 +548,55 @@ export async function prepareSections(
|
|
|
537
548
|
return {};
|
|
538
549
|
}
|
|
539
550
|
// Bootstrapping: the same resume decision `preparedSection` makes (§4.7).
|
|
540
|
-
const
|
|
551
|
+
const resume =
|
|
541
552
|
token !== undefined && token.asOfCommitSeq >= horizonSeq
|
|
542
|
-
? token
|
|
543
|
-
:
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
+
? token
|
|
554
|
+
: undefined;
|
|
555
|
+
const startCursor = resume?.rowCursor ?? null;
|
|
556
|
+
const firstPage = ctx.storage.scanRows(ctx.partition, {
|
|
557
|
+
table: plan.table.name,
|
|
558
|
+
scopeFilter: plan.effective,
|
|
559
|
+
afterRowId: startCursor,
|
|
560
|
+
limit: limits.limitSnapshotRows + 1,
|
|
561
|
+
});
|
|
562
|
+
if (!sqliteLaneEligible(limits, plan, startCursor)) return { firstPage };
|
|
563
|
+
// §4.7: a fresh sqlite-lane bootstrap pins at the newest change in its
|
|
564
|
+
// scope (never below the horizon, never above maxCommitSeq), so a stored
|
|
565
|
+
// image stays the reuse key's match until this scope changes (§5.3).
|
|
566
|
+
const pinned =
|
|
567
|
+
resume !== undefined
|
|
568
|
+
? Promise.resolve({ asOf: resume.asOfCommitSeq, pin: undefined })
|
|
569
|
+
: ctx.storage
|
|
570
|
+
.latestScopedChangeSeq(ctx.partition, {
|
|
571
|
+
table: plan.table.name,
|
|
572
|
+
scopeFilter: plan.effective,
|
|
573
|
+
})
|
|
574
|
+
.then((latest) => {
|
|
575
|
+
const pin = Math.min(maxSeq, Math.max(latest, horizonSeq));
|
|
576
|
+
return { asOf: pin, pin };
|
|
577
|
+
});
|
|
578
|
+
const lane = pinned.then(async ({ asOf, pin }) => ({
|
|
579
|
+
pin,
|
|
580
|
+
image: await ctx.segments.find(
|
|
581
|
+
{
|
|
582
|
+
partition: ctx.partition,
|
|
583
|
+
logEpoch,
|
|
584
|
+
table: plan.table.name,
|
|
585
|
+
schemaVersion: schema.version,
|
|
586
|
+
mediaType: 'sqlite',
|
|
587
|
+
scopeDigest: await scopeDigest(plan.effective),
|
|
588
|
+
asOfCommitSeq: asOf,
|
|
589
|
+
},
|
|
590
|
+
clockOf(ctx)(),
|
|
591
|
+
),
|
|
592
|
+
}));
|
|
593
|
+
return { firstPage, lane };
|
|
553
594
|
});
|
|
554
595
|
const settled = await Promise.all(
|
|
555
596
|
reads.map(async (read) => ({
|
|
556
597
|
commits: read.commits === undefined ? undefined : await read.commits,
|
|
557
598
|
firstPage: await read.firstPage,
|
|
599
|
+
lane: await read.lane,
|
|
558
600
|
})),
|
|
559
601
|
);
|
|
560
602
|
// Validate continuity before committing to an active section. A prune
|
|
@@ -566,6 +608,8 @@ export async function prepareSections(
|
|
|
566
608
|
commits: read.commits ?? [],
|
|
567
609
|
horizonSeq: read.commits === undefined ? horizonSeq : windowHorizon,
|
|
568
610
|
firstPage: read.firstPage,
|
|
611
|
+
pin: read.lane?.pin,
|
|
612
|
+
image: read.lane?.image,
|
|
569
613
|
}));
|
|
570
614
|
}
|
|
571
615
|
|
|
@@ -583,12 +627,17 @@ export async function* subscriptionSection(
|
|
|
583
627
|
trace?: PullSectionTrace,
|
|
584
628
|
logEpoch?: string,
|
|
585
629
|
): AsyncGenerator<ResponseFrame, SubscriptionResult> {
|
|
630
|
+
if (logEpoch === undefined || logEpoch.length === 0) {
|
|
631
|
+
throw new Error('subscriptionSection requires a non-empty log epoch');
|
|
632
|
+
}
|
|
586
633
|
const [prepared] = await prepareSections(
|
|
587
634
|
ctx,
|
|
635
|
+
schema,
|
|
588
636
|
limits,
|
|
589
637
|
[plan],
|
|
590
638
|
maxSeq,
|
|
591
639
|
horizonSeq,
|
|
640
|
+
logEpoch,
|
|
592
641
|
);
|
|
593
642
|
if (prepared === undefined) throw new Error('prepareSections lost a plan');
|
|
594
643
|
return yield* preparedSection(
|
|
@@ -660,8 +709,11 @@ export async function* preparedSection(
|
|
|
660
709
|
token !== undefined && token.asOfCommitSeq >= horizonSeq
|
|
661
710
|
? token
|
|
662
711
|
: undefined;
|
|
663
|
-
const asOf = resume?.asOfCommitSeq ?? maxSeq;
|
|
712
|
+
const asOf = resume?.asOfCommitSeq ?? prepared.pin ?? maxSeq;
|
|
664
713
|
const startCursor = resume?.rowCursor ?? null;
|
|
714
|
+
if (prepared.firstPage === undefined) {
|
|
715
|
+
throw new Error('prepareSections did not read the bootstrap page');
|
|
716
|
+
}
|
|
665
717
|
yield {
|
|
666
718
|
type: 'SUB_START',
|
|
667
719
|
id: sub.id,
|
|
@@ -680,6 +732,7 @@ export async function* preparedSection(
|
|
|
680
732
|
trace,
|
|
681
733
|
logEpoch,
|
|
682
734
|
prepared.firstPage,
|
|
735
|
+
prepared.image,
|
|
683
736
|
);
|
|
684
737
|
if (outcome.complete) {
|
|
685
738
|
yield { type: 'SUB_END', nextCursor: asOf };
|
|
@@ -740,3 +793,123 @@ export async function* preparedSection(
|
|
|
740
793
|
yield { type: 'SUB_END', nextCursor };
|
|
741
794
|
return { nextCursor, active: true };
|
|
742
795
|
}
|
|
796
|
+
|
|
797
|
+
export interface PublishSqliteImageInput {
|
|
798
|
+
/** The sync config the serving host runs, plus a `sqliteImageBuilder`. */
|
|
799
|
+
readonly config: SyncServerConfig;
|
|
800
|
+
readonly partition: string;
|
|
801
|
+
readonly table: string;
|
|
802
|
+
/**
|
|
803
|
+
* The effective scopes the subscribing clients hold for `table` (§3.2):
|
|
804
|
+
* the reuse key carries their digest, so publish once per distinct scope
|
|
805
|
+
* set that clients subscribe with.
|
|
806
|
+
*/
|
|
807
|
+
readonly scopes: ScopeMap;
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
export interface PublishedSqliteImage {
|
|
811
|
+
readonly segmentId: string;
|
|
812
|
+
/** The §4.7 scope pin the image is stored under. */
|
|
813
|
+
readonly asOfCommitSeq: number;
|
|
814
|
+
readonly scopeDigest: string;
|
|
815
|
+
readonly rowCount: number;
|
|
816
|
+
readonly byteLength: number;
|
|
817
|
+
/** `reused` when an unexpired image for the same reuse key already existed. */
|
|
818
|
+
readonly origin: 'built' | 'reused';
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
/**
|
|
822
|
+
* Publish the §5.3 sqlite image that a pull for (`table`, `scopes`) looks
|
|
823
|
+
* up, from a host with a SQLite engine (a Bun process with the production
|
|
824
|
+
* storage). It runs the pull's own bootstrap path (the §4.7 scope pin, the
|
|
825
|
+
* reuse key, the build), so the stored identity is exactly the one a
|
|
826
|
+
* serving host without a builder finds. The image stays current until a
|
|
827
|
+
* change reaches the scope; its lifetime is the segment store's TTL at
|
|
828
|
+
* publication.
|
|
829
|
+
*/
|
|
830
|
+
export async function publishSqliteImage(
|
|
831
|
+
input: PublishSqliteImageInput,
|
|
832
|
+
): Promise<PublishedSqliteImage> {
|
|
833
|
+
const { config, partition } = input;
|
|
834
|
+
if (config.sqliteImageBuilder === undefined) {
|
|
835
|
+
throw new Error('publishSqliteImage requires config.sqliteImageBuilder');
|
|
836
|
+
}
|
|
837
|
+
const schema = compileSchema(config.schema);
|
|
838
|
+
const table = schema.tables.get(input.table);
|
|
839
|
+
if (table === undefined) {
|
|
840
|
+
throw new Error('publishSqliteImage: the schema has no such table');
|
|
841
|
+
}
|
|
842
|
+
await config.storage.ensureSchema(schema, config.checkpoints);
|
|
843
|
+
const logEpoch = await config.storage.getPartitionLogEpoch(partition);
|
|
844
|
+
if (logEpoch === undefined) {
|
|
845
|
+
throw new Error('publishSqliteImage: the partition has no log epoch');
|
|
846
|
+
}
|
|
847
|
+
const ctx: SyncRequestContext = {
|
|
848
|
+
...config,
|
|
849
|
+
partition,
|
|
850
|
+
actorId: 'syncular.image-publisher',
|
|
851
|
+
};
|
|
852
|
+
const plan: SubscriptionPlan = {
|
|
853
|
+
frame: {
|
|
854
|
+
type: 'SUBSCRIPTION',
|
|
855
|
+
id: 'image',
|
|
856
|
+
table: input.table,
|
|
857
|
+
scopes: input.scopes,
|
|
858
|
+
cursor: -1,
|
|
859
|
+
},
|
|
860
|
+
table,
|
|
861
|
+
status: 'active',
|
|
862
|
+
effective: input.scopes,
|
|
863
|
+
};
|
|
864
|
+
// One-row pages make every table above one row image-eligible (§5.3).
|
|
865
|
+
const limits: PullLimits = {
|
|
866
|
+
limitCommits: 1,
|
|
867
|
+
limitSnapshotRows: 1,
|
|
868
|
+
maxSnapshotPages: 1,
|
|
869
|
+
accept: ACCEPT_SQLITE,
|
|
870
|
+
};
|
|
871
|
+
const maxSeq = await config.storage.getMaxCommitSeq(partition);
|
|
872
|
+
const horizonSeq = await config.storage.getHorizonSeq(partition);
|
|
873
|
+
const trace: PullSectionTrace = { segments: [] };
|
|
874
|
+
const [prepared] = await prepareSections(
|
|
875
|
+
ctx,
|
|
876
|
+
schema,
|
|
877
|
+
limits,
|
|
878
|
+
[plan],
|
|
879
|
+
maxSeq,
|
|
880
|
+
horizonSeq,
|
|
881
|
+
logEpoch,
|
|
882
|
+
);
|
|
883
|
+
if (prepared === undefined) throw new Error('prepareSections lost a plan');
|
|
884
|
+
let ref: SegmentRefFrame | undefined;
|
|
885
|
+
for await (const frame of preparedSection(
|
|
886
|
+
ctx,
|
|
887
|
+
schema,
|
|
888
|
+
limits,
|
|
889
|
+
plan,
|
|
890
|
+
maxSeq,
|
|
891
|
+
prepared,
|
|
892
|
+
trace,
|
|
893
|
+
logEpoch,
|
|
894
|
+
)) {
|
|
895
|
+
if (frame.type === 'SEGMENT_REF' && frame.mediaType === 'sqlite') {
|
|
896
|
+
ref = frame;
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
const image = trace.segments.find(
|
|
900
|
+
(segment) => segment.mediaType === 'sqlite',
|
|
901
|
+
);
|
|
902
|
+
if (ref === undefined || image === undefined) {
|
|
903
|
+
throw new Error(
|
|
904
|
+
'publishSqliteImage: the scope holds at most one row, which pulls serve inline',
|
|
905
|
+
);
|
|
906
|
+
}
|
|
907
|
+
return {
|
|
908
|
+
segmentId: ref.segmentId,
|
|
909
|
+
asOfCommitSeq: ref.asOfCommitSeq,
|
|
910
|
+
scopeDigest: ref.scopeDigest,
|
|
911
|
+
rowCount: ref.rowCount,
|
|
912
|
+
byteLength: ref.byteLength,
|
|
913
|
+
origin: image.origin === 'reused' ? 'reused' : 'built',
|
|
914
|
+
};
|
|
915
|
+
}
|
package/src/realtime.ts
CHANGED
|
@@ -39,7 +39,13 @@ import {
|
|
|
39
39
|
RESOLVER_OUTAGE,
|
|
40
40
|
touchAuthenticatedPartition,
|
|
41
41
|
} from './context';
|
|
42
|
-
import {
|
|
42
|
+
import {
|
|
43
|
+
adapterSyncError,
|
|
44
|
+
reportError,
|
|
45
|
+
SyncError,
|
|
46
|
+
type SyncularErrorHandler,
|
|
47
|
+
syncError,
|
|
48
|
+
} from './errors';
|
|
43
49
|
import { emitEvent, type SyncularServerEvents } from './events';
|
|
44
50
|
import { createSyncResponseStream } from './handler';
|
|
45
51
|
import { type CompiledSchema, compileSchema } from './schema';
|
|
@@ -581,19 +587,18 @@ export class RealtimeSession {
|
|
|
581
587
|
);
|
|
582
588
|
} catch (error) {
|
|
583
589
|
// §8.7 failures: what HTTP reports as status+JSON becomes a
|
|
584
|
-
// minimal RESP_HEADER/ERROR/END response stream on the socket.
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
)
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
throw error;
|
|
590
|
+
// minimal RESP_HEADER/ERROR/END response stream on the socket. An
|
|
591
|
+
// exception outside the catalog is reported and answers
|
|
592
|
+
// `sync.internal_error`, exactly like the HTTP adapter's 500.
|
|
593
|
+
const sync =
|
|
594
|
+
error instanceof DecodeError
|
|
595
|
+
? syncError(error.code, error.message)
|
|
596
|
+
: adapterSyncError(error, this.#hub.onError, 'realtime');
|
|
597
|
+
finishRound(); // END is in this one chunk
|
|
598
|
+
await this.#sendRoundChunk(
|
|
599
|
+
errorResponseBytes(sync, this.wireVersion, this.logEpoch),
|
|
600
|
+
);
|
|
601
|
+
return;
|
|
597
602
|
}
|
|
598
603
|
// Fetch-ahead so the round stops being "in flight" the moment the
|
|
599
604
|
// chunk carrying END is handed to send: the client may legally
|
|
@@ -604,23 +609,22 @@ export class RealtimeSession {
|
|
|
604
609
|
try {
|
|
605
610
|
step = await iterator.next();
|
|
606
611
|
} catch (error) {
|
|
607
|
-
// The
|
|
608
|
-
// `next()`, before any chunk is yielded
|
|
609
|
-
//
|
|
610
|
-
//
|
|
611
|
-
//
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
)
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
throw error;
|
|
612
|
+
// The generator builds the whole response (and runs the read-verify
|
|
613
|
+
// refusal) on its first `next()`, before any chunk is yielded.
|
|
614
|
+
// Nothing has escaped, so a failure there is delivered as the same
|
|
615
|
+
// §8.4 ERROR frame HTTP returns rather than a silent close; a
|
|
616
|
+
// non-catalog exception is reported and answers
|
|
617
|
+
// `sync.internal_error`. A throw after the first chunk still takes
|
|
618
|
+
// the §8.7 violation path.
|
|
619
|
+
const sync =
|
|
620
|
+
error instanceof DecodeError
|
|
621
|
+
? syncError(error.code, error.message)
|
|
622
|
+
: adapterSyncError(error, this.#hub.onError, 'realtime');
|
|
623
|
+
finishRound();
|
|
624
|
+
await this.#sendRoundChunk(
|
|
625
|
+
errorResponseBytes(sync, this.wireVersion, this.logEpoch),
|
|
626
|
+
);
|
|
627
|
+
return;
|
|
624
628
|
}
|
|
625
629
|
while (!step.done) {
|
|
626
630
|
const chunk = step.value;
|
|
@@ -628,9 +632,10 @@ export class RealtimeSession {
|
|
|
628
632
|
if (step.done) finishRound();
|
|
629
633
|
await this.#sendRoundChunk(chunk);
|
|
630
634
|
}
|
|
631
|
-
} catch {
|
|
635
|
+
} catch (error) {
|
|
632
636
|
// A host failure mid-stream leaves the byte stream unfinishable —
|
|
633
637
|
// fail loud, drop the connection (§8.7 / §1.4 abort rule).
|
|
638
|
+
reportError(error, this.#hub.onError, 'realtime');
|
|
634
639
|
this.#violation('sync round failed mid-stream');
|
|
635
640
|
} finally {
|
|
636
641
|
finishRound();
|
|
@@ -1059,10 +1064,18 @@ export class RealtimeHub {
|
|
|
1059
1064
|
...(this.#config.events !== undefined
|
|
1060
1065
|
? { events: this.#config.events }
|
|
1061
1066
|
: {}),
|
|
1067
|
+
...(this.#config.onError !== undefined
|
|
1068
|
+
? { onError: this.#config.onError }
|
|
1069
|
+
: {}),
|
|
1062
1070
|
realtime: this,
|
|
1063
1071
|
};
|
|
1064
1072
|
}
|
|
1065
1073
|
|
|
1074
|
+
/** The host's error reporter (`SyncServerConfig.onError`), if any. */
|
|
1075
|
+
get onError(): SyncularErrorHandler | undefined {
|
|
1076
|
+
return this.#config.onError;
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1066
1079
|
requestContext(session: RealtimeSession): SyncRequestContext {
|
|
1067
1080
|
return this.requestContextFor(session);
|
|
1068
1081
|
}
|
package/src/relational-rows.ts
CHANGED
|
@@ -705,6 +705,35 @@ export function prefetchRowsSql(table: CompiledTable): string {
|
|
|
705
705
|
ON t.partition=$1 AND t.tbl=$3 AND t.row_id=ids.id`;
|
|
706
706
|
}
|
|
707
707
|
|
|
708
|
+
/**
|
|
709
|
+
* SQLite/D1: the newest `commit_seq` in the inverted change-scope index for
|
|
710
|
+
* one variable's values, as one index-only max per value (a `(partition,
|
|
711
|
+
* tbl, var, value)` prefix), so the cost is independent of the table's
|
|
712
|
+
* history. Bind order: [partition, tbl, var, value] per value.
|
|
713
|
+
*/
|
|
714
|
+
export function latestScopedChangeSeqSql(valueCount: number): string {
|
|
715
|
+
const perValue: string[] = [];
|
|
716
|
+
for (let i = 0; i < valueCount; i++) {
|
|
717
|
+
perValue.push(
|
|
718
|
+
'SELECT (SELECT max(commit_seq) FROM sync_change_scopes WHERE partition=? AND tbl=? AND var=? AND value=?) AS seq',
|
|
719
|
+
);
|
|
720
|
+
}
|
|
721
|
+
return `SELECT max(seq) AS seq FROM (${perValue.join(' UNION ALL ')})`;
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
/**
|
|
725
|
+
* PostgreSQL set-based form of `latestScopedChangeSeqSql` for the
|
|
726
|
+
* subscriptions of one table in a pull. `$2` is a JSON array of
|
|
727
|
+
* `{i, var, vals}` records. Params: [partition, specs (JSON text), tbl].
|
|
728
|
+
*/
|
|
729
|
+
export const LATEST_SCOPED_CHANGE_BATCH_SQL = `SELECT s.i AS sub,
|
|
730
|
+
(SELECT max(latest.seq) FROM (
|
|
731
|
+
SELECT (SELECT max(commit_seq) FROM sync_change_scopes
|
|
732
|
+
WHERE partition=$1 AND tbl=$3 AND var=s.var AND value=v.value) AS seq
|
|
733
|
+
FROM jsonb_array_elements_text(s.vals) AS v(value)) AS latest) AS seq
|
|
734
|
+
FROM jsonb_to_recordset($2::text::jsonb) AS s(i int, var text, vals jsonb)
|
|
735
|
+
ORDER BY s.i`;
|
|
736
|
+
|
|
708
737
|
/** SELECT a row's version + scope map (admin/blob authz). Params: [partition, rowId]. */
|
|
709
738
|
export function selectRowScopesSql(
|
|
710
739
|
table: CompiledTable,
|
package/src/s3-segment-store.ts
CHANGED
|
@@ -49,6 +49,7 @@ import type {
|
|
|
49
49
|
SegmentRecord,
|
|
50
50
|
SegmentStore,
|
|
51
51
|
SegmentStoreStats,
|
|
52
|
+
SegmentStream,
|
|
52
53
|
} from './segment-store';
|
|
53
54
|
import {
|
|
54
55
|
mergeSegmentRecord,
|
|
@@ -619,6 +620,20 @@ export class S3SegmentStore implements SegmentStore {
|
|
|
619
620
|
};
|
|
620
621
|
}
|
|
621
622
|
|
|
623
|
+
/** The record plus the object GET's body, never buffered (see `get`). */
|
|
624
|
+
async open(segmentId: string): Promise<SegmentStream | undefined> {
|
|
625
|
+
if (!SEGMENT_ID_PATTERN.test(segmentId)) return undefined;
|
|
626
|
+
const record = await this.#readRecord(segmentId);
|
|
627
|
+
if (record === undefined) return undefined;
|
|
628
|
+
const response = await this.#request('GET', this.objectKeyFor(segmentId));
|
|
629
|
+
if (response === undefined || response.body === null) return undefined;
|
|
630
|
+
return {
|
|
631
|
+
record: record.record,
|
|
632
|
+
body: response.body,
|
|
633
|
+
byteLength: record.record.byteLength,
|
|
634
|
+
};
|
|
635
|
+
}
|
|
636
|
+
|
|
622
637
|
async find(
|
|
623
638
|
key: SegmentFindKey,
|
|
624
639
|
nowMs: number,
|