@syncular/server 0.22.0 → 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.
@@ -26,7 +26,11 @@ export type StorageQueryErrorCode =
26
26
  | 'sync.storage.invalid_limit'
27
27
  | 'sync.storage.prune_epoch_mismatch'
28
28
  | 'sync.storage.partition_unregistered'
29
- | 'sync.storage.invalid_prune_cursor';
29
+ | 'sync.storage.invalid_prune_cursor'
30
+ | 'sync.storage.checkpoint_not_declared'
31
+ | 'sync.storage.checkpoint_unsupported'
32
+ | 'sync.storage.checkpoint_fence_missing'
33
+ | 'sync.storage.checkpoint_incomplete';
30
34
 
31
35
  const STORAGE_QUERY_MESSAGES: Readonly<Record<StorageQueryErrorCode, string>> =
32
36
  {
@@ -44,6 +48,14 @@ const STORAGE_QUERY_MESSAGES: Readonly<Record<StorageQueryErrorCode, string>> =
44
48
  'pruning requires a registered partition',
45
49
  'sync.storage.invalid_prune_cursor':
46
50
  'pruning requires a non-negative safe integer cursor and a non-empty log epoch',
51
+ 'sync.storage.checkpoint_not_declared':
52
+ 'checkpoint claim requires a declared, not yet activated row',
53
+ 'sync.storage.checkpoint_unsupported':
54
+ 'this storage backend cannot declare backfill checkpoints',
55
+ 'sync.storage.checkpoint_fence_missing':
56
+ 'checkpoint activation requires the writer fence raised at declaration',
57
+ 'sync.storage.checkpoint_incomplete':
58
+ 'a declared backfill checkpoint is not activated and this caller did not declare it',
47
59
  'sync.storage.scan_requires_scope':
48
60
  'scope-indexed row scans require at least one scope variable',
49
61
  'sync.storage.index_not_found':
package/src/storage.ts CHANGED
@@ -31,6 +31,120 @@ export interface CommitPruneResult {
31
31
  readonly removedCommits: number;
32
32
  }
33
33
 
34
+ /** RFC 0007 backfill lifecycle. `activated` is terminal within a schema version. */
35
+ export type CheckpointState = 'declared' | 'backfilling' | 'activated';
36
+
37
+ /**
38
+ * Host-owned checkpoint installation carried on `ensureSchema`: the schema
39
+ * bump, the `declared` row, and the raised writer fence commit together.
40
+ */
41
+ export interface CheckpointDeclaration {
42
+ readonly partition: string;
43
+ readonly name: string;
44
+ readonly schemaVersion: number;
45
+ }
46
+
47
+ /** One host-storage backfill checkpoint row. Never a synced application row. */
48
+ export interface StoredCheckpoint {
49
+ readonly partition: string;
50
+ readonly name: string;
51
+ readonly schemaVersion: number;
52
+ readonly state: CheckpointState;
53
+ /** Highest source `commit_seq` the projection was derived from. */
54
+ readonly watermark: number;
55
+ /** Monotonic claim counter; a batch is stale when it does not match. */
56
+ readonly ownerEpoch: number;
57
+ readonly observedRows: number;
58
+ readonly updatedAtMs: number;
59
+ }
60
+
61
+ /**
62
+ * RFC 0007 serve-gate token, read inside the transaction that produces the
63
+ * result. `storedSchemaVersion` is the published marker; `logEpoch` is the
64
+ * partition's log generation, so a restore that presents V -> other -> V is
65
+ * still distinguishable. `pending` names every incomplete declared checkpoint
66
+ * at the running schema version, across partitions.
67
+ */
68
+ export interface ServeGate {
69
+ readonly storedSchemaVersion: number;
70
+ readonly logEpoch: string | undefined;
71
+ readonly pending: readonly {
72
+ readonly partition: string;
73
+ readonly name: string;
74
+ }[];
75
+ }
76
+
77
+ /** Why `serveGate` refuses, or `undefined` when this request may serve. */
78
+ export type ServeGateRefusal =
79
+ | {
80
+ readonly kind: 'schema';
81
+ readonly stored: number;
82
+ readonly running: number;
83
+ }
84
+ | {
85
+ readonly kind: 'checkpoint';
86
+ readonly partition: string;
87
+ readonly name: string;
88
+ }
89
+ | {
90
+ /** The token changed between two reads: a restore rotated the epoch. */
91
+ readonly kind: 'epoch';
92
+ };
93
+
94
+ /**
95
+ * Evaluate the RFC 0007 predicate against a gate token. A process that
96
+ * declares the pending checkpoint may serve while it backfills; any other
97
+ * process is refused, and a database newer than the running build is always
98
+ * refused.
99
+ */
100
+ export function serveGateRefusal(
101
+ gate: ServeGate,
102
+ runningSchemaVersion: number,
103
+ checkpoints: readonly CheckpointDeclaration[] | undefined,
104
+ ): ServeGateRefusal | undefined {
105
+ if (gate.storedSchemaVersion > runningSchemaVersion) {
106
+ return {
107
+ kind: 'schema',
108
+ stored: gate.storedSchemaVersion,
109
+ running: runningSchemaVersion,
110
+ };
111
+ }
112
+ for (const pending of gate.pending) {
113
+ const declared = (checkpoints ?? []).some(
114
+ (declaration) =>
115
+ declaration.partition === pending.partition &&
116
+ declaration.name === pending.name,
117
+ );
118
+ if (!declared) {
119
+ return {
120
+ kind: 'checkpoint',
121
+ partition: pending.partition,
122
+ name: pending.name,
123
+ };
124
+ }
125
+ }
126
+ return undefined;
127
+ }
128
+
129
+ /**
130
+ * RFC 0007: compare two gate tokens read around a data read. The token binds
131
+ * the published schema version and the partition log epoch, so a restore that
132
+ * presents V → other → V is distinguishable. Returns a refusal when either
133
+ * changed across the read.
134
+ */
135
+ export function serveGateTokenChanged(
136
+ before: ServeGate,
137
+ after: ServeGate,
138
+ ): ServeGateRefusal | undefined {
139
+ if (
140
+ before.storedSchemaVersion !== after.storedSchemaVersion ||
141
+ before.logEpoch !== after.logEpoch
142
+ ) {
143
+ return { kind: 'epoch' };
144
+ }
145
+ return undefined;
146
+ }
147
+
34
148
  /** The current stored state of a synced row. */
35
149
  export interface StoredRow {
36
150
  readonly rowId: string;
@@ -406,6 +520,27 @@ export interface StorageTransaction {
406
520
  clientCommitId: string,
407
521
  result: StoredPushResult,
408
522
  ): Promise<void>;
523
+ /**
524
+ * Advance this partition's checkpoint only while `owner_epoch` still
525
+ * matches, so the owner-epoch CAS rides inside the caller's transaction.
526
+ * The host calls this from `processPushOperationsWithTrace`'s operation
527
+ * builder: the backfill is an ordinary server-authoritative write, and this
528
+ * CAS commits with that write's row versions and commit-log append. False
529
+ * means abort and write nothing, rather than a throw for a superseded owner.
530
+ */
531
+ advanceCheckpoint(
532
+ name: string,
533
+ ownerEpoch: number,
534
+ watermark: number,
535
+ observedRows: number,
536
+ nowMs: number,
537
+ ): Promise<boolean>;
538
+ /**
539
+ * RFC 0007 serve gate on the caller's own connection, for write paths that
540
+ * already hold the partition lock: the gate and the write share one
541
+ * transaction, so a migration cannot interleave between them.
542
+ */
543
+ readServeGate(runningSchemaVersion: number): Promise<ServeGate>;
409
544
  /**
410
545
  * Blob reference index (§5.9.4) — ADDITIVE, optional. Set the blobIds a
411
546
  * row currently references (empty = clear), replacing any prior entries
@@ -438,7 +573,10 @@ export interface ServerStorage {
438
573
  * before binding a public port; protocol handlers still call this method
439
574
  * lazily as a defensive backstop.
440
575
  */
441
- ensureSchema(schema: CompiledSchema): Promise<void>;
576
+ ensureSchema(
577
+ schema: CompiledSchema,
578
+ checkpoints?: readonly CheckpointDeclaration[],
579
+ ): Promise<void>;
442
580
 
443
581
  /** Create or refresh the authenticated partition registry row (§2.1). */
444
582
  touchPartition(
@@ -491,6 +629,84 @@ export interface ServerStorage {
491
629
  createdBeforeMs: number,
492
630
  ): Promise<number>;
493
631
 
632
+ /** Every declared backfill checkpoint for the partition, ordered by name. */
633
+ readCheckpoints(partition: string): Promise<StoredCheckpoint[]>;
634
+ /**
635
+ * RFC 0007 serve gate: read the published schema version, the partition's
636
+ * log epoch, and every incomplete declared checkpoint at
637
+ * `runningSchemaVersion`. Never memoized; the serve paths call it per
638
+ * request so an already-serving process notices a migration.
639
+ */
640
+ readServeGate(
641
+ partition: string,
642
+ runningSchemaVersion: number,
643
+ ): Promise<ServeGate>;
644
+ /**
645
+ * Atomically take ownership of a declared or backfilling checkpoint. Increments
646
+ * `owner_epoch` and moves `declared` to `backfilling`. Throws
647
+ * `sync.storage.checkpoint_not_declared` when no claimable row exists (absent,
648
+ * or already `activated`).
649
+ */
650
+ claimCheckpoint(
651
+ partition: string,
652
+ name: string,
653
+ schemaVersion: number,
654
+ nowMs: number,
655
+ ): Promise<StoredCheckpoint>;
656
+ /**
657
+ * Advance the watermark only while `owner_epoch` still matches. Returns false
658
+ * for a superseded owner; the stale case never throws.
659
+ */
660
+ advanceCheckpoint(
661
+ partition: string,
662
+ name: string,
663
+ ownerEpoch: number,
664
+ watermark: number,
665
+ observedRows: number,
666
+ nowMs: number,
667
+ ): Promise<boolean>;
668
+ /** Highest commitSeq carrying a change to one of `tables`; 0 when none. */
669
+ sourceCoverageSeq(
670
+ partition: string,
671
+ tables: readonly string[],
672
+ ): Promise<number>;
673
+ /**
674
+ * Whether any source change sits above `seq`. `unverifiable` when the pruning
675
+ * horizon has passed `seq`, so the history that would answer the question is
676
+ * gone; a pruned window is never reported as `clean`.
677
+ */
678
+ hasSourceChangesAbove(
679
+ partition: string,
680
+ tables: readonly string[],
681
+ seq: number,
682
+ ): Promise<'clean' | 'changed' | 'unverifiable'>;
683
+ /**
684
+ * Install the barrier: insert the checkpoint in `declared` and raise
685
+ * `required_writer_version` for the partition in one transaction. The fence
686
+ * is raised at declaration, before any backfill work, so old writers are
687
+ * rejected for the whole backfill window. Idempotent on an existing declared
688
+ * or backfilling row.
689
+ */
690
+ declareCheckpoint(
691
+ partition: string,
692
+ name: string,
693
+ schemaVersion: number,
694
+ nowMs: number,
695
+ ): Promise<StoredCheckpoint>;
696
+ /**
697
+ * Activate a declared checkpoint under the partition write lock. Refuses when
698
+ * the fence is not already raised at the checkpoint's schema version, when a
699
+ * source change sits above `watermark` (`stale`), or when the pruning horizon
700
+ * has passed `watermark` (`unverifiable`). `activated` is terminal.
701
+ */
702
+ activateCheckpoint(
703
+ partition: string,
704
+ name: string,
705
+ ownerEpoch: number,
706
+ watermark: number,
707
+ sources: readonly string[],
708
+ nowMs: number,
709
+ ): Promise<'activated' | 'stale' | 'unverifiable'>;
494
710
  getRow(
495
711
  partition: string,
496
712
  table: string,
@@ -589,6 +805,7 @@ export interface ServerStorage {
589
805
  queryAuthoritative?(
590
806
  partition: string,
591
807
  query: AuthoritativeQueryRequest,
808
+ checkpoints?: readonly CheckpointDeclaration[],
592
809
  ): Promise<AuthoritativeQueryResult>;
593
810
 
594
811
  /**