import { untrack } from "svelte";
// optimistic overlays for eventually-consistent bobbin reads: mutations commit
// locally (the pds write is authoritative) and reconcile on natural reloads.
// `key` scopes an overlay to its subject so reused components drop it.
// TODO(bobbin): read-your-writes (serve at-or-after a commit rev) would let
// mutations invalidate loads immediately instead of waiting for navigation.
export const seededValue = <S, T>(
seed: () => S | Promise<S>,
map: (value: S) => T,
failure: T
): { readonly value: T | undefined } => {
const initial = untrack(() => {
const current = seed();
return current instanceof Promise ? undefined : map(current);
});
let value = $state<T | undefined>(initial);
$effect(() => {
const current = seed();
if (!(current instanceof Promise)) {
value = map(current);
return;
}
let active = true;
value = undefined;
void current
.then((resolved) => {
if (active) value = map(resolved);
})
.catch(() => {
if (active) value = failure;
});
return () => {
active = false;
};
});
return {
get value() {
return value;
}
};
};
interface OptimisticCountOptions {
key: () => string;
loaded: () => number | null | undefined;
}
export interface OptimisticCount {
readonly value: number;
readonly failed: boolean;
adjust(delta: number): void;
fail(): void;
resetFailure(): void;
}
export const createOptimisticCount = (options: OptimisticCountOptions): OptimisticCount => {
let failed = $state(false);
// a bump is a bound: fresher data may pass it, never regress across it.
let held = $state<null | { key: string; value: number; up: boolean }>(null);
const currentKey = $derived(options.key());
const loaded = $derived(Math.max(0, options.loaded() ?? 0));
const value = $derived.by(() => {
if (held === null || held.key !== currentKey) return loaded;
return held.up ? Math.max(loaded, held.value) : Math.min(loaded, held.value);
});
$effect(() => {
if (held === null) return;
const caughtUp = held.up ? loaded >= held.value : loaded <= held.value;
if (held.key !== currentKey || caughtUp) held = null;
});
return {
get value() {
return value;
},
get failed() {
return failed;
},
adjust(delta) {
failed = false;
held = { key: currentKey, value: Math.max(0, value + delta), up: delta > 0 };
},
fail() {
failed = true;
},
resetFailure() {
failed = false;
}
};
};
interface OptimisticRelationOptions {
key: () => string;
loadedRkey: () => string | null | undefined;
}
export interface OptimisticRelation {
readonly rkey: string | null;
readonly known: boolean;
readonly active: boolean;
readonly loading: boolean;
readonly failed: boolean;
begin(intent?: boolean): void;
created(rkey: string): void;
deleted(): void;
fail(): void;
resetFailure(): void;
}
export const createOptimisticRelation = (
options: OptimisticRelationOptions
): OptimisticRelation => {
let status = $state<"idle" | "loading" | "failed">("idle");
let pending = $state<boolean | null>(null);
let committed = $state<null | { key: string; rkey: string | null }>(null);
const currentKey = $derived(options.key());
const loaded = $derived(options.loadedRkey());
const rkey = $derived(committed?.key === currentKey ? committed.rkey : (loaded ?? null));
const known = $derived(loaded !== undefined || committed?.key === currentKey);
const active = $derived(pending ?? rkey !== null);
$effect(() => {
if (committed === null) return;
if (committed.key !== currentKey || loaded === committed.rkey) {
committed = null;
status = "idle";
}
});
const set = (next: string | null): void => {
status = "idle";
pending = null;
committed = { key: currentKey, rkey: next };
};
return {
get rkey() {
return rkey;
},
get known() {
return known;
},
get active() {
return active;
},
get loading() {
return status === "loading";
},
get failed() {
return status === "failed";
},
begin(intent) {
status = "loading";
pending = intent ?? null;
},
created: set,
deleted: () => set(null),
fail() {
status = "failed";
pending = null;
},
resetFailure() {
status = "idle";
}
};
};
// call during component init: runs `reset` two seconds after `failed` turns true
export const resetFailureSoon = (failed: () => boolean, reset: () => void): void => {
$effect(() => {
if (!failed()) return;
const timer = setTimeout(reset, 2000);
return () => clearTimeout(timer);
});
};