Files
tech-log-frontend/src/presentation/adapters/query/optimistic-layer-runtime.ts
T

247 lines
7.6 KiB
TypeScript

import { hashKey, type QueryClient } from "@tanstack/react-query";
import type { CacheScopeSnapshot } from "../../../contracts/server-state-scope.ts";
import { OPTIMISTIC_LAYER_BOUNDS } from "../../../contracts/server-state.ts";
export type OptimisticLayerLease = Readonly<{
commit(): void;
rollback(): void;
markUncertain(): void;
reconcile(resolution: "APPLIED" | "NOT_APPLIED"): void;
}>;
type Layer = {
id: number;
status: "pending" | "uncertain" | "committed";
apply(value: unknown): unknown;
};
type OptimisticLayerScope = Pick<CacheScopeSnapshot, "isCurrent"> &
Partial<Pick<CacheScopeSnapshot, "signal">>;
type EntryState = {
queryKey: readonly unknown[];
scope: OptimisticLayerScope;
base: unknown;
layers: Layer[];
disposeScopeListener: (() => void) | null;
};
export function createOptimisticLayerRuntime(queryClient: QueryClient) {
const entries = new Map<string, EntryState>();
let nextId = 1;
let writing = false;
function removeEntry(key: string, entry: EntryState): void {
if (entries.get(key) === entry) entries.delete(key);
entry.disposeScopeListener?.();
entry.disposeScopeListener = null;
}
function writeProjection(entry: EntryState, value: unknown): void {
writing = true;
try {
queryClient.setQueryData(entry.queryKey, value);
} finally {
writing = false;
}
}
queryClient.getQueryCache().subscribe((event) => {
if (
writing ||
event.type !== "updated" ||
!entries.has(event.query.queryHash)
) {
return;
}
const entry = entries.get(event.query.queryHash);
if (!entry) return;
entry.base = event.query.state.data;
project(event.query.queryHash, entry);
});
function project(key: string, entry: EntryState): void {
if (!entry.scope.isCurrent()) {
removeEntry(key, entry);
queryClient.removeQueries({ queryKey: entry.queryKey, exact: true });
return;
}
if (entry.base === undefined && entry.layers.length === 0) {
removeEntry(key, entry);
queryClient.removeQueries({ queryKey: entry.queryKey, exact: true });
return;
}
let value = entry.base;
try {
for (const layer of entry.layers) value = layer.apply(value);
} catch {
removeEntry(key, entry);
return;
}
writeProjection(entry, value);
}
function collapse(key: string, entry: EntryState): void {
while (entry.layers[0]?.status === "committed") {
const committed = entry.layers.shift();
if (!committed) break;
entry.base = committed.apply(entry.base);
}
project(key, entry);
if (entry.layers.length === 0) removeEntry(key, entry);
}
return Object.freeze({
begin<Input>(
queryKey: readonly unknown[],
input: Input,
update: (previous: unknown, input: Input) => unknown,
scope: OptimisticLayerScope,
): OptimisticLayerLease | null {
if (!scope.isCurrent()) return null;
const current = queryClient.getQueryData(queryKey);
const key = hashKey(queryKey);
let entry = entries.get(key);
if (!entry) {
entry = {
queryKey,
scope,
base: current,
layers: [],
disposeScopeListener: null,
};
entries.set(key, entry);
if (scope.signal) {
const selectedEntry = entry;
const discardScope = () => {
removeEntry(key, selectedEntry);
queryClient.removeQueries({ queryKey, exact: true });
};
scope.signal.addEventListener("abort", discardScope, { once: true });
entry.disposeScopeListener = () =>
scope.signal?.removeEventListener("abort", discardScope);
if (scope.signal.aborted || !scope.isCurrent()) {
discardScope();
return null;
}
}
} else if (entry.scope !== scope) {
return null;
}
// §11.5. Overflow falls back to pessimistic execution; an existing
// layer is never silently evicted to make room for a new one.
if (entry.layers.length >= OPTIMISTIC_LAYER_BOUNDS.maxLayersPerQueryKey) {
if (entry.layers.length === 0) removeEntry(key, entry);
return null;
}
const layer: Layer = {
id: nextId,
status: "pending",
apply: (value) => update(value, input),
};
let projected: unknown;
try {
projected = entry.base;
for (const existingLayer of entry.layers) {
projected = existingLayer.apply(projected);
}
projected = layer.apply(projected);
} catch (error) {
if (entry.layers.length > 0) return null;
removeEntry(key, entry);
throw error;
}
if (
estimateLayerBytes(projected) >
OPTIMISTIC_LAYER_BOUNDS.maxSingleLayerBytes
) {
if (entry.layers.length === 0) removeEntry(key, entry);
return null;
}
nextId += 1;
entry.layers.push(layer);
writeProjection(entry, projected);
let state: "pending" | "uncertain" | "settled" = "pending";
const selectedLayer = () => {
if (entries.get(key) !== entry) return undefined;
return entry.layers.find((candidate) => candidate.id === layer.id);
};
return Object.freeze({
commit() {
if (state !== "pending") return;
state = "settled";
const selected = selectedLayer();
if (!selected) return;
selected.status = "committed";
collapse(key, entry);
},
rollback() {
if (state !== "pending") return;
state = "settled";
if (!selectedLayer()) return;
entry.layers = entry.layers.filter(
(candidate) => candidate.id !== layer.id,
);
collapse(key, entry);
},
markUncertain() {
if (state !== "pending") return;
state = "uncertain";
const selected = selectedLayer();
if (!selected) return;
selected.status = "uncertain";
collapse(key, entry);
},
reconcile(resolution) {
if (state !== "uncertain") return;
state = "settled";
const selected = selectedLayer();
if (!selected) return;
if (resolution === "APPLIED") {
selected.status = "committed";
} else {
entry.layers = entry.layers.filter(
(candidate) => candidate.id !== layer.id,
);
}
collapse(key, entry);
},
});
},
});
}
const encoder = new TextEncoder();
/**
* A local, bounded estimate of one optimistic projection. This is not the
* §10.4 query result measurement: it only decides whether a rollback snapshot
* stays inside the layer budget, and it stops as soon as the budget is passed.
*/
function estimateLayerBytes(value: unknown): number {
let total = 0;
const stack: unknown[] = [value];
let visited = 0;
while (stack.length > 0) {
if (visited++ > 4_096) return Number.POSITIVE_INFINITY;
if (total > OPTIMISTIC_LAYER_BOUNDS.maxSingleLayerBytes) return total;
const current = stack.pop();
if (typeof current === "string") {
total += encoder.encode(current).byteLength;
} else if (typeof current === "number" || typeof current === "boolean") {
total += 8;
} else if (Array.isArray(current)) {
total += 8;
for (const item of current) stack.push(item);
} else if (current && typeof current === "object") {
total += 8;
for (const [key, item] of Object.entries(current)) {
total += encoder.encode(key).byteLength;
stack.push(item);
}
}
}
return total;
}