@syncular/server 0.21.1 → 0.23.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/README.md +20 -6
- package/dist/admin.d.ts +3 -1
- package/dist/admin.js +7 -1
- package/dist/blob-handlers.js +1 -1
- package/dist/context.d.ts +8 -1
- package/dist/d1-storage.d.ts +11 -3
- package/dist/d1-storage.js +84 -2
- package/dist/errors.js +10 -0
- package/dist/handler.js +50 -4
- package/dist/operations.js +3 -3
- package/dist/pg-executor.d.ts +11 -1
- package/dist/postgres-storage.d.ts +20 -5
- package/dist/postgres-storage.js +345 -9
- package/dist/pull.js +9 -5
- package/dist/push.js +18 -0
- package/dist/readiness.d.ts +9 -2
- package/dist/readiness.js +27 -2
- package/dist/realtime.d.ts +40 -1
- package/dist/realtime.js +116 -19
- package/dist/relational-rows.d.ts +20 -0
- package/dist/relational-rows.js +29 -0
- package/dist/s3-segment-store.d.ts +20 -8
- package/dist/s3-segment-store.js +216 -68
- package/dist/schema.js +11 -0
- package/dist/segment-download.d.ts +1 -1
- package/dist/segment-download.js +50 -17
- package/dist/segment-store.d.ts +86 -0
- package/dist/segment-store.js +156 -17
- package/dist/signed-url.d.ts +7 -1
- package/dist/signed-url.js +10 -1
- package/dist/sqlite-dialect.d.ts +7 -1
- package/dist/sqlite-dialect.js +23 -0
- package/dist/sqlite-segment-store.js +153 -42
- package/dist/sqlite-storage.d.ts +11 -3
- package/dist/sqlite-storage.js +346 -34
- package/dist/storage-errors.d.ts +1 -1
- package/dist/storage-errors.js +4 -0
- package/dist/storage.d.ts +152 -2
- package/dist/storage.js +40 -1
- package/dist/validate.d.ts +20 -1
- package/package.json +2 -2
- package/src/admin.ts +13 -1
- package/src/blob-handlers.ts +1 -1
- package/src/context.ts +8 -0
- package/src/d1-storage.ts +143 -1
- package/src/errors.ts +10 -0
- package/src/handler.ts +62 -4
- package/src/operations.ts +17 -13
- package/src/pg-executor.ts +11 -1
- package/src/postgres-storage.ts +520 -5
- package/src/pull.ts +9 -1
- package/src/push.ts +18 -0
- package/src/readiness.ts +39 -4
- package/src/realtime.ts +117 -18
- package/src/relational-rows.ts +36 -0
- package/src/s3-segment-store.ts +275 -84
- package/src/schema.ts +15 -0
- package/src/segment-download.ts +79 -28
- package/src/segment-store.ts +250 -18
- package/src/signed-url.ts +12 -2
- package/src/sqlite-dialect.ts +23 -0
- package/src/sqlite-segment-store.ts +245 -65
- package/src/sqlite-storage.ts +494 -36
- package/src/storage-errors.ts +13 -1
- package/src/storage.ts +243 -1
- package/src/validate.ts +20 -1
package/dist/storage.d.ts
CHANGED
|
@@ -28,6 +28,72 @@ export interface CommitPruneResult {
|
|
|
28
28
|
readonly horizonSeq: number;
|
|
29
29
|
readonly removedCommits: number;
|
|
30
30
|
}
|
|
31
|
+
/** RFC 0007 backfill lifecycle. `activated` is terminal within a schema version. */
|
|
32
|
+
export type CheckpointState = 'declared' | 'backfilling' | 'activated';
|
|
33
|
+
/**
|
|
34
|
+
* Host-owned checkpoint installation carried on `ensureSchema`: the schema
|
|
35
|
+
* bump, the `declared` row, and the raised writer fence commit together.
|
|
36
|
+
*/
|
|
37
|
+
export interface CheckpointDeclaration {
|
|
38
|
+
readonly partition: string;
|
|
39
|
+
readonly name: string;
|
|
40
|
+
readonly schemaVersion: number;
|
|
41
|
+
}
|
|
42
|
+
/** One host-storage backfill checkpoint row. Never a synced application row. */
|
|
43
|
+
export interface StoredCheckpoint {
|
|
44
|
+
readonly partition: string;
|
|
45
|
+
readonly name: string;
|
|
46
|
+
readonly schemaVersion: number;
|
|
47
|
+
readonly state: CheckpointState;
|
|
48
|
+
/** Highest source `commit_seq` the projection was derived from. */
|
|
49
|
+
readonly watermark: number;
|
|
50
|
+
/** Monotonic claim counter; a batch is stale when it does not match. */
|
|
51
|
+
readonly ownerEpoch: number;
|
|
52
|
+
readonly observedRows: number;
|
|
53
|
+
readonly updatedAtMs: number;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* RFC 0007 serve-gate token, read inside the transaction that produces the
|
|
57
|
+
* result. `storedSchemaVersion` is the published marker; `logEpoch` is the
|
|
58
|
+
* partition's log generation, so a restore that presents V -> other -> V is
|
|
59
|
+
* still distinguishable. `pending` names every incomplete declared checkpoint
|
|
60
|
+
* at the running schema version, across partitions.
|
|
61
|
+
*/
|
|
62
|
+
export interface ServeGate {
|
|
63
|
+
readonly storedSchemaVersion: number;
|
|
64
|
+
readonly logEpoch: string | undefined;
|
|
65
|
+
readonly pending: readonly {
|
|
66
|
+
readonly partition: string;
|
|
67
|
+
readonly name: string;
|
|
68
|
+
}[];
|
|
69
|
+
}
|
|
70
|
+
/** Why `serveGate` refuses, or `undefined` when this request may serve. */
|
|
71
|
+
export type ServeGateRefusal = {
|
|
72
|
+
readonly kind: 'schema';
|
|
73
|
+
readonly stored: number;
|
|
74
|
+
readonly running: number;
|
|
75
|
+
} | {
|
|
76
|
+
readonly kind: 'checkpoint';
|
|
77
|
+
readonly partition: string;
|
|
78
|
+
readonly name: string;
|
|
79
|
+
} | {
|
|
80
|
+
/** The token changed between two reads: a restore rotated the epoch. */
|
|
81
|
+
readonly kind: 'epoch';
|
|
82
|
+
};
|
|
83
|
+
/**
|
|
84
|
+
* Evaluate the RFC 0007 predicate against a gate token. A process that
|
|
85
|
+
* declares the pending checkpoint may serve while it backfills; any other
|
|
86
|
+
* process is refused, and a database newer than the running build is always
|
|
87
|
+
* refused.
|
|
88
|
+
*/
|
|
89
|
+
export declare function serveGateRefusal(gate: ServeGate, runningSchemaVersion: number, checkpoints: readonly CheckpointDeclaration[] | undefined): ServeGateRefusal | undefined;
|
|
90
|
+
/**
|
|
91
|
+
* RFC 0007: compare two gate tokens read around a data read. The token binds
|
|
92
|
+
* the published schema version and the partition log epoch, so a restore that
|
|
93
|
+
* presents V → other → V is distinguishable. Returns a refusal when either
|
|
94
|
+
* changed across the read.
|
|
95
|
+
*/
|
|
96
|
+
export declare function serveGateTokenChanged(before: ServeGate, after: ServeGate): ServeGateRefusal | undefined;
|
|
31
97
|
/** The current stored state of a synced row. */
|
|
32
98
|
export interface StoredRow {
|
|
33
99
|
readonly rowId: string;
|
|
@@ -352,6 +418,21 @@ export interface StorageTransaction {
|
|
|
352
418
|
* first writer wins; callers read the canonical value after commit.
|
|
353
419
|
*/
|
|
354
420
|
putPushResult(clientId: string, clientCommitId: string, result: StoredPushResult): Promise<void>;
|
|
421
|
+
/**
|
|
422
|
+
* Advance this partition's checkpoint only while `owner_epoch` still
|
|
423
|
+
* matches, so the owner-epoch CAS rides inside the caller's transaction.
|
|
424
|
+
* The host calls this from `processPushOperationsWithTrace`'s operation
|
|
425
|
+
* builder: the backfill is an ordinary server-authoritative write, and this
|
|
426
|
+
* CAS commits with that write's row versions and commit-log append. False
|
|
427
|
+
* means abort and write nothing, rather than a throw for a superseded owner.
|
|
428
|
+
*/
|
|
429
|
+
advanceCheckpoint(name: string, ownerEpoch: number, watermark: number, observedRows: number, nowMs: number): Promise<boolean>;
|
|
430
|
+
/**
|
|
431
|
+
* RFC 0007 serve gate on the caller's own connection, for write paths that
|
|
432
|
+
* already hold the partition lock: the gate and the write share one
|
|
433
|
+
* transaction, so a migration cannot interleave between them.
|
|
434
|
+
*/
|
|
435
|
+
readServeGate(runningSchemaVersion: number): Promise<ServeGate>;
|
|
355
436
|
/**
|
|
356
437
|
* Blob reference index (§5.9.4) — ADDITIVE, optional. Set the blobIds a
|
|
357
438
|
* row currently references (empty = clear), replacing any prior entries
|
|
@@ -379,7 +460,7 @@ export interface ServerStorage {
|
|
|
379
460
|
* before binding a public port; protocol handlers still call this method
|
|
380
461
|
* lazily as a defensive backstop.
|
|
381
462
|
*/
|
|
382
|
-
ensureSchema(schema: CompiledSchema): Promise<void>;
|
|
463
|
+
ensureSchema(schema: CompiledSchema, checkpoints?: readonly CheckpointDeclaration[]): Promise<void>;
|
|
383
464
|
/** Create or refresh the authenticated partition registry row (§2.1). */
|
|
384
465
|
touchPartition(partition: string, authenticatedAtMs: number, initialLogEpoch: string): Promise<PartitionRegistryEntry>;
|
|
385
466
|
/** Rotate log continuity after restore and discard stale cursor records. */
|
|
@@ -388,6 +469,19 @@ export interface ServerStorage {
|
|
|
388
469
|
listPartitionRegistry(): Promise<PartitionRegistryEntry[]>;
|
|
389
470
|
/** Read continuity without refreshing authenticated activity. */
|
|
390
471
|
getPartitionLogEpoch(partition: string): Promise<string | undefined>;
|
|
472
|
+
/**
|
|
473
|
+
* Open a storage transaction. This is the raw primitive, not a command
|
|
474
|
+
* boundary: it acquires NO partition write lock. In-tree SQLite begins
|
|
475
|
+
* `IMMEDIATE`, so a hand-rolled command on SQLite happens to be serialized
|
|
476
|
+
* against a concurrent push; PostgreSQL's `BEGIN` takes no row lock, so the
|
|
477
|
+
* same command races a push on Postgres and a SQLite-only test conceals it.
|
|
478
|
+
*
|
|
479
|
+
* A trusted command must therefore run through `registerRemoteCommand`,
|
|
480
|
+
* which builds after `lockPartitionForPush` and the idempotency recheck and
|
|
481
|
+
* shares the committed transaction, or call `lockPartitionForPush` before
|
|
482
|
+
* its first authority, version, or replay read. SSP2 request handling does
|
|
483
|
+
* this for you; only host-authored commands choose.
|
|
484
|
+
*/
|
|
391
485
|
begin(partition: string): Promise<StorageTransaction>;
|
|
392
486
|
getMaxCommitSeq(partition: string): Promise<number>;
|
|
393
487
|
getHorizonSeq(partition: string): Promise<number>;
|
|
@@ -400,6 +494,50 @@ export interface ServerStorage {
|
|
|
400
494
|
pruneCommitsThrough(partition: string, query: CommitPruneQuery): Promise<CommitPruneResult>;
|
|
401
495
|
/** Newest commitSeq created strictly before the timestamp; 0 if none. */
|
|
402
496
|
getCommitSeqBefore(partition: string, createdBeforeMs: number): Promise<number>;
|
|
497
|
+
/** Every declared backfill checkpoint for the partition, ordered by name. */
|
|
498
|
+
readCheckpoints(partition: string): Promise<StoredCheckpoint[]>;
|
|
499
|
+
/**
|
|
500
|
+
* RFC 0007 serve gate: read the published schema version, the partition's
|
|
501
|
+
* log epoch, and every incomplete declared checkpoint at
|
|
502
|
+
* `runningSchemaVersion`. Never memoized; the serve paths call it per
|
|
503
|
+
* request so an already-serving process notices a migration.
|
|
504
|
+
*/
|
|
505
|
+
readServeGate(partition: string, runningSchemaVersion: number): Promise<ServeGate>;
|
|
506
|
+
/**
|
|
507
|
+
* Atomically take ownership of a declared or backfilling checkpoint. Increments
|
|
508
|
+
* `owner_epoch` and moves `declared` to `backfilling`. Throws
|
|
509
|
+
* `sync.storage.checkpoint_not_declared` when no claimable row exists (absent,
|
|
510
|
+
* or already `activated`).
|
|
511
|
+
*/
|
|
512
|
+
claimCheckpoint(partition: string, name: string, schemaVersion: number, nowMs: number): Promise<StoredCheckpoint>;
|
|
513
|
+
/**
|
|
514
|
+
* Advance the watermark only while `owner_epoch` still matches. Returns false
|
|
515
|
+
* for a superseded owner; the stale case never throws.
|
|
516
|
+
*/
|
|
517
|
+
advanceCheckpoint(partition: string, name: string, ownerEpoch: number, watermark: number, observedRows: number, nowMs: number): Promise<boolean>;
|
|
518
|
+
/** Highest commitSeq carrying a change to one of `tables`; 0 when none. */
|
|
519
|
+
sourceCoverageSeq(partition: string, tables: readonly string[]): Promise<number>;
|
|
520
|
+
/**
|
|
521
|
+
* Whether any source change sits above `seq`. `unverifiable` when the pruning
|
|
522
|
+
* horizon has passed `seq`, so the history that would answer the question is
|
|
523
|
+
* gone; a pruned window is never reported as `clean`.
|
|
524
|
+
*/
|
|
525
|
+
hasSourceChangesAbove(partition: string, tables: readonly string[], seq: number): Promise<'clean' | 'changed' | 'unverifiable'>;
|
|
526
|
+
/**
|
|
527
|
+
* Install the barrier: insert the checkpoint in `declared` and raise
|
|
528
|
+
* `required_writer_version` for the partition in one transaction. The fence
|
|
529
|
+
* is raised at declaration, before any backfill work, so old writers are
|
|
530
|
+
* rejected for the whole backfill window. Idempotent on an existing declared
|
|
531
|
+
* or backfilling row.
|
|
532
|
+
*/
|
|
533
|
+
declareCheckpoint(partition: string, name: string, schemaVersion: number, nowMs: number): Promise<StoredCheckpoint>;
|
|
534
|
+
/**
|
|
535
|
+
* Activate a declared checkpoint under the partition write lock. Refuses when
|
|
536
|
+
* the fence is not already raised at the checkpoint's schema version, when a
|
|
537
|
+
* source change sits above `watermark` (`stale`), or when the pruning horizon
|
|
538
|
+
* has passed `watermark` (`unverifiable`). `activated` is terminal.
|
|
539
|
+
*/
|
|
540
|
+
activateCheckpoint(partition: string, name: string, ownerEpoch: number, watermark: number, sources: readonly string[], nowMs: number): Promise<'activated' | 'stale' | 'unverifiable'>;
|
|
403
541
|
getRow(partition: string, table: string, rowId: string): Promise<StoredRow | undefined>;
|
|
404
542
|
/**
|
|
405
543
|
* Idempotency lookup (§2.3). Throws `SyncError sync.idempotency_cache_miss`
|
|
@@ -436,8 +574,20 @@ export interface ServerStorage {
|
|
|
436
574
|
* Optional registered-query capability. The implementation MUST replace
|
|
437
575
|
* every generated app-table relation with a partition-filtered relation and
|
|
438
576
|
* return rows plus maxCommitSeq from one consistent database snapshot.
|
|
577
|
+
*
|
|
578
|
+
* The storage serializes this call behind an open transaction on a
|
|
579
|
+
* single-connection executor (SQLite, PGlite). A validator runs inside the
|
|
580
|
+
* push transaction, so on that executor a validator that calls
|
|
581
|
+
* `queryAuthoritative` waits on the transaction it is already inside and
|
|
582
|
+
* the push never completes. A pool-backed executor (`pg`, `Bun.sql`) hands
|
|
583
|
+
* the call a second connection instead: the validator does not deadlock,
|
|
584
|
+
* but it reads committed state without the candidate write. On either
|
|
585
|
+
* executor read candidate state through the whole-commit `CommitValidator`
|
|
586
|
+
* reader (§6.8). A call made from any other context waits for the open
|
|
587
|
+
* transaction to finish on a single-connection executor, and runs
|
|
588
|
+
* concurrently on a pool-backed one.
|
|
439
589
|
*/
|
|
440
|
-
queryAuthoritative?(partition: string, query: AuthoritativeQueryRequest): Promise<AuthoritativeQueryResult>;
|
|
590
|
+
queryAuthoritative?(partition: string, query: AuthoritativeQueryRequest, checkpoints?: readonly CheckpointDeclaration[]): Promise<AuthoritativeQueryResult>;
|
|
441
591
|
/**
|
|
442
592
|
* Optional trusted-host exact lookup through a declared relational index.
|
|
443
593
|
* This capability is outside client scope/subscription authorization and
|
package/dist/storage.js
CHANGED
|
@@ -1 +1,40 @@
|
|
|
1
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Evaluate the RFC 0007 predicate against a gate token. A process that
|
|
3
|
+
* declares the pending checkpoint may serve while it backfills; any other
|
|
4
|
+
* process is refused, and a database newer than the running build is always
|
|
5
|
+
* refused.
|
|
6
|
+
*/
|
|
7
|
+
export function serveGateRefusal(gate, runningSchemaVersion, checkpoints) {
|
|
8
|
+
if (gate.storedSchemaVersion > runningSchemaVersion) {
|
|
9
|
+
return {
|
|
10
|
+
kind: 'schema',
|
|
11
|
+
stored: gate.storedSchemaVersion,
|
|
12
|
+
running: runningSchemaVersion,
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
for (const pending of gate.pending) {
|
|
16
|
+
const declared = (checkpoints ?? []).some((declaration) => declaration.partition === pending.partition &&
|
|
17
|
+
declaration.name === pending.name);
|
|
18
|
+
if (!declared) {
|
|
19
|
+
return {
|
|
20
|
+
kind: 'checkpoint',
|
|
21
|
+
partition: pending.partition,
|
|
22
|
+
name: pending.name,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return undefined;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* RFC 0007: compare two gate tokens read around a data read. The token binds
|
|
30
|
+
* the published schema version and the partition log epoch, so a restore that
|
|
31
|
+
* presents V → other → V is distinguishable. Returns a refusal when either
|
|
32
|
+
* changed across the read.
|
|
33
|
+
*/
|
|
34
|
+
export function serveGateTokenChanged(before, after) {
|
|
35
|
+
if (before.storedSchemaVersion !== after.storedSchemaVersion ||
|
|
36
|
+
before.logEpoch !== after.logEpoch) {
|
|
37
|
+
return { kind: 'epoch' };
|
|
38
|
+
}
|
|
39
|
+
return undefined;
|
|
40
|
+
}
|
package/dist/validate.d.ts
CHANGED
|
@@ -95,7 +95,19 @@ export interface ValidateCommitInput {
|
|
|
95
95
|
* staged, but before commit-log/idempotency append and transaction commit.
|
|
96
96
|
*/
|
|
97
97
|
export type CommitValidator = (input: ValidateCommitInput) => void | Promise<void>;
|
|
98
|
-
/**
|
|
98
|
+
/**
|
|
99
|
+
* Ambient context a validator may consult (§6.7).
|
|
100
|
+
*
|
|
101
|
+
* A validator runs inside the push transaction. On a single-connection
|
|
102
|
+
* executor (SQLite, PGlite) the storage serializes authoritative queries
|
|
103
|
+
* behind that transaction, so a validator that calls `queryAuthoritative`
|
|
104
|
+
* waits for the transaction it is already inside and the push never
|
|
105
|
+
* completes. On a pool-backed executor (`pg`, `Bun.sql`) the query takes a
|
|
106
|
+
* second connection and returns committed state without the candidate write.
|
|
107
|
+
* Either way a rule that must read rows belongs in the whole-commit
|
|
108
|
+
* `CommitValidator`, whose reader runs get and scan operations on the same
|
|
109
|
+
* transaction (§6.8).
|
|
110
|
+
*/
|
|
99
111
|
export interface ValidateContext {
|
|
100
112
|
/** Host-authenticated actor (§1.1) performing the write. */
|
|
101
113
|
readonly actorId: string;
|
|
@@ -110,6 +122,13 @@ export interface ValidateContext {
|
|
|
110
122
|
* any other error is treated as a rejection with a generic
|
|
111
123
|
* `sync.constraint_violation` code (a validator SHOULD throw
|
|
112
124
|
* `ValidationRejection` to control the code). MUST NOT mutate the row.
|
|
125
|
+
*
|
|
126
|
+
* The hook runs inside the push transaction and cannot read candidate rows
|
|
127
|
+
* from an authoritative query: on a single-connection executor
|
|
128
|
+
* `queryAuthoritative` serializes behind that transaction and the push never
|
|
129
|
+
* completes, and on a pool-backed executor it reads committed state without
|
|
130
|
+
* the candidate write. Read rows with the whole-commit `CommitValidator`
|
|
131
|
+
* reader instead (§6.8).
|
|
113
132
|
*/
|
|
114
133
|
export type Validator = (op: ValidateOperation, ctx: ValidateContext) => void | Promise<void>;
|
|
115
134
|
/** Table name → validator (§6.7). Absent tables are unvalidated. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@syncular/server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.23.0",
|
|
4
4
|
"description": "Syncular server: handleSyncRequest + storage/auth interfaces for the sync protocol",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Benjamin Kniffler",
|
|
@@ -68,7 +68,7 @@
|
|
|
68
68
|
"verify:node": "cd ../.. && bun build ./packages/server/test/sqlite-runtime/verify-node.mjs --target=node --conditions=bun --outfile=./packages/server/.verify-node.built.mjs && node ./packages/server/.verify-node.built.mjs"
|
|
69
69
|
},
|
|
70
70
|
"dependencies": {
|
|
71
|
-
"@syncular/core": "0.
|
|
71
|
+
"@syncular/core": "0.23.0"
|
|
72
72
|
},
|
|
73
73
|
"devDependencies": {
|
|
74
74
|
"@electric-sql/pglite": "^0.5.4"
|
package/src/admin.ts
CHANGED
|
@@ -22,6 +22,7 @@ import { DEFAULT_RETENTION, type RetentionPolicy } from './prune';
|
|
|
22
22
|
import { compileSchema, type ServerSchema } from './schema';
|
|
23
23
|
import type { SegmentStore, SegmentStoreStats } from './segment-store';
|
|
24
24
|
import type {
|
|
25
|
+
CheckpointDeclaration,
|
|
25
26
|
ClientRecord,
|
|
26
27
|
CommitMetadata,
|
|
27
28
|
ReactionStatus,
|
|
@@ -157,6 +158,8 @@ export interface SyncularAdminOptions {
|
|
|
157
158
|
* a storage instance that has not served a sync request yet.
|
|
158
159
|
*/
|
|
159
160
|
readonly schema?: ServerSchema;
|
|
161
|
+
/** RFC 0007: declarations `inspectRow` carries into `ensureSchema`. */
|
|
162
|
+
readonly checkpoints?: readonly CheckpointDeclaration[];
|
|
160
163
|
/** The event ring feeding the event tail. Absent ⇒ `events()` is empty. */
|
|
161
164
|
readonly ring?: RingBufferEvents;
|
|
162
165
|
readonly segments?: SegmentStore;
|
|
@@ -218,6 +221,7 @@ function toAdminClient(
|
|
|
218
221
|
export class SyncularAdmin {
|
|
219
222
|
readonly #storage: ServerStorage;
|
|
220
223
|
readonly #schema?: ServerSchema;
|
|
224
|
+
readonly #checkpoints?: readonly CheckpointDeclaration[];
|
|
221
225
|
readonly #ring?: RingBufferEvents;
|
|
222
226
|
readonly #segments?: SegmentStore;
|
|
223
227
|
readonly #blobs?: BlobStore;
|
|
@@ -228,6 +232,8 @@ export class SyncularAdmin {
|
|
|
228
232
|
constructor(options: SyncularAdminOptions) {
|
|
229
233
|
this.#storage = options.storage;
|
|
230
234
|
if (options.schema !== undefined) this.#schema = options.schema;
|
|
235
|
+
if (options.checkpoints !== undefined)
|
|
236
|
+
this.#checkpoints = options.checkpoints;
|
|
231
237
|
if (options.ring !== undefined) this.#ring = options.ring;
|
|
232
238
|
if (options.segments !== undefined) this.#segments = options.segments;
|
|
233
239
|
if (options.blobs !== undefined) this.#blobs = options.blobs;
|
|
@@ -248,6 +254,9 @@ export class SyncularAdmin {
|
|
|
248
254
|
return new SyncularAdmin({
|
|
249
255
|
storage: config.storage,
|
|
250
256
|
schema: config.schema,
|
|
257
|
+
...(config.checkpoints !== undefined
|
|
258
|
+
? { checkpoints: config.checkpoints }
|
|
259
|
+
: {}),
|
|
251
260
|
segments: config.segments,
|
|
252
261
|
...(config.blobs !== undefined ? { blobs: config.blobs } : {}),
|
|
253
262
|
...(config.leases !== undefined ? { leases: config.leases.store } : {}),
|
|
@@ -352,7 +361,10 @@ export class SyncularAdmin {
|
|
|
352
361
|
'storage',
|
|
353
362
|
);
|
|
354
363
|
if (this.#schema !== undefined) {
|
|
355
|
-
await this.#storage.ensureSchema(
|
|
364
|
+
await this.#storage.ensureSchema(
|
|
365
|
+
compileSchema(this.#schema),
|
|
366
|
+
this.#checkpoints,
|
|
367
|
+
);
|
|
356
368
|
}
|
|
357
369
|
const row = await read(partition, table, rowId);
|
|
358
370
|
if (row === undefined) {
|
package/src/blob-handlers.ts
CHANGED
|
@@ -233,7 +233,7 @@ async function downloadBlob(
|
|
|
233
233
|
const schema = compileSchema(ctx.schema);
|
|
234
234
|
// A blob download can hit a fresh storage instance before any sync
|
|
235
235
|
// request has run (Workers): make sure the row tables exist first.
|
|
236
|
-
await ctx.storage.ensureSchema(schema);
|
|
236
|
+
await ctx.storage.ensureSchema(schema, ctx.checkpoints);
|
|
237
237
|
const rows = await listRows.call(ctx.storage, ctx.partition, blobId);
|
|
238
238
|
let authorized = false;
|
|
239
239
|
for (const row of rows) {
|
package/src/context.ts
CHANGED
|
@@ -19,6 +19,7 @@ import type {
|
|
|
19
19
|
} from './signed-url';
|
|
20
20
|
import type { SqliteImageBuilder } from './sqlite-image';
|
|
21
21
|
import type {
|
|
22
|
+
CheckpointDeclaration,
|
|
22
23
|
PartitionRegistryEntry,
|
|
23
24
|
ServerStorage,
|
|
24
25
|
StoredCommit,
|
|
@@ -96,6 +97,13 @@ export interface RealtimeNotifier {
|
|
|
96
97
|
export interface SyncServerConfig {
|
|
97
98
|
readonly schema: ServerSchema;
|
|
98
99
|
readonly storage: ServerStorage;
|
|
100
|
+
/**
|
|
101
|
+
* RFC 0007: the backfill checkpoints this host installs. Every readiness
|
|
102
|
+
* path carries this set into `ensureSchema`. A process that omits it
|
|
103
|
+
* declares nothing, and the storage refuses to serve a partition whose
|
|
104
|
+
* stored state shows a checkpoint that is not activated.
|
|
105
|
+
*/
|
|
106
|
+
readonly checkpoints?: readonly CheckpointDeclaration[];
|
|
99
107
|
readonly segments: SegmentStore;
|
|
100
108
|
/**
|
|
101
109
|
* Blob store (§5.9). Absent ⇒ blobs unsupported: a table with a
|
package/src/d1-storage.ts
CHANGED
|
@@ -90,6 +90,7 @@ import {
|
|
|
90
90
|
import type {
|
|
91
91
|
AuthoritativeQueryRequest,
|
|
92
92
|
AuthoritativeQueryResult,
|
|
93
|
+
CheckpointDeclaration,
|
|
93
94
|
ClientCursorInfo,
|
|
94
95
|
ClientRecord,
|
|
95
96
|
ClientSubscription,
|
|
@@ -111,8 +112,10 @@ import type {
|
|
|
111
112
|
ScopeActivityQuery,
|
|
112
113
|
ScopeCommitActivity,
|
|
113
114
|
ServerStorage,
|
|
115
|
+
ServeGate,
|
|
114
116
|
StorageTransaction,
|
|
115
117
|
StoredCommit,
|
|
118
|
+
StoredCheckpoint,
|
|
116
119
|
StoredPushResult,
|
|
117
120
|
StoredReaction,
|
|
118
121
|
StoredRow,
|
|
@@ -447,6 +450,31 @@ class D1Transaction implements StorageTransaction {
|
|
|
447
450
|
this.#pushApplyCheckpoint = this.#buffer.length;
|
|
448
451
|
}
|
|
449
452
|
|
|
453
|
+
advanceCheckpoint(
|
|
454
|
+
_name: string,
|
|
455
|
+
_ownerEpoch: number,
|
|
456
|
+
_watermark: number,
|
|
457
|
+
_observedRows: number,
|
|
458
|
+
_nowMs: number,
|
|
459
|
+
): Promise<boolean> {
|
|
460
|
+
// D1 installs no fence and declares no checkpoints in this release.
|
|
461
|
+
return Promise.reject(
|
|
462
|
+
new StorageQueryError('sync.storage.checkpoint_unsupported'),
|
|
463
|
+
);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
async readServeGate(_runningSchemaVersion: number): Promise<ServeGate> {
|
|
467
|
+
this.#assertOpen();
|
|
468
|
+
const marker = await this.#db
|
|
469
|
+
.prepare('SELECT schema_version FROM sync_schema_meta WHERE id=1')
|
|
470
|
+
.first<{ schema_version: number }>();
|
|
471
|
+
return {
|
|
472
|
+
storedSchemaVersion: marker?.schema_version ?? 0,
|
|
473
|
+
logEpoch: undefined,
|
|
474
|
+
pending: [],
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
|
|
450
478
|
async commitRejectedPushResult(
|
|
451
479
|
clientId: string,
|
|
452
480
|
clientCommitId: string,
|
|
@@ -834,7 +862,15 @@ export class D1ServerStorage implements ServerStorage {
|
|
|
834
862
|
return table;
|
|
835
863
|
}
|
|
836
864
|
|
|
837
|
-
async ensureSchema(
|
|
865
|
+
async ensureSchema(
|
|
866
|
+
schema: CompiledSchema,
|
|
867
|
+
checkpoints?: readonly CheckpointDeclaration[],
|
|
868
|
+
): Promise<void> {
|
|
869
|
+
if (checkpoints !== undefined && checkpoints.length > 0) {
|
|
870
|
+
// No database-side fence can be installed atomically with schema
|
|
871
|
+
// visibility here; refuse instead of accepting a weaker guarantee.
|
|
872
|
+
throw new StorageQueryError('sync.storage.checkpoint_unsupported');
|
|
873
|
+
}
|
|
838
874
|
if (this.#schemaVersion === schema.version) {
|
|
839
875
|
await this.#schemaDatabase().batch([]);
|
|
840
876
|
return;
|
|
@@ -1427,7 +1463,11 @@ export class D1ServerStorage implements ServerStorage {
|
|
|
1427
1463
|
async queryAuthoritative(
|
|
1428
1464
|
partition: string,
|
|
1429
1465
|
query: AuthoritativeQueryRequest,
|
|
1466
|
+
_checkpoints?: readonly CheckpointDeclaration[],
|
|
1430
1467
|
): Promise<AuthoritativeQueryResult> {
|
|
1468
|
+
// D1 declares no checkpoints. `#schemaDatabase` prepends a guard that
|
|
1469
|
+
// re-checks the published marker inside this same batch, so condition 2
|
|
1470
|
+
// is already evaluated with the query (§2.4 D1 schema readiness).
|
|
1431
1471
|
const db = this.#schemaDatabase();
|
|
1432
1472
|
if (this.#tables === undefined) {
|
|
1433
1473
|
throw new Error(
|
|
@@ -1591,6 +1631,108 @@ export class D1ServerStorage implements ServerStorage {
|
|
|
1591
1631
|
return row?.seq ?? 0;
|
|
1592
1632
|
}
|
|
1593
1633
|
|
|
1634
|
+
async readCheckpoints(_partition: string): Promise<StoredCheckpoint[]> {
|
|
1635
|
+
// D1 supports no declared backfill checkpoints in this release: migrations
|
|
1636
|
+
// run out of band and no database-side fence can be installed atomically
|
|
1637
|
+
// with schema visibility. No checkpoint row can exist.
|
|
1638
|
+
return [];
|
|
1639
|
+
}
|
|
1640
|
+
|
|
1641
|
+
async readServeGate(
|
|
1642
|
+
partition: string,
|
|
1643
|
+
_runningSchemaVersion: number,
|
|
1644
|
+
): Promise<ServeGate> {
|
|
1645
|
+
// D1 has no checkpoints. The published marker is the only gate input;
|
|
1646
|
+
// `#schemaDatabase` already re-checks it per protected batch.
|
|
1647
|
+
const marker = await this.#db
|
|
1648
|
+
.prepare('SELECT schema_version FROM sync_schema_meta WHERE id=1')
|
|
1649
|
+
.first<{ schema_version: number }>();
|
|
1650
|
+
return {
|
|
1651
|
+
storedSchemaVersion: marker?.schema_version ?? 0,
|
|
1652
|
+
logEpoch: await this.getPartitionLogEpoch(partition),
|
|
1653
|
+
pending: [],
|
|
1654
|
+
};
|
|
1655
|
+
}
|
|
1656
|
+
|
|
1657
|
+
async declareCheckpoint(
|
|
1658
|
+
_partition: string,
|
|
1659
|
+
_name: string,
|
|
1660
|
+
_schemaVersion: number,
|
|
1661
|
+
_nowMs: number,
|
|
1662
|
+
): Promise<StoredCheckpoint> {
|
|
1663
|
+
throw new StorageQueryError('sync.storage.checkpoint_unsupported');
|
|
1664
|
+
}
|
|
1665
|
+
|
|
1666
|
+
async activateCheckpoint(
|
|
1667
|
+
_partition: string,
|
|
1668
|
+
_name: string,
|
|
1669
|
+
_ownerEpoch: number,
|
|
1670
|
+
_watermark: number,
|
|
1671
|
+
_sources: readonly string[],
|
|
1672
|
+
_nowMs: number,
|
|
1673
|
+
): Promise<'activated' | 'stale' | 'unverifiable'> {
|
|
1674
|
+
throw new StorageQueryError('sync.storage.checkpoint_unsupported');
|
|
1675
|
+
}
|
|
1676
|
+
|
|
1677
|
+
async claimCheckpoint(
|
|
1678
|
+
_partition: string,
|
|
1679
|
+
_name: string,
|
|
1680
|
+
_schemaVersion: number,
|
|
1681
|
+
_nowMs: number,
|
|
1682
|
+
): Promise<StoredCheckpoint> {
|
|
1683
|
+
throw new StorageQueryError('sync.storage.checkpoint_unsupported');
|
|
1684
|
+
}
|
|
1685
|
+
|
|
1686
|
+
async advanceCheckpoint(
|
|
1687
|
+
_partition: string,
|
|
1688
|
+
_name: string,
|
|
1689
|
+
_ownerEpoch: number,
|
|
1690
|
+
_watermark: number,
|
|
1691
|
+
_observedRows: number,
|
|
1692
|
+
_nowMs: number,
|
|
1693
|
+
): Promise<boolean> {
|
|
1694
|
+
throw new StorageQueryError('sync.storage.checkpoint_unsupported');
|
|
1695
|
+
}
|
|
1696
|
+
|
|
1697
|
+
async sourceCoverageSeq(
|
|
1698
|
+
partition: string,
|
|
1699
|
+
tables: readonly string[],
|
|
1700
|
+
): Promise<number> {
|
|
1701
|
+
if (tables.length === 0) return 0;
|
|
1702
|
+
const tableParams = tables.map(() => '?').join(',');
|
|
1703
|
+
const row = await this.#db
|
|
1704
|
+
.prepare(
|
|
1705
|
+
`SELECT max(commit_seq) AS seq FROM sync_changes
|
|
1706
|
+
WHERE partition=? AND tbl IN (${tableParams})`,
|
|
1707
|
+
)
|
|
1708
|
+
.bind(partition, ...tables)
|
|
1709
|
+
.first<{ seq: number | null }>();
|
|
1710
|
+
return row?.seq ?? 0;
|
|
1711
|
+
}
|
|
1712
|
+
|
|
1713
|
+
async hasSourceChangesAbove(
|
|
1714
|
+
partition: string,
|
|
1715
|
+
tables: readonly string[],
|
|
1716
|
+
seq: number,
|
|
1717
|
+
): Promise<'clean' | 'changed' | 'unverifiable'> {
|
|
1718
|
+
// Read the horizon before the window scan: a horizon past `seq` means the
|
|
1719
|
+
// history that would answer the question is gone, so an empty scan proves
|
|
1720
|
+
// nothing and must not be reported as `clean`.
|
|
1721
|
+
const horizon = await this.getHorizonSeq(partition);
|
|
1722
|
+
if (tables.length === 0) return 'clean';
|
|
1723
|
+
const tableParams = tables.map(() => '?').join(',');
|
|
1724
|
+
const hit = await this.#db
|
|
1725
|
+
.prepare(
|
|
1726
|
+
`SELECT 1 AS hit FROM sync_changes
|
|
1727
|
+
WHERE partition=? AND tbl IN (${tableParams}) AND commit_seq>?
|
|
1728
|
+
LIMIT 1`,
|
|
1729
|
+
)
|
|
1730
|
+
.bind(partition, ...tables, seq)
|
|
1731
|
+
.first<{ hit: number }>();
|
|
1732
|
+
if (hit !== null) return 'changed';
|
|
1733
|
+
return horizon > seq ? 'unverifiable' : 'clean';
|
|
1734
|
+
}
|
|
1735
|
+
|
|
1594
1736
|
async getRow(
|
|
1595
1737
|
partition: string,
|
|
1596
1738
|
table: string,
|
package/src/errors.ts
CHANGED
|
@@ -167,6 +167,16 @@ export const ERROR_CATALOG: Readonly<Record<string, ErrorCatalogEntry>> = {
|
|
|
167
167
|
recommendedAction: 'retryLater',
|
|
168
168
|
httpStatus: 500,
|
|
169
169
|
},
|
|
170
|
+
// §2.4 on a non-D1 backend: the server refuses while a declared backfill
|
|
171
|
+
// checkpoint is incomplete or the database is newer than the running build.
|
|
172
|
+
// Formerly host-only; promoted to the §10.2 catalogue so an in-flight
|
|
173
|
+
// request can be refused with a distinguishable identity.
|
|
174
|
+
'sync.schema_not_ready': {
|
|
175
|
+
category: 'internal',
|
|
176
|
+
retryable: true,
|
|
177
|
+
recommendedAction: 'retryLater',
|
|
178
|
+
httpStatus: 503,
|
|
179
|
+
},
|
|
170
180
|
'sync.too_many_operations': {
|
|
171
181
|
category: 'invalid-request',
|
|
172
182
|
retryable: false,
|
package/src/handler.ts
CHANGED
|
@@ -51,10 +51,17 @@ import {
|
|
|
51
51
|
subscriptionSection,
|
|
52
52
|
} from './pull';
|
|
53
53
|
import { type ProcessedPushCommit, processPushCommitWithTrace } from './push';
|
|
54
|
+
import { serveNotReadyError } from './readiness';
|
|
54
55
|
import type { CompiledSchema } from './schema';
|
|
55
56
|
import { compileSchema } from './schema';
|
|
56
57
|
import { computeEffective, type ResolvedScopes } from './scopes';
|
|
57
|
-
import
|
|
58
|
+
import { StorageQueryError } from './storage-errors';
|
|
59
|
+
import {
|
|
60
|
+
type ClientSubscription,
|
|
61
|
+
type PartitionRegistryEntry,
|
|
62
|
+
serveGateRefusal,
|
|
63
|
+
serveGateTokenChanged,
|
|
64
|
+
} from './storage';
|
|
58
65
|
|
|
59
66
|
interface RequestPlan {
|
|
60
67
|
readonly wireVersion: number;
|
|
@@ -709,13 +716,64 @@ async function createStreamCore(
|
|
|
709
716
|
const schema = compileSchema(ctx.schema);
|
|
710
717
|
// Relational row tables: create/
|
|
711
718
|
// migrate on first contact; memoized per storage instance thereafter.
|
|
712
|
-
|
|
719
|
+
try {
|
|
720
|
+
await ctx.storage.ensureSchema(schema, ctx.checkpoints);
|
|
721
|
+
} catch (error) {
|
|
722
|
+
if (
|
|
723
|
+
error instanceof StorageQueryError &&
|
|
724
|
+
error.code === 'sync.storage.checkpoint_incomplete'
|
|
725
|
+
) {
|
|
726
|
+
const refusal = serveGateRefusal(
|
|
727
|
+
await ctx.storage.readServeGate(ctx.partition, schema.version),
|
|
728
|
+
schema.version,
|
|
729
|
+
ctx.checkpoints,
|
|
730
|
+
);
|
|
731
|
+
throw refusal !== undefined ? serveNotReadyError(refusal) : error;
|
|
732
|
+
}
|
|
733
|
+
throw error;
|
|
734
|
+
}
|
|
735
|
+
// RFC 0007 serve gate: re-read stored state on every request so an
|
|
736
|
+
// already-serving process notices a migration or a newly declared
|
|
737
|
+
// checkpoint. Read after `touchAuthenticatedPartition` establishes the
|
|
738
|
+
// partition's log epoch, so the entry token is the epoch this request
|
|
739
|
+
// serves under. Nothing has escaped at this point; the push transaction
|
|
740
|
+
// re-evaluates the gate under the partition lock.
|
|
713
741
|
const registry = await touchAuthenticatedPartition(ctx);
|
|
742
|
+
const gate = await ctx.storage.readServeGate(ctx.partition, schema.version);
|
|
743
|
+
const refusal = serveGateRefusal(gate, schema.version, ctx.checkpoints);
|
|
744
|
+
if (refusal !== undefined) throw serveNotReadyError(refusal);
|
|
714
745
|
const plan = await planRequest(request, ctx, schema, registry);
|
|
715
|
-
if (events === undefined) return streamResponse(plan, ctx, schema);
|
|
716
746
|
const report: RequestReport = { outcome: 'ok' };
|
|
747
|
+
// RFC 0007 read-verify-refuse for the streamed read path: the generator
|
|
748
|
+
// builds the whole response (its data reads) before yielding the first
|
|
749
|
+
// frame, then compares the gate token across those reads. Nothing escapes
|
|
750
|
+
// before the verification, and the buffered size is bounded by the request's
|
|
751
|
+
// pull limits and the server's inline segment cap. Every request verifies,
|
|
752
|
+
// including a mixed push+pull: a write that already applied when the token
|
|
753
|
+
// changed is durable and replayable under the same commit id / idempotency
|
|
754
|
+
// key, so refusing the request is safe (proved in the serve-gate lane).
|
|
755
|
+
const verified = async function* (): AsyncGenerator<Uint8Array> {
|
|
756
|
+
const buffered: Uint8Array[] = [];
|
|
757
|
+
for await (const chunk of streamResponse(plan, ctx, schema, report)) {
|
|
758
|
+
buffered.push(chunk);
|
|
759
|
+
}
|
|
760
|
+
const after = await ctx.storage.readServeGate(
|
|
761
|
+
ctx.partition,
|
|
762
|
+
schema.version,
|
|
763
|
+
);
|
|
764
|
+
const changed = serveGateTokenChanged(gate, after);
|
|
765
|
+
const afterRefusal = serveGateRefusal(
|
|
766
|
+
after,
|
|
767
|
+
schema.version,
|
|
768
|
+
ctx.checkpoints,
|
|
769
|
+
);
|
|
770
|
+
if (afterRefusal !== undefined) throw serveNotReadyError(afterRefusal);
|
|
771
|
+
if (changed !== undefined) throw serveNotReadyError(changed);
|
|
772
|
+
for (const chunk of buffered) yield chunk;
|
|
773
|
+
};
|
|
774
|
+
if (events === undefined) return verified();
|
|
717
775
|
return instrumentedStream(
|
|
718
|
-
|
|
776
|
+
verified(),
|
|
719
777
|
ctx,
|
|
720
778
|
events,
|
|
721
779
|
plan,
|