@syncular/server 0.26.1 → 0.26.3
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/d1-storage.js +75 -14
- package/dist/handler.js +7 -3
- package/dist/postgres-storage.js +79 -37
- package/dist/pull.d.ts +19 -0
- package/dist/pull.js +87 -33
- package/dist/relational-rows.d.ts +21 -0
- package/dist/relational-rows.js +42 -0
- package/package.json +2 -2
- package/src/d1-storage.ts +93 -33
- package/src/handler.ts +14 -4
- package/src/postgres-storage.ts +112 -50
- package/src/pull.ts +144 -39
- package/src/relational-rows.ts +44 -0
package/src/pull.ts
CHANGED
|
@@ -207,6 +207,26 @@ function segmentRefFrame(
|
|
|
207
207
|
};
|
|
208
208
|
}
|
|
209
209
|
|
|
210
|
+
/**
|
|
211
|
+
* §5.3: the sqlite-image lane — whole-table, chosen only at the start of a
|
|
212
|
+
* table (a mid-table resume stays on the rows lane, never switching lanes),
|
|
213
|
+
* and only when the client advertised bit 2. §5.11: a table with any
|
|
214
|
+
* encrypted column is image-INELIGIBLE — an image copies ciphertext
|
|
215
|
+
* wholesale with no per-row decrypt pass, so it MUST be served on the rows
|
|
216
|
+
* lane (which decrypts per row on the client).
|
|
217
|
+
*/
|
|
218
|
+
function sqliteLaneEligible(
|
|
219
|
+
limits: PullLimits,
|
|
220
|
+
plan: SubscriptionPlan,
|
|
221
|
+
startRowCursor: string | null,
|
|
222
|
+
): boolean {
|
|
223
|
+
return (
|
|
224
|
+
(limits.accept & ACCEPT_SQLITE) !== 0 &&
|
|
225
|
+
startRowCursor === null &&
|
|
226
|
+
plan.table.encryptedColumnIndices.length === 0
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
|
|
210
230
|
/**
|
|
211
231
|
* §5.3 sqlite-image lane: only at the start of a table, only when the
|
|
212
232
|
* client advertised bit 2, and only when the snapshot exceeds one rows
|
|
@@ -369,6 +389,7 @@ async function* bootstrapSegments(
|
|
|
369
389
|
startRowCursor: string | null,
|
|
370
390
|
trace: PullSectionTrace | undefined,
|
|
371
391
|
logEpoch: string,
|
|
392
|
+
firstPage: StoredRow[] | undefined,
|
|
372
393
|
): AsyncGenerator<
|
|
373
394
|
ResponseFrame,
|
|
374
395
|
{ complete: boolean; rowCursor: string | null }
|
|
@@ -377,17 +398,7 @@ async function* bootstrapSegments(
|
|
|
377
398
|
const serverLimits = limitsOf(ctx);
|
|
378
399
|
const digest = await scopeDigest(plan.effective);
|
|
379
400
|
const now = clockOf(ctx)();
|
|
380
|
-
|
|
381
|
-
// of a table (a mid-table resume stays on the rows lane, never
|
|
382
|
-
// switching lanes), and only when the client advertised bit 2.
|
|
383
|
-
// §5.11: a table with any encrypted column is image-INELIGIBLE — an image
|
|
384
|
-
// copies ciphertext wholesale with no per-row decrypt pass, so it MUST be
|
|
385
|
-
// served on the rows lane (which decrypts per row on the client).
|
|
386
|
-
if (
|
|
387
|
-
(limits.accept & ACCEPT_SQLITE) !== 0 &&
|
|
388
|
-
startRowCursor === null &&
|
|
389
|
-
plan.table.encryptedColumnIndices.length === 0
|
|
390
|
-
) {
|
|
401
|
+
if (sqliteLaneEligible(limits, plan, startRowCursor)) {
|
|
391
402
|
const imaged = yield* sqliteImageSegment(
|
|
392
403
|
ctx,
|
|
393
404
|
schema,
|
|
@@ -402,12 +413,15 @@ async function* bootstrapSegments(
|
|
|
402
413
|
}
|
|
403
414
|
let rowCursor = startRowCursor;
|
|
404
415
|
for (let page = 0; page < limits.maxSnapshotPages; page++) {
|
|
405
|
-
const scanned =
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
416
|
+
const scanned =
|
|
417
|
+
page === 0 && firstPage !== undefined
|
|
418
|
+
? firstPage
|
|
419
|
+
: await storage.scanRows(partition, {
|
|
420
|
+
table: plan.table.name,
|
|
421
|
+
scopeFilter: plan.effective,
|
|
422
|
+
afterRowId: rowCursor,
|
|
423
|
+
limit: limits.limitSnapshotRows + 1,
|
|
424
|
+
});
|
|
411
425
|
const pageRows = scanned.slice(0, limits.limitSnapshotRows);
|
|
412
426
|
const hasMore = scanned.length > limits.limitSnapshotRows;
|
|
413
427
|
// §5.2: every row record carries the row's current server_version.
|
|
@@ -475,6 +489,86 @@ async function* bootstrapSegments(
|
|
|
475
489
|
return { complete: false, rowCursor };
|
|
476
490
|
}
|
|
477
491
|
|
|
492
|
+
/**
|
|
493
|
+
* The storage reads a section needs before its first frame. A pull starts
|
|
494
|
+
* them for every subscription before awaiting any, so a storage that batches
|
|
495
|
+
* concurrent page reads (PostgreSQL) answers all subscriptions of one table
|
|
496
|
+
* with one statement, and the horizon re-check runs once per pull.
|
|
497
|
+
*/
|
|
498
|
+
export interface PreparedSection {
|
|
499
|
+
/** §4.5 window read for an incremental cursor; empty when none was read. */
|
|
500
|
+
readonly commits: StoredCommit[];
|
|
501
|
+
/** Horizon to judge the cursor against, re-read after any window read. */
|
|
502
|
+
readonly horizonSeq: number;
|
|
503
|
+
/** First rows-lane snapshot page, read when the sqlite lane is excluded. */
|
|
504
|
+
readonly firstPage: StoredRow[] | undefined;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/** Run the pre-frame storage reads of every subscription concurrently. */
|
|
508
|
+
export async function prepareSections(
|
|
509
|
+
ctx: SyncRequestContext,
|
|
510
|
+
limits: PullLimits,
|
|
511
|
+
plans: readonly SubscriptionPlan[],
|
|
512
|
+
maxSeq: number,
|
|
513
|
+
horizonSeq: number,
|
|
514
|
+
): Promise<PreparedSection[]> {
|
|
515
|
+
const reads = plans.map((plan) => {
|
|
516
|
+
const sub = plan.frame;
|
|
517
|
+
if (plan.status === 'revoked') return {};
|
|
518
|
+
const token = parseBootstrapToken(sub.bootstrapState, sub.table);
|
|
519
|
+
if (
|
|
520
|
+
token === undefined &&
|
|
521
|
+
sub.cursor >= 0 &&
|
|
522
|
+
sub.cursor >= horizonSeq &&
|
|
523
|
+
sub.cursor <= maxSeq
|
|
524
|
+
) {
|
|
525
|
+
return {
|
|
526
|
+
commits: ctx.storage.readCommitWindow(ctx.partition, {
|
|
527
|
+
table: sub.table,
|
|
528
|
+
scopeFilter: plan.effective,
|
|
529
|
+
afterSeq: sub.cursor,
|
|
530
|
+
throughSeq: maxSeq,
|
|
531
|
+
limitChanges: limits.limitCommits + 1,
|
|
532
|
+
}),
|
|
533
|
+
};
|
|
534
|
+
}
|
|
535
|
+
// §4.6 reset: no reads.
|
|
536
|
+
if (token === undefined && sub.cursor >= 0 && sub.cursor < horizonSeq) {
|
|
537
|
+
return {};
|
|
538
|
+
}
|
|
539
|
+
// Bootstrapping: the same resume decision `preparedSection` makes (§4.7).
|
|
540
|
+
const startCursor =
|
|
541
|
+
token !== undefined && token.asOfCommitSeq >= horizonSeq
|
|
542
|
+
? token.rowCursor
|
|
543
|
+
: null;
|
|
544
|
+
if (sqliteLaneEligible(limits, plan, startCursor)) return {};
|
|
545
|
+
return {
|
|
546
|
+
firstPage: ctx.storage.scanRows(ctx.partition, {
|
|
547
|
+
table: plan.table.name,
|
|
548
|
+
scopeFilter: plan.effective,
|
|
549
|
+
afterRowId: startCursor,
|
|
550
|
+
limit: limits.limitSnapshotRows + 1,
|
|
551
|
+
}),
|
|
552
|
+
};
|
|
553
|
+
});
|
|
554
|
+
const settled = await Promise.all(
|
|
555
|
+
reads.map(async (read) => ({
|
|
556
|
+
commits: read.commits === undefined ? undefined : await read.commits,
|
|
557
|
+
firstPage: await read.firstPage,
|
|
558
|
+
})),
|
|
559
|
+
);
|
|
560
|
+
// Validate continuity before committing to an active section. A prune
|
|
561
|
+
// during a paged read can otherwise make an incomplete window look empty.
|
|
562
|
+
const windowHorizon = settled.some((read) => read.commits !== undefined)
|
|
563
|
+
? Math.max(horizonSeq, await ctx.storage.getHorizonSeq(ctx.partition))
|
|
564
|
+
: horizonSeq;
|
|
565
|
+
return settled.map((read) => ({
|
|
566
|
+
commits: read.commits ?? [],
|
|
567
|
+
horizonSeq: read.commits === undefined ? horizonSeq : windowHorizon,
|
|
568
|
+
firstPage: read.firstPage,
|
|
569
|
+
}));
|
|
570
|
+
}
|
|
571
|
+
|
|
478
572
|
/**
|
|
479
573
|
* Produce the `SUB_START … SUB_END` section for one subscription (§1.6),
|
|
480
574
|
* returning the cursor recorded for the retention watermark (§4.5).
|
|
@@ -488,6 +582,37 @@ export async function* subscriptionSection(
|
|
|
488
582
|
horizonSeq: number,
|
|
489
583
|
trace?: PullSectionTrace,
|
|
490
584
|
logEpoch?: string,
|
|
585
|
+
): AsyncGenerator<ResponseFrame, SubscriptionResult> {
|
|
586
|
+
const [prepared] = await prepareSections(
|
|
587
|
+
ctx,
|
|
588
|
+
limits,
|
|
589
|
+
[plan],
|
|
590
|
+
maxSeq,
|
|
591
|
+
horizonSeq,
|
|
592
|
+
);
|
|
593
|
+
if (prepared === undefined) throw new Error('prepareSections lost a plan');
|
|
594
|
+
return yield* preparedSection(
|
|
595
|
+
ctx,
|
|
596
|
+
schema,
|
|
597
|
+
limits,
|
|
598
|
+
plan,
|
|
599
|
+
maxSeq,
|
|
600
|
+
prepared,
|
|
601
|
+
trace,
|
|
602
|
+
logEpoch,
|
|
603
|
+
);
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
/** The section of one subscription whose reads `prepareSections` ran. */
|
|
607
|
+
export async function* preparedSection(
|
|
608
|
+
ctx: SyncRequestContext,
|
|
609
|
+
schema: CompiledSchema,
|
|
610
|
+
limits: PullLimits,
|
|
611
|
+
plan: SubscriptionPlan,
|
|
612
|
+
maxSeq: number,
|
|
613
|
+
prepared: PreparedSection,
|
|
614
|
+
trace?: PullSectionTrace,
|
|
615
|
+
logEpoch?: string,
|
|
491
616
|
): AsyncGenerator<ResponseFrame, SubscriptionResult> {
|
|
492
617
|
const sub = plan.frame;
|
|
493
618
|
if (logEpoch === undefined || logEpoch.length === 0) {
|
|
@@ -508,28 +633,7 @@ export async function* subscriptionSection(
|
|
|
508
633
|
}
|
|
509
634
|
|
|
510
635
|
const token = parseBootstrapToken(sub.bootstrapState, sub.table);
|
|
511
|
-
|
|
512
|
-
let commits: StoredCommit[] = [];
|
|
513
|
-
if (
|
|
514
|
-
token === undefined &&
|
|
515
|
-
sub.cursor >= 0 &&
|
|
516
|
-
sub.cursor >= horizonSeq &&
|
|
517
|
-
sub.cursor <= maxSeq
|
|
518
|
-
) {
|
|
519
|
-
commits = await ctx.storage.readCommitWindow(ctx.partition, {
|
|
520
|
-
table: sub.table,
|
|
521
|
-
scopeFilter: plan.effective,
|
|
522
|
-
afterSeq: sub.cursor,
|
|
523
|
-
throughSeq: maxSeq,
|
|
524
|
-
limitChanges: limits.limitCommits + 1,
|
|
525
|
-
});
|
|
526
|
-
// Validate continuity before committing to an active section. A prune
|
|
527
|
-
// during a paged read can otherwise make an incomplete window look empty.
|
|
528
|
-
horizonSeq = Math.max(
|
|
529
|
-
horizonSeq,
|
|
530
|
-
await ctx.storage.getHorizonSeq(ctx.partition),
|
|
531
|
-
);
|
|
532
|
-
}
|
|
636
|
+
const { commits, horizonSeq } = prepared;
|
|
533
637
|
|
|
534
638
|
// §4.6: a cursor behind the horizon (and not resuming a bootstrap)
|
|
535
639
|
// cannot compute deltas — answer `reset` and echo the cursor.
|
|
@@ -575,6 +679,7 @@ export async function* subscriptionSection(
|
|
|
575
679
|
startCursor,
|
|
576
680
|
trace,
|
|
577
681
|
logEpoch,
|
|
682
|
+
prepared.firstPage,
|
|
578
683
|
);
|
|
579
684
|
if (outcome.complete) {
|
|
580
685
|
yield { type: 'SUB_END', nextCursor: asOf };
|
package/src/relational-rows.ts
CHANGED
|
@@ -644,6 +644,50 @@ export function commitWindowPageSql(
|
|
|
644
644
|
ORDER BY c.commit_seq, ch.idx`;
|
|
645
645
|
}
|
|
646
646
|
|
|
647
|
+
/**
|
|
648
|
+
* PostgreSQL set-based form of `scanRowPageSql`: one statement answers the
|
|
649
|
+
* page of every subscription scanning `table` in a pull. `$2` is a JSON array
|
|
650
|
+
* of `{i, var, vals, after, lim}` records; each record runs the same
|
|
651
|
+
* index-first candidate subquery through a LATERAL join, so every
|
|
652
|
+
* subscription keeps its own keyset cursor and LIMIT. Rows arrive ordered
|
|
653
|
+
* by (i, row_id).
|
|
654
|
+
*
|
|
655
|
+
* Params: [partition, specs (jsonb text), tbl].
|
|
656
|
+
*/
|
|
657
|
+
export function scanRowBatchSql(table: CompiledTable): string {
|
|
658
|
+
return `SELECT s.i AS sub, c.row_id 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
|
|
659
|
+
FROM jsonb_to_recordset($2::jsonb) AS s(i int, var text, vals text[], after text, lim int)
|
|
660
|
+
CROSS JOIN LATERAL (SELECT DISTINCT row_id FROM sync_row_scopes
|
|
661
|
+
WHERE partition=$1 AND tbl=$3 AND var=s.var AND value=ANY(s.vals)
|
|
662
|
+
AND row_id>s.after
|
|
663
|
+
ORDER BY row_id LIMIT s.lim) c
|
|
664
|
+
LEFT JOIN ${quoteIdent(table.name)} r
|
|
665
|
+
ON r.${quoteIdent(SYNC_PARTITION_COLUMN)}=$1 AND r.${quoteIdent(SYNC_ROW_ID_COLUMN)}=c.row_id
|
|
666
|
+
ORDER BY s.i, c.row_id`;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
/**
|
|
670
|
+
* PostgreSQL set-based form of `commitWindowPageSql`: one statement answers
|
|
671
|
+
* the window page of every subscription reading `tbl` in a pull. `$2` is a
|
|
672
|
+
* JSON array of `{i, var, vals, after, through, lim}` records, each running
|
|
673
|
+
* the same index-first candidate subquery through a LATERAL join. Rows
|
|
674
|
+
* arrive ordered by (i, commit_seq, idx).
|
|
675
|
+
*
|
|
676
|
+
* Params: [partition, specs (jsonb text), tbl].
|
|
677
|
+
*/
|
|
678
|
+
export 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,
|
|
679
|
+
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
|
|
680
|
+
FROM jsonb_to_recordset($2::jsonb) AS s(i int, var text, vals text[], after bigint, through bigint, lim int)
|
|
681
|
+
CROSS JOIN LATERAL (SELECT DISTINCT commit_seq FROM sync_change_scopes
|
|
682
|
+
WHERE partition=$1 AND tbl=$3 AND var=s.var AND value=ANY(s.vals)
|
|
683
|
+
AND commit_seq>s.after AND commit_seq<=s.through
|
|
684
|
+
ORDER BY commit_seq LIMIT s.lim) c
|
|
685
|
+
LEFT JOIN sync_commits m
|
|
686
|
+
ON m.partition=$1 AND m.commit_seq=c.commit_seq
|
|
687
|
+
LEFT JOIN sync_changes ch
|
|
688
|
+
ON ch.partition=$1 AND ch.commit_seq=c.commit_seq AND ch.tbl=$3
|
|
689
|
+
ORDER BY s.i, c.commit_seq, ch.idx`;
|
|
690
|
+
|
|
647
691
|
/** SELECT a row's version + scope map (admin/blob authz). Params: [partition, rowId]. */
|
|
648
692
|
export function selectRowScopesSql(
|
|
649
693
|
table: CompiledTable,
|