This repository has no description
1// optimistic overlays for eventually-consistent bobbin reads: mutations commit
2// locally (the pds write is authoritative) and reconcile on natural reloads.
3// `key` scopes an overlay to its subject so reused components drop it.
4// TODO(bobbin): read-your-writes (serve at-or-after a commit rev) would let
5// mutations invalidate loads immediately instead of waiting for navigation.
6
7interface OptimisticCountOptions {
8 key: () => string;
9 loaded: () => number | null | undefined;
10}
11
12export interface OptimisticCount {
13 readonly value: number;
14 readonly failed: boolean;
15 adjust(delta: number): void;
16 fail(): void;
17 resetFailure(): void;
18}
19
20export const createOptimisticCount = (options: OptimisticCountOptions): OptimisticCount => {
21 let failed = $state(false);
22 // a bump is a bound: fresher data may pass it, never regress across it.
23 let held = $state<null | { key: string; value: number; up: boolean }>(null);
24 const currentKey = $derived(options.key());
25 const loaded = $derived(Math.max(0, options.loaded() ?? 0));
26
27 const value = $derived.by(() => {
28 if (held === null || held.key !== currentKey) return loaded;
29 return held.up ? Math.max(loaded, held.value) : Math.min(loaded, held.value);
30 });
31
32 $effect(() => {
33 if (held === null) return;
34 const caughtUp = held.up ? loaded >= held.value : loaded <= held.value;
35 if (held.key !== currentKey || caughtUp) held = null;
36 });
37
38 return {
39 get value() {
40 return value;
41 },
42 get failed() {
43 return failed;
44 },
45 adjust(delta) {
46 failed = false;
47 held = { key: currentKey, value: Math.max(0, value + delta), up: delta > 0 };
48 },
49 fail() {
50 failed = true;
51 },
52 resetFailure() {
53 failed = false;
54 }
55 };
56};
57
58interface OptimisticRelationOptions {
59 key: () => string;
60 loadedRkey: () => string | null | undefined;
61}
62
63export interface OptimisticRelation {
64 readonly rkey: string | null;
65 readonly known: boolean;
66 readonly active: boolean;
67 readonly loading: boolean;
68 readonly failed: boolean;
69 begin(): void;
70 created(rkey: string): void;
71 deleted(): void;
72 fail(): void;
73 resetFailure(): void;
74}
75
76export const createOptimisticRelation = (
77 options: OptimisticRelationOptions
78): OptimisticRelation => {
79 let status = $state<"idle" | "loading" | "failed">("idle");
80 let committed = $state<null | { key: string; rkey: string | null }>(null);
81 const currentKey = $derived(options.key());
82 const loaded = $derived(options.loadedRkey());
83 const rkey = $derived(committed?.key === currentKey ? committed.rkey : (loaded ?? null));
84 const known = $derived(loaded !== undefined || committed?.key === currentKey);
85 const active = $derived(rkey !== null);
86
87 $effect(() => {
88 if (committed === null) return;
89 if (committed.key !== currentKey || loaded === committed.rkey) {
90 committed = null;
91 status = "idle";
92 }
93 });
94
95 const set = (next: string | null): void => {
96 status = "idle";
97 committed = { key: currentKey, rkey: next };
98 };
99
100 return {
101 get rkey() {
102 return rkey;
103 },
104 get known() {
105 return known;
106 },
107 get active() {
108 return active;
109 },
110 get loading() {
111 return status === "loading";
112 },
113 get failed() {
114 return status === "failed";
115 },
116 begin() {
117 status = "loading";
118 },
119 created: set,
120 deleted: () => set(null),
121 fail() {
122 status = "failed";
123 },
124 resetFailure() {
125 status = "idle";
126 }
127 };
128};