clone

a deno framework for local-first web apps that open instantly, work offline and sync when users choose

feat/141-effect-library
lofi / package / testing / scenario / fuzz.ts
fuzz.ts 13.8K
1import { converge } from "./assertions.ts";2import { ScenarioError } from "./errors.ts";3import type { ScenarioApp } from "./headless.ts";4import type { ScenarioTable } from "./peer.ts";5import { runHeadlessScenario, type ScenarioConfig } from "./run.ts";67/** The kinds of operations a fuzz plan is built from. */8export type FuzzOpKind = "insert" | "update" | "remove" | "offline" | "online" | "sync";910/**11 * One step of a fuzz plan. Row-targeting steps reference the insert that12 * created the row by its `ref` — the index of that insert in the plan — since13 * row ids are only assigned when the plan runs.14 */15export type FuzzOp =16 | { kind: "insert"; peer: string; table: string; values: Record<string, unknown>; ref: number }17 | { kind: "update"; peer: string; table: string; ref: number; patch: Record<string, unknown> }18 | { kind: "remove"; peer: string; table: string; ref: number }19 | { kind: "offline"; peer: string }20 | { kind: "online"; peer: string }21 | { kind: "sync" };2223/** One column of a table, as the fuzz generator sees it. */24export interface FuzzColumn {25 /** Column name. */26 name: string;27 /** The compiled column type tag, e.g. `"Text"` or `"Integer"`. */28 type: string;29 /** Whether the column accepts null. */30 nullable: boolean;31 /** Whether the column has a declared default and can be omitted on insert. */32 hasDefault: boolean;33 /** The column's merge strategy, when it is not last-write-wins. */34 mergeStrategy?: string;35 /** The referenced table, for foreign-key columns. */36 references?: string;37}3839/** Input to the pure fuzz-plan generator. */40export interface FuzzPlanInput {41 /** PRNG seed; the same seed always yields the same plan. */42 seed: number;43 /** Number of operations to generate. */44 steps: number;45 /** Peer names participating in the plan. */46 peers: readonly string[];47 /** Column descriptions per table. */48 tables: { readonly [table: string]: readonly FuzzColumn[] };49 /** Relative op-kind weights; omitted kinds use their defaults. */50 weights?: Partial<Record<FuzzOpKind, number>>;51}5253/** A generated fuzz plan: the operations plus what the generator left out. */54export interface FuzzPlan {55 /** The seed the plan was generated from. */56 seed: number;57 /** The operations, in execution order. */58 ops: readonly FuzzOp[];59 /**60 * Tables the generator cannot insert into — e.g. a required foreign-key or61 * merge-strategy column with no default — and therefore skipped entirely.62 */63 skippedTables: readonly string[];64}6566const DEFAULT_WEIGHTS: Record<FuzzOpKind, number> = {67 insert: 4,68 update: 4,69 remove: 2,70 offline: 1.5,71 online: 1.5,72 sync: 1,73};7475const SCALAR_TYPES = new Set(["Text", "Integer", "Float", "Boolean", "Timestamp"]);76const MAX_OFFLINE_OPS = 6;7778function mulberry32(seed: number): () => number {79 let state = seed >>> 0;80 return () => {81 state = (state + 0x6D2B79F5) >>> 0;82 let t = state;83 t = Math.imul(t ^ (t >>> 15), t | 1);84 t ^= t + Math.imul(t ^ (t >>> 7), t | 61);85 return ((t ^ (t >>> 14)) >>> 0) / 4294967296;86 };87}8889function scalarValue(random: () => number, type: string, sequence: number): unknown {90 switch (type) {91 case "Text":92 return `v${sequence}-${Math.floor(random() * 1000)}`;93 case "Integer":94 return Math.floor(random() * 100);95 case "Float":96 return Math.floor(random() * 1000) / 10;97 case "Boolean":98 return random() < 0.5;99 case "Timestamp":100 return new Date(1_700_000_000_000 + Math.floor(random() * 1_000_000) * 1000);101 default:102 return null;103 }104}105106function insertableColumns(columns: readonly FuzzColumn[]): readonly FuzzColumn[] | undefined {107 const generated: FuzzColumn[] = [];108 for (const column of columns) {109 if (column.name === "id") continue;110 const generatable = SCALAR_TYPES.has(column.type) &&111 column.mergeStrategy === undefined && column.references === undefined;112 if (generatable) {113 generated.push(column);114 } else if (!column.hasDefault && !column.nullable) {115 return undefined;116 }117 }118 return generated;119}120121/**122 * Generate a fuzz plan: a deterministic, seed-replayable sequence of inserts,123 * updates, removes, offline windows, and sync barriers across the peers.124 * Updates and removes only target rows their peer has locally created or has125 * observed through a sync barrier; offline windows are bounded; the plan ends126 * with every peer back online.127 */128export function generateFuzzPlan(input: FuzzPlanInput): FuzzPlan {129 const random = mulberry32(input.seed);130 const weights = { ...DEFAULT_WEIGHTS, ...input.weights };131 const tables: { name: string; columns: readonly FuzzColumn[] }[] = [];132 const skippedTables: string[] = [];133 for (const [name, columns] of Object.entries(input.tables)) {134 const insertable = insertableColumns(columns);135 if (insertable === undefined) skippedTables.push(name);136 else tables.push({ name, columns: insertable });137 }138139 const ops: FuzzOp[] = [];140 const inserts: { ref: number; table: string; peer: string }[] = [];141 const barrierKnown = new Set<number>();142 const offlinePeers = new Set<string>();143 const offlineOps = new Map<string, number>();144145 const pick = <T>(items: readonly T[]): T => items[Math.floor(random() * items.length)];146147 const insertValues = (columns: readonly FuzzColumn[]): Record<string, unknown> => {148 const values: Record<string, unknown> = {};149 for (const column of columns) {150 if (column.hasDefault && random() < 0.3) continue;151 if (column.nullable && random() < 0.1) {152 values[column.name] = null;153 continue;154 }155 values[column.name] = scalarValue(random, column.type, ops.length);156 }157 return values;158 };159160 const targetsFor = (peer: string, table: string): readonly number[] =>161 inserts162 .filter((entry) =>163 entry.table === table && (entry.peer === peer || barrierKnown.has(entry.ref))164 )165 .map((entry) => entry.ref);166167 const kindsAvailable = (): FuzzOpKind[] => {168 const kinds: FuzzOpKind[] = [];169 if (tables.length > 0) kinds.push("insert");170 if (inserts.length > 0) kinds.push("update", "remove");171 if (offlinePeers.size < input.peers.length) kinds.push("offline");172 if (offlinePeers.size > 0) kinds.push("online");173 if (offlinePeers.size === 0 && inserts.length > 0) kinds.push("sync");174 return kinds;175 };176177 const weightedKind = (kinds: readonly FuzzOpKind[]): FuzzOpKind => {178 const total = kinds.reduce((sum, kind) => sum + weights[kind], 0);179 let roll = random() * total;180 for (const kind of kinds) {181 roll -= weights[kind];182 if (roll <= 0) return kind;183 }184 return kinds[kinds.length - 1];185 };186187 for (let step = 0; step < input.steps; step++) {188 // Close any offline window that has run its course before choosing an op.189 const overdue = input.peers.find((peer) =>190 offlinePeers.has(peer) && (offlineOps.get(peer) ?? 0) >= MAX_OFFLINE_OPS191 );192 if (overdue !== undefined) {193 offlinePeers.delete(overdue);194 offlineOps.delete(overdue);195 ops.push({ kind: "online", peer: overdue });196 continue;197 }198199 const kinds = kindsAvailable();200 if (kinds.length === 0) break;201 const kind = weightedKind(kinds);202 for (const peer of offlinePeers) offlineOps.set(peer, (offlineOps.get(peer) ?? 0) + 1);203204 if (kind === "insert") {205 const table = pick(tables);206 const peer = pick(input.peers);207 const ref = ops.length;208 ops.push({ kind, peer, table: table.name, values: insertValues(table.columns), ref });209 inserts.push({ ref, table: table.name, peer });210 } else if (kind === "update" || kind === "remove") {211 const peer = pick(input.peers);212 const entry = pick(inserts);213 const targets = targetsFor(peer, entry.table);214 if (targets.length === 0) continue;215 const ref = pick(targets);216 if (kind === "update") {217 const columns = tables.find((table) => table.name === entry.table)?.columns ?? [];218 if (columns.length === 0) continue;219 const column = pick(columns);220 ops.push({221 kind,222 peer,223 table: entry.table,224 ref,225 patch: { [column.name]: scalarValue(random, column.type, ops.length) },226 });227 } else {228 ops.push({ kind, peer, table: entry.table, ref });229 }230 } else if (kind === "offline") {231 const online = input.peers.filter((peer) => !offlinePeers.has(peer));232 const peer = pick(online);233 offlinePeers.add(peer);234 offlineOps.set(peer, 0);235 ops.push({ kind, peer });236 } else if (kind === "online") {237 const peer = pick([...offlinePeers]);238 offlinePeers.delete(peer);239 offlineOps.delete(peer);240 ops.push({ kind, peer });241 } else {242 ops.push({ kind: "sync" });243 for (const entry of inserts) barrierKnown.add(entry.ref);244 }245 }246247 for (const peer of input.peers) {248 if (offlinePeers.has(peer)) ops.push({ kind: "online", peer });249 }250 return { seed: input.seed, ops, skippedTables };251}252253/** Options for a fuzz scenario, extending the scenario config with fuzz knobs. */254export interface FuzzScenarioOptions<A extends ScenarioApp> extends ScenarioConfig<A> {255 /**256 * PRNG seed. Defaults to the `LOFI_FUZZ_SEED` environment variable when257 * set, otherwise a random seed. The seed in use is always part of any258 * failure, so a failing run can be replayed exactly.259 */260 seed?: number;261 /** Number of operations to generate. Defaults to 40. */262 steps?: number;263 /** Tables to fuzz. Defaults to every table the generator can insert into. */264 tables?: readonly string[];265 /** Relative op-kind weights; omitted kinds use their defaults. */266 weights?: Partial<Record<FuzzOpKind, number>>;267}268269function fuzzColumnsOf(270 app: ScenarioApp,271 tables?: readonly string[],272): { [table: string]: readonly FuzzColumn[] } {273 const described: { [table: string]: readonly FuzzColumn[] } = {};274 for (const [name, table] of Object.entries(app.wasmSchema)) {275 if (tables !== undefined && !tables.includes(name)) continue;276 described[name] = table.columns.map((column) => ({277 name: column.name,278 type: column.column_type.type,279 nullable: column.nullable,280 hasDefault: column.default !== undefined,281 mergeStrategy: column.merge_strategy,282 references: column.references,283 }));284 }285 return described;286}287288function resolveSeed(explicit?: number): number {289 if (explicit !== undefined) return explicit;290 try {291 const fromEnv = Deno.env.get("LOFI_FUZZ_SEED");292 if (fromEnv !== undefined && fromEnv !== "") return Number(fromEnv) >>> 0;293 } catch {294 // Env permission not granted: fall through to a random seed.295 }296 return Math.floor(Math.random() * 0xFFFFFFFF);297}298299/**300 * Run one fuzz scenario: generate a plan, execute it through real peers, and301 * assert convergence — across the participating peers and against a fresh302 * canonical reader. Failures carry the seed and the full op trace.303 */304export async function runFuzzScenario<A extends ScenarioApp>(305 options: FuzzScenarioOptions<A>,306): Promise<void> {307 const seed = resolveSeed(options.seed);308 const plan = generateFuzzPlan({309 seed,310 steps: options.steps ?? 40,311 peers: ["alice", "bob"],312 tables: fuzzColumnsOf(options.app, options.tables),313 weights: options.weights,314 });315 const trace = plan.ops.map((op, index) => `${index}: ${JSON.stringify(op)}`).join("\n");316 const report = `seed ${seed} (replay with LOFI_FUZZ_SEED=${seed})\n${trace}`;317318 try {319 await runHeadlessScenario(options, async ({ alice, bob, addPeer }) => {320 const peers = { alice, bob };321 const refToId = new Map<number, string>();322 const facadeFor = (323 peer: typeof alice,324 table: string,325 ): ScenarioTable<Record<string, unknown>, Record<string, unknown>, unknown> =>326 (peer.db as Record<327 string,328 ScenarioTable<Record<string, unknown>, Record<string, unknown>, unknown>329 >)[table];330331 for (const op of plan.ops) {332 if (op.kind === "sync") {333 // A mid-plan barrier settles durability so later ops can target334 // rows across peers; it deliberately does not assert convergence,335 // because a live replica that offline-edited a remotely deleted row336 // legitimately diverges until it restarts.337 await Promise.all([alice.settle(), bob.settle()]);338 continue;339 }340 const peer = peers[op.peer as keyof typeof peers];341 if (op.kind === "offline") {342 await peer.offline();343 } else if (op.kind === "online") {344 await peer.online();345 } else if (op.kind === "insert") {346 const row = await facadeFor(peer, op.table).insert(op.values);347 refToId.set(op.ref, String(row.id));348 } else {349 const id = refToId.get(op.ref);350 if (id === undefined) continue;351 // A write against a row another peer already deleted may be352 // rejected at adjudication; that is a legitimate outcome, and the353 // invariant under test is convergence, not per-op success.354 if (op.kind === "update") {355 await facadeFor(peer, op.table).update(id, op.patch).catch(() => undefined);356 } else {357 await facadeFor(peer, op.table).remove(id).catch(() => undefined);358 }359 }360 }361362 // Restart both peers before the final comparison: what must converge is363 // the durable, adjudicated state every fresh view agrees on, not the364 // session-lifetime echoes of each live replica.365 await Promise.all([alice.settle(), bob.settle()]);366 await Promise.all([alice.restart(), bob.restart()]);367 const canonical = await addPeer("canonical");368 await converge(alice, bob, canonical);369 });370 } catch (error) {371 if (error instanceof ScenarioError) {372 throw new ScenarioError(error.stage, `fuzz run failed: ${error.message}`, {373 cause: error,374 details: report,375 });376 }377 throw new ScenarioError("body", "fuzz run failed", { cause: error, details: report });378 }379}