@demlik/tea 0.1.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.
- package/dist/cache/index.d.ts +151 -0
- package/dist/cache/index.js +53 -0
- package/dist/cache/index.js.map +1 -0
- package/dist/chunk-2YFLOCWX.js +15 -0
- package/dist/chunk-2YFLOCWX.js.map +1 -0
- package/dist/chunk-CMG2KMWX.js +458 -0
- package/dist/chunk-CMG2KMWX.js.map +1 -0
- package/dist/chunk-PYBQREFU.js +95 -0
- package/dist/chunk-PYBQREFU.js.map +1 -0
- package/dist/chunk-PYOCUBQJ.js +196 -0
- package/dist/chunk-PYOCUBQJ.js.map +1 -0
- package/dist/chunk-Y5JM3UUH.js +126 -0
- package/dist/chunk-Y5JM3UUH.js.map +1 -0
- package/dist/chunk-ZRUST6BK.js +62 -0
- package/dist/chunk-ZRUST6BK.js.map +1 -0
- package/dist/circuit-breaker/index.d.ts +196 -0
- package/dist/circuit-breaker/index.js +53 -0
- package/dist/circuit-breaker/index.js.map +1 -0
- package/dist/deadline/index.d.ts +113 -0
- package/dist/deadline/index.js +20 -0
- package/dist/deadline/index.js.map +1 -0
- package/dist/debounce/index.d.ts +114 -0
- package/dist/debounce/index.js +56 -0
- package/dist/debounce/index.js.map +1 -0
- package/dist/devtools/index.d.ts +137 -0
- package/dist/devtools/index.js +217 -0
- package/dist/devtools/index.js.map +1 -0
- package/dist/devtools/styles.css +218 -0
- package/dist/do/index.d.ts +135 -0
- package/dist/do/index.js +54 -0
- package/dist/do/index.js.map +1 -0
- package/dist/extension/index.d.ts +207 -0
- package/dist/extension/index.js +26 -0
- package/dist/extension/index.js.map +1 -0
- package/dist/extension/react.d.ts +121 -0
- package/dist/extension/react.js +69 -0
- package/dist/extension/react.js.map +1 -0
- package/dist/extension/subs/index.d.ts +44 -0
- package/dist/extension/subs/index.js +83 -0
- package/dist/extension/subs/index.js.map +1 -0
- package/dist/extension/test-utils.d.ts +128 -0
- package/dist/extension/test-utils.js +269 -0
- package/dist/extension/test-utils.js.map +1 -0
- package/dist/idempotency/index.d.ts +131 -0
- package/dist/idempotency/index.js +58 -0
- package/dist/idempotency/index.js.map +1 -0
- package/dist/index.d.ts +364 -0
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -0
- package/dist/machine-viz/index.d.ts +78 -0
- package/dist/machine-viz/index.js +139 -0
- package/dist/machine-viz/index.js.map +1 -0
- package/dist/mem/index.d.ts +45 -0
- package/dist/mem/index.js +19 -0
- package/dist/mem/index.js.map +1 -0
- package/dist/node/index.d.ts +134 -0
- package/dist/node/index.js +89 -0
- package/dist/node/index.js.map +1 -0
- package/dist/pbt/arbitraries/index.d.ts +135 -0
- package/dist/pbt/arbitraries/index.js +3 -0
- package/dist/pbt/arbitraries/index.js.map +1 -0
- package/dist/pbt/index.d.ts +28 -0
- package/dist/pbt/index.js +18 -0
- package/dist/pbt/index.js.map +1 -0
- package/dist/pbt/runners/index.d.ts +117 -0
- package/dist/pbt/runners/index.js +4 -0
- package/dist/pbt/runners/index.js.map +1 -0
- package/dist/rate-limit/index.d.ts +115 -0
- package/dist/rate-limit/index.js +45 -0
- package/dist/rate-limit/index.js.map +1 -0
- package/dist/react/index.d.ts +72 -0
- package/dist/react/index.js +33 -0
- package/dist/react/index.js.map +1 -0
- package/dist/recorder/index.d.ts +287 -0
- package/dist/recorder/index.js +157 -0
- package/dist/recorder/index.js.map +1 -0
- package/dist/retry-backoff/index.d.ts +131 -0
- package/dist/retry-backoff/index.js +37 -0
- package/dist/retry-backoff/index.js.map +1 -0
- package/dist/subs/index.d.ts +57 -0
- package/dist/subs/index.js +4 -0
- package/dist/subs/index.js.map +1 -0
- package/dist/testing/index.d.ts +220 -0
- package/dist/testing/index.js +105 -0
- package/dist/testing/index.js.map +1 -0
- package/dist/throttle/index.d.ts +125 -0
- package/dist/throttle/index.js +54 -0
- package/dist/throttle/index.js.map +1 -0
- package/dist/trace-replay/index.d.ts +52 -0
- package/dist/trace-replay/index.js +57 -0
- package/dist/trace-replay/index.js.map +1 -0
- package/dist/types-CwBC5wMn.d.ts +5 -0
- package/dist/work-queue/index.d.ts +56 -0
- package/dist/work-queue/index.js +143 -0
- package/dist/work-queue/index.js.map +1 -0
- package/package.json +184 -0
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// src/machine-viz/index.ts
|
|
2
|
+
function safeId(raw) {
|
|
3
|
+
const cleaned = raw.replace(/[^A-Za-z0-9_]/g, "_");
|
|
4
|
+
if (cleaned === "") return "_empty";
|
|
5
|
+
return /^[0-9]/.test(cleaned) ? `s_${cleaned}` : cleaned;
|
|
6
|
+
}
|
|
7
|
+
function safeLabel(raw) {
|
|
8
|
+
return raw.replace(/["\n\r;:]/g, " ").trim();
|
|
9
|
+
}
|
|
10
|
+
function detectMode(update) {
|
|
11
|
+
const keys = Object.keys(update);
|
|
12
|
+
const firstKey = keys[0];
|
|
13
|
+
if (firstKey === void 0) return "reducer";
|
|
14
|
+
const firstValue = update[firstKey];
|
|
15
|
+
return typeof firstValue === "function" ? "reducer" : "transitions";
|
|
16
|
+
}
|
|
17
|
+
function toMermaid(machine, opts = {}) {
|
|
18
|
+
const direction = opts.direction ?? "TB";
|
|
19
|
+
const stateSamples = opts.samples?.states ?? {};
|
|
20
|
+
const msgSamples = opts.samples?.msgs ?? {};
|
|
21
|
+
const hasCtx = opts.samples !== void 0 && "ctx" in opts.samples;
|
|
22
|
+
const mode = detectMode(machine.update);
|
|
23
|
+
const lines = [];
|
|
24
|
+
if (opts.title !== void 0) {
|
|
25
|
+
lines.push("---", `title: ${opts.title}`, "---");
|
|
26
|
+
}
|
|
27
|
+
lines.push("stateDiagram-v2");
|
|
28
|
+
lines.push(` direction ${direction}`);
|
|
29
|
+
if (mode === "reducer") {
|
|
30
|
+
emitReducer(machine, lines);
|
|
31
|
+
} else {
|
|
32
|
+
emitTransitions(machine, lines, {
|
|
33
|
+
stateSamples,
|
|
34
|
+
msgSamples,
|
|
35
|
+
hasCtx,
|
|
36
|
+
ctx: opts.samples?.ctx
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
emitSubscriptions(machine, lines, stateSamples);
|
|
40
|
+
return lines.join("\n");
|
|
41
|
+
}
|
|
42
|
+
function emitReducer(machine, lines) {
|
|
43
|
+
const msgTypes = Object.keys(machine.update);
|
|
44
|
+
lines.push(" state ReducerMachine {");
|
|
45
|
+
if (msgTypes.length === 0) {
|
|
46
|
+
lines.push(" [*] --> NoMsgs");
|
|
47
|
+
lines.push(' NoMsgs : "(no msg variants \u2014 M is never)"');
|
|
48
|
+
} else {
|
|
49
|
+
for (const m of msgTypes) {
|
|
50
|
+
lines.push(` ${safeId(m)} : ${safeLabel(m)}`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
lines.push(" }");
|
|
54
|
+
lines.push(" note right of ReducerMachine");
|
|
55
|
+
lines.push(" Reducer-form machine: flat dispatch keyed by msg.type.");
|
|
56
|
+
lines.push(
|
|
57
|
+
" No phase graph \u2014 state is not a discriminated union over phases."
|
|
58
|
+
);
|
|
59
|
+
lines.push(` Handled msg types: ${msgTypes.length}`);
|
|
60
|
+
lines.push(" end note");
|
|
61
|
+
}
|
|
62
|
+
function emitTransitions(machine, lines, ctx) {
|
|
63
|
+
const table = machine.update;
|
|
64
|
+
const stateTypes = Object.keys(table);
|
|
65
|
+
for (const st of stateTypes) {
|
|
66
|
+
lines.push(` ${safeId(st)}`);
|
|
67
|
+
}
|
|
68
|
+
if (ctx.hasCtx) {
|
|
69
|
+
const [initState] = machine.init(null, ctx.ctx);
|
|
70
|
+
const initType = initState.type;
|
|
71
|
+
lines.push(` [*] --> ${safeId(initType)}`);
|
|
72
|
+
} else {
|
|
73
|
+
lines.push(
|
|
74
|
+
" %% init edge omitted: no samples.ctx provided to run init(null, ctx)"
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
let anyStructural = false;
|
|
78
|
+
for (const st of stateTypes) {
|
|
79
|
+
const row = table[st];
|
|
80
|
+
if (row === void 0) continue;
|
|
81
|
+
const msgTypes = Object.keys(row);
|
|
82
|
+
for (const mt of msgTypes) {
|
|
83
|
+
const edge = resolveEdge(row, st, mt, ctx);
|
|
84
|
+
if (edge.kind === "resolved") {
|
|
85
|
+
lines.push(
|
|
86
|
+
` ${safeId(edge.from)} --> ${safeId(edge.to)} : ${safeLabel(edge.msg)}`
|
|
87
|
+
);
|
|
88
|
+
} else {
|
|
89
|
+
anyStructural = true;
|
|
90
|
+
lines.push(
|
|
91
|
+
` ${safeId(edge.from)} --> ${safeId(edge.from)} : ${safeLabel(edge.msg)}?`
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
lines.push(" note right of " + safeId(stateTypes[0] ?? "_empty"));
|
|
97
|
+
lines.push(" Transitions-form machine.");
|
|
98
|
+
lines.push(" Edge a --> b : msg = RESOLVED (cell executed with samples).");
|
|
99
|
+
if (anyStructural) {
|
|
100
|
+
lines.push(
|
|
101
|
+
" Self-loop a --> a : msg? = STRUCTURAL (no sample; target unresolved)."
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
lines.push(" end note");
|
|
105
|
+
}
|
|
106
|
+
function resolveEdge(row, stateType, msgType, ctx) {
|
|
107
|
+
const sampleState = ctx.stateSamples[stateType];
|
|
108
|
+
const sampleMsg = ctx.msgSamples[msgType];
|
|
109
|
+
const cell = row[msgType];
|
|
110
|
+
if (sampleState === void 0 || sampleMsg === void 0 || cell === void 0) {
|
|
111
|
+
return { kind: "structural", from: stateType, msg: msgType };
|
|
112
|
+
}
|
|
113
|
+
try {
|
|
114
|
+
const [next] = cell(sampleState, sampleMsg);
|
|
115
|
+
const to = next.type;
|
|
116
|
+
return { kind: "resolved", from: stateType, to, msg: msgType };
|
|
117
|
+
} catch {
|
|
118
|
+
return { kind: "structural", from: stateType, msg: msgType };
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
function emitSubscriptions(machine, lines, stateSamples) {
|
|
122
|
+
if (machine.subscriptions === void 0) return;
|
|
123
|
+
const stateTypes = Object.keys(stateSamples);
|
|
124
|
+
if (stateTypes.length === 0) return;
|
|
125
|
+
for (const st of stateTypes) {
|
|
126
|
+
const sample = stateSamples[st];
|
|
127
|
+
if (sample === void 0) continue;
|
|
128
|
+
const subs = machine.subscriptions(sample);
|
|
129
|
+
const subTypes = subs.map((s) => s.type);
|
|
130
|
+
const body = subTypes.length === 0 ? "(no active subs)" : `subs: ${subTypes.map(safeLabel).join(", ")}`;
|
|
131
|
+
lines.push(` note left of ${safeId(st)}`);
|
|
132
|
+
lines.push(` ${body}`);
|
|
133
|
+
lines.push(" end note");
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export { toMermaid };
|
|
138
|
+
//# sourceMappingURL=index.js.map
|
|
139
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/machine-viz/index.ts"],"names":[],"mappings":"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","file":"index.js","sourcesContent":["/**\n * @demlik/tea/machine-viz — turn a `Machine` into a Mermaid diagram string.\n *\n * PURE. No deps, no AST parsing, no file I/O. The function reads only the\n * `Machine` value it is handed plus optional sample states/msgs/ctx.\n *\n * The trick: TEA update cells are PURE (invariant 2 — they return\n * `[nextState, cmds]` as data, no effects). So we can SAFELY EXECUTE a cell to\n * discover its target state — exactly what `replay` does. That gives us REAL\n * edges (`red --> green : go`) without parsing source. When no sample is\n * provided for a `(state, msg)` pair we cannot resolve the target, so we fall\n * back to a STRUCTURAL self-edge annotated with `?` — visually distinct from a\n * resolved edge and documented in the diagram's legend.\n *\n * Form detection mirrors `run`/`replay` (the `updateMode` logic): inspect the\n * first own key of `update`; a value that is a function ⇒ Reducer form, a value\n * that is a record ⇒ Transitions form. We do NOT add a runtime tag to Machine.\n *\n * What we MUST NOT do (these are the effectful paths, off-limits here):\n * - call `interpret[*]` handlers (perform I/O)\n * - call `subscribe[*]` handlers (install listeners)\n * - call `run` / the runtime\n * We MAY call `init`, `update[*]` cells, and `subscriptions(state)` — all pure\n * by the invariants (same set `replay` is allowed to touch).\n */\n\nimport type { Cmd, Machine, Sub } from \"../index\";\n\n// === Public options ===\n\n/**\n * Options controlling diagram emission.\n *\n * `samples` is the lever that turns STRUCTURAL edges into RESOLVED edges. For a\n * Transitions machine, providing a sample state for `stateType` AND a sample\n * msg for `msgType` lets us execute `update[stateType][msgType](state, msg)` and\n * read the real target `next.type`. Sample maps are keyed by the discriminant\n * string (`state.type` / `msg.type`).\n *\n * `samples.ctx` is the argument to `init(null, ctx)` — provide it to draw the\n * `[*] -->` initial-state edge. Omitted ⇒ no init edge (annotated as such),\n * because `init`'s signature requires a ctx and we will not invent one.\n */\nexport interface MachineVizOptions<S, M> {\n samples?: {\n /** Sample state per `state.type` — unlocks RESOLVED edges + sub annotations. */\n states?: Partial<Record<string, S>>;\n /** Sample msg per `msg.type` — unlocks RESOLVED edges. */\n msgs?: Partial<Record<string, M>>;\n /** Ctx for `init(null, ctx)` — unlocks the `[*] -->` initial-state edge. */\n ctx?: unknown;\n };\n /** Mermaid layout direction. Default `\"TB\"` (top-to-bottom). */\n direction?: \"TB\" | \"LR\";\n /** Optional diagram title, emitted as a Mermaid front-matter `title:` block. */\n title?: string;\n}\n\n// === Mermaid label escaping ===\n//\n// Mermaid state ids must be identifier-safe; arbitrary `type` strings (which\n// CAN contain `:`, spaces, `-`, etc.) are not. We sanitize the id used as the\n// node identifier and carry the human-readable original as a quoted label via\n// `stateId : \"Original\"` when they differ. Edge transition labels (after the\n// `:`) are sanitized to avoid breaking the arrow syntax.\n\n/** Sanitize a discriminant string into a Mermaid-safe node identifier. */\nfunction safeId(raw: string): string {\n // Replace any run of non-identifier chars with `_`; prefix a leading digit so\n // the id is always a valid Mermaid token. Empty ⇒ a stable placeholder.\n const cleaned = raw.replace(/[^A-Za-z0-9_]/g, \"_\");\n if (cleaned === \"\") return \"_empty\";\n return /^[0-9]/.test(cleaned) ? `s_${cleaned}` : cleaned;\n}\n\n/** Sanitize text used as a Mermaid edge/transition label (after the `:`). */\nfunction safeLabel(raw: string): string {\n // Strip the chars that terminate or confuse a transition label: newlines,\n // the `:` that separates label from target, and Mermaid's `\"`/`;` delimiters.\n return raw.replace(/[\"\\n\\r;:]/g, \" \").trim();\n}\n\n// === Form detection (mirrors run/replay updateMode) ===\n\ntype UpdateMode = \"reducer\" | \"transitions\";\n\n/**\n * Detect Reducer-vs-Transitions form from the value shape — identical logic to\n * `run`/`replay`'s `updateMode`. Inspect the first own key: a function value ⇒\n * Reducer (flat `{ [msg.type]: fn }`), a record value ⇒ Transitions (nested\n * `{ [state.type]: { [msg.type]: fn } }`). Empty object ⇒ M is `never`; the\n * mode is moot, default `\"reducer\"`.\n */\nfunction detectMode(update: unknown): UpdateMode {\n const keys = Object.keys(update as object);\n const firstKey = keys[0];\n if (firstKey === undefined) return \"reducer\";\n const firstValue = (update as Record<string, unknown>)[firstKey];\n return typeof firstValue === \"function\" ? \"reducer\" : \"transitions\";\n}\n\n// === Edge model ===\n\ntype Edge =\n // Resolved: we executed the cell with samples and read the real target.\n | { kind: \"resolved\"; from: string; to: string; msg: string }\n // Structural: no samples for the pair → target unknown, drawn as a self-loop.\n | { kind: \"structural\"; from: string; msg: string };\n\n// === toMermaid ===\n\n/**\n * Render a `Machine` as a Mermaid `stateDiagram-v2` string.\n *\n * - **Transitions form** (`update` is a record-of-records): nodes are the\n * `state.type` keys; each `(stateType × msgType)` cell becomes an edge. With\n * samples for both, the edge is RESOLVED by executing the (pure) cell. Without\n * samples it is STRUCTURAL — a `from --> from : msg?` self-loop (the `?`\n * suffix marks \"target unresolved\"). A legend note documents the distinction.\n * The `[*] -->` init edge is drawn only when `samples.ctx` is provided.\n *\n * - **Reducer form** (`update` values are functions): there is no static phase\n * graph (state is not a discriminated union over phases). We emit a single\n * node listing the handled `msg.type`s and an honest note saying so. No edges\n * are invented.\n *\n * Output is deterministic for the same input (keys iterated in insertion order,\n * no clocks, no randomness).\n */\nexport function toMermaid<\n S,\n M extends { type: string },\n C extends Cmd,\n U extends Sub,\n Ctx,\n>(\n machine: Machine<S, M, C, U, Ctx>,\n // `NoInfer` pins S/M to the `machine` argument so sample object literals in\n // `opts` are CHECKED against the machine's State/Msg unions rather than\n // driving inference (which would widen `{ type: \"open\" }` to `{ type: string }`\n // and collide with the machine's discriminated union).\n opts: MachineVizOptions<NoInfer<S>, NoInfer<M>> = {},\n): string {\n const direction = opts.direction ?? \"TB\";\n const stateSamples = opts.samples?.states ?? {};\n const msgSamples = opts.samples?.msgs ?? {};\n const hasCtx = opts.samples !== undefined && \"ctx\" in opts.samples;\n\n const mode = detectMode(machine.update);\n const lines: string[] = [];\n\n // Front matter — Mermaid reads a YAML `title:` block before the diagram.\n if (opts.title !== undefined) {\n lines.push(\"---\", `title: ${opts.title}`, \"---\");\n }\n lines.push(\"stateDiagram-v2\");\n lines.push(` direction ${direction}`);\n\n if (mode === \"reducer\") {\n emitReducer(machine, lines);\n } else {\n emitTransitions(machine, lines, {\n stateSamples,\n msgSamples,\n hasCtx,\n ctx: opts.samples?.ctx,\n });\n }\n\n // Subscriptions annotation — pure call to `subscriptions(sampleState)` per\n // sampled state. Skipped entirely when no state samples are provided (we\n // refuse to invent a state to feed it).\n emitSubscriptions(machine, lines, stateSamples);\n\n return lines.join(\"\\n\");\n}\n\n// === Reducer-form emission ===\n\n/**\n * Reducer form has no phase graph. Emit one node listing the handled msg types\n * plus an honest note. The msg set is the `update` record's keys (the Reducer\n * mapped type guarantees one cell per Msg variant).\n */\nfunction emitReducer(machine: { update: unknown }, lines: string[]): void {\n const msgTypes = Object.keys(machine.update as object);\n lines.push(\" state ReducerMachine {\");\n if (msgTypes.length === 0) {\n lines.push(\" [*] --> NoMsgs\");\n lines.push(' NoMsgs : \"(no msg variants — M is never)\"');\n } else {\n for (const m of msgTypes) {\n lines.push(` ${safeId(m)} : ${safeLabel(m)}`);\n }\n }\n lines.push(\" }\");\n // The note IS the design honesty: no edges, because reducer-form state is not\n // a discriminated union over phases — there is no static target to draw.\n lines.push(\" note right of ReducerMachine\");\n lines.push(\" Reducer-form machine: flat dispatch keyed by msg.type.\");\n lines.push(\n \" No phase graph — state is not a discriminated union over phases.\",\n );\n lines.push(` Handled msg types: ${msgTypes.length}`);\n lines.push(\" end note\");\n}\n\n// === Transitions-form emission ===\n\ninterface TransitionsCtx<S, M> {\n stateSamples: Partial<Record<string, S>>;\n msgSamples: Partial<Record<string, M>>;\n hasCtx: boolean;\n ctx: unknown;\n}\n\n/**\n * Transitions form: walk the `update[stateType][msgType]` table. Execute the\n * (pure) cell when both samples exist → RESOLVED edge; otherwise → STRUCTURAL\n * self-loop. Draw the `[*] -->` init edge only when a ctx sample is given.\n */\nfunction emitTransitions<\n S,\n M extends { type: string },\n C extends Cmd,\n U extends Sub,\n Ctx,\n>(\n machine: Machine<S, M, C, U, Ctx>,\n lines: string[],\n ctx: TransitionsCtx<S, M>,\n): void {\n type CellFn = (state: S, msg: M) => readonly [S, readonly C[]];\n type Table = Record<string, Record<string, CellFn>>;\n const table = machine.update as unknown as Table;\n const stateTypes = Object.keys(table);\n\n // Declare every node up front so even unreachable phases render.\n for (const st of stateTypes) {\n lines.push(` ${safeId(st)}`);\n }\n\n // Init edge — only resolvable with a ctx sample. `init(null, ctx)` is pure\n // (invariant 2: fresh-boot branch returns a starting state). Read `next.type`.\n if (ctx.hasCtx) {\n const [initState] = machine.init(null, ctx.ctx as Ctx);\n const initType = (initState as unknown as { type: string }).type;\n lines.push(` [*] --> ${safeId(initType)}`);\n } else {\n lines.push(\n \" %% init edge omitted: no samples.ctx provided to run init(null, ctx)\",\n );\n }\n\n let anyStructural = false;\n for (const st of stateTypes) {\n const row = table[st];\n if (row === undefined) continue;\n const msgTypes = Object.keys(row);\n for (const mt of msgTypes) {\n const edge = resolveEdge(row, st, mt, ctx);\n if (edge.kind === \"resolved\") {\n lines.push(\n ` ${safeId(edge.from)} --> ${safeId(edge.to)} : ${safeLabel(edge.msg)}`,\n );\n } else {\n anyStructural = true;\n // Structural self-loop with a `?` suffix — target unresolved. Visually\n // distinct from a resolved edge (self-loop + trailing `?`).\n lines.push(\n ` ${safeId(edge.from)} --> ${safeId(edge.from)} : ${safeLabel(edge.msg)}?`,\n );\n }\n }\n }\n\n // Legend — document the resolved-vs-structural distinction in the output.\n lines.push(\" note right of \" + safeId(stateTypes[0] ?? \"_empty\"));\n lines.push(\" Transitions-form machine.\");\n lines.push(\" Edge a --> b : msg = RESOLVED (cell executed with samples).\");\n if (anyStructural) {\n lines.push(\n \" Self-loop a --> a : msg? = STRUCTURAL (no sample; target unresolved).\",\n );\n }\n lines.push(\" end note\");\n}\n\n/**\n * Resolve a single `(stateType, msgType)` cell into an Edge. With both a sample\n * state and a sample msg, EXECUTE the pure cell to read the real target. Any\n * missing sample → structural. A thrown cell (e.g. a sample shaped wrong for\n * the cell) degrades to structural rather than crashing the whole diagram —\n * the diagram is a best-effort visualization, not a correctness gate.\n */\nfunction resolveEdge<S, M extends { type: string }, C extends Cmd>(\n row: Record<string, (state: S, msg: M) => readonly [S, readonly C[]]>,\n stateType: string,\n msgType: string,\n ctx: {\n stateSamples: Partial<Record<string, S>>;\n msgSamples: Partial<Record<string, M>>;\n },\n): Edge {\n const sampleState = ctx.stateSamples[stateType];\n const sampleMsg = ctx.msgSamples[msgType];\n const cell = row[msgType];\n if (\n sampleState === undefined ||\n sampleMsg === undefined ||\n cell === undefined\n ) {\n return { kind: \"structural\", from: stateType, msg: msgType };\n }\n try {\n const [next] = cell(sampleState, sampleMsg);\n const to = (next as unknown as { type: string }).type;\n return { kind: \"resolved\", from: stateType, to, msg: msgType };\n } catch {\n // Mismatched sample → cannot resolve. Fall back to structural.\n return { kind: \"structural\", from: stateType, msg: msgType };\n }\n}\n\n// === Subscriptions annotation ===\n\n/**\n * For each sampled state, call the (pure) `subscriptions(state)` description\n * function and annotate which Sub types are active in that state as a Mermaid\n * note. We call `subscriptions` (the description) but NEVER `subscribe[*]` (the\n * effectful installer) — same boundary `replay` honors. Skipped when no state\n * samples are provided or the machine declares no `subscriptions`.\n */\nfunction emitSubscriptions<\n S,\n M extends { type: string },\n C extends Cmd,\n U extends Sub,\n Ctx,\n>(\n machine: Machine<S, M, C, U, Ctx>,\n lines: string[],\n stateSamples: Partial<Record<string, S>>,\n): void {\n if (machine.subscriptions === undefined) return;\n const stateTypes = Object.keys(stateSamples);\n if (stateTypes.length === 0) return;\n\n for (const st of stateTypes) {\n const sample = stateSamples[st];\n if (sample === undefined) continue;\n const subs = machine.subscriptions(sample);\n const subTypes = subs.map((s) => s.type);\n const body =\n subTypes.length === 0\n ? \"(no active subs)\"\n : `subs: ${subTypes.map(safeLabel).join(\", \")}`;\n lines.push(` note left of ${safeId(st)}`);\n lines.push(` ${body}`);\n lines.push(\" end note\");\n }\n}\n"]}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { Store } from '../index.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @demlik/tea/mem — in-memory `Store<S>` adapter for `@demlik/tea`.
|
|
5
|
+
*
|
|
6
|
+
* The standard test adapter — used everywhere a Worker or DO would be
|
|
7
|
+
* overkill. Mirrors the source-only convention from `packages/widget-engine`
|
|
8
|
+
* and `packages/tea` (no build step).
|
|
9
|
+
*
|
|
10
|
+
* Reference semantics, NOT deep-clone semantics:
|
|
11
|
+
* - `save(state)` stores the reference; subsequent mutations to that
|
|
12
|
+
* reference by the caller are observable through later `load()` calls.
|
|
13
|
+
* - `load()` returns the stored reference; mutations made through it are
|
|
14
|
+
* observable on the next `load()`.
|
|
15
|
+
*
|
|
16
|
+
* This matches what a real KV/DO `get()` would NOT do (those serialize +
|
|
17
|
+
* deserialize), but it also matches what an in-memory cache obviously does.
|
|
18
|
+
* Adding a deep clone here would lie about the boundary and impose a JSON
|
|
19
|
+
* round-trip on every test. Tests that need fresh copies should clone at the
|
|
20
|
+
* call site.
|
|
21
|
+
*
|
|
22
|
+
* Boundary parse (invariant 8): `Store<S>` requires `migrate(raw)`. For
|
|
23
|
+
* `memoryStore` we ship a permissive identity-default — `(raw) => raw as
|
|
24
|
+
* S | null` — because this adapter is a degenerate same-process boundary:
|
|
25
|
+
* there is no serialization, the reference we read is structurally the
|
|
26
|
+
* reference we wrote. The `as` cast here is the documented exception to the
|
|
27
|
+
* invariant-8 no-`as` rule, scoped to the parameter default. Callers that
|
|
28
|
+
* want strict parsing (e.g. a test that hand-seeds the cell from outside)
|
|
29
|
+
* may pass an explicit `parse` to override.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Build an in-memory `Store<S>`.
|
|
34
|
+
*
|
|
35
|
+
* @param initial - optional seed value. `undefined` and explicit `null` both
|
|
36
|
+
* mean "no seed"; `load()` resolves with `null` until the first `save()`.
|
|
37
|
+
* A concrete `S` seed is returned by `load()` until overwritten.
|
|
38
|
+
* @param parse - optional boundary parse for the `Store<S>.migrate` contract.
|
|
39
|
+
* Defaults to identity (`(raw) => raw as S | null`) — honest for this
|
|
40
|
+
* adapter's degenerate same-process boundary. Pass an explicit parser when
|
|
41
|
+
* the test wants to reject seeded shapes that don't match `S`.
|
|
42
|
+
*/
|
|
43
|
+
declare function memoryStore<S>(initial?: S | null, parse?: (raw: unknown) => S | null): Store<S>;
|
|
44
|
+
|
|
45
|
+
export { memoryStore };
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// src/mem/index.ts
|
|
2
|
+
function memoryStore(initial, parse = (raw) => raw) {
|
|
3
|
+
let cell = initial ?? null;
|
|
4
|
+
return {
|
|
5
|
+
async load() {
|
|
6
|
+
return cell;
|
|
7
|
+
},
|
|
8
|
+
async save(state) {
|
|
9
|
+
cell = state;
|
|
10
|
+
},
|
|
11
|
+
migrate(raw) {
|
|
12
|
+
return parse(raw);
|
|
13
|
+
}
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export { memoryStore };
|
|
18
|
+
//# sourceMappingURL=index.js.map
|
|
19
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/mem/index.ts"],"names":[],"mappings":";AA0CO,SAAS,WAAA,CACd,OAAA,EACA,KAAA,GAAoC,CAAC,QAAQ,GAAA,EACnC;AAGV,EAAA,IAAI,OAAiB,OAAA,IAAW,IAAA;AAEhC,EAAA,OAAO;AAAA,IACL,MAAM,IAAA,GAAyB;AAC7B,MAAA,OAAO,IAAA;AAAA,IACT,CAAA;AAAA,IACA,MAAM,KAAK,KAAA,EAAyB;AAClC,MAAA,IAAA,GAAO,KAAA;AAAA,IACT,CAAA;AAAA,IACA,QAAQ,GAAA,EAAwB;AAC9B,MAAA,OAAO,MAAM,GAAG,CAAA;AAAA,IAClB;AAAA,GACF;AACF","file":"index.js","sourcesContent":["/**\n * @demlik/tea/mem — in-memory `Store<S>` adapter for `@demlik/tea`.\n *\n * The standard test adapter — used everywhere a Worker or DO would be\n * overkill. Mirrors the source-only convention from `packages/widget-engine`\n * and `packages/tea` (no build step).\n *\n * Reference semantics, NOT deep-clone semantics:\n * - `save(state)` stores the reference; subsequent mutations to that\n * reference by the caller are observable through later `load()` calls.\n * - `load()` returns the stored reference; mutations made through it are\n * observable on the next `load()`.\n *\n * This matches what a real KV/DO `get()` would NOT do (those serialize +\n * deserialize), but it also matches what an in-memory cache obviously does.\n * Adding a deep clone here would lie about the boundary and impose a JSON\n * round-trip on every test. Tests that need fresh copies should clone at the\n * call site.\n *\n * Boundary parse (invariant 8): `Store<S>` requires `migrate(raw)`. For\n * `memoryStore` we ship a permissive identity-default — `(raw) => raw as\n * S | null` — because this adapter is a degenerate same-process boundary:\n * there is no serialization, the reference we read is structurally the\n * reference we wrote. The `as` cast here is the documented exception to the\n * invariant-8 no-`as` rule, scoped to the parameter default. Callers that\n * want strict parsing (e.g. a test that hand-seeds the cell from outside)\n * may pass an explicit `parse` to override.\n */\n\nimport type { Store } from \"../index\";\n\n/**\n * Build an in-memory `Store<S>`.\n *\n * @param initial - optional seed value. `undefined` and explicit `null` both\n * mean \"no seed\"; `load()` resolves with `null` until the first `save()`.\n * A concrete `S` seed is returned by `load()` until overwritten.\n * @param parse - optional boundary parse for the `Store<S>.migrate` contract.\n * Defaults to identity (`(raw) => raw as S | null`) — honest for this\n * adapter's degenerate same-process boundary. Pass an explicit parser when\n * the test wants to reject seeded shapes that don't match `S`.\n */\nexport function memoryStore<S>(\n initial?: S | null,\n parse: (raw: unknown) => S | null = (raw) => raw as S | null,\n): Store<S> {\n // Single internal cell — `undefined` and `null` collapse to one\n // representation so the load site is branchless.\n let cell: S | null = initial ?? null;\n\n return {\n async load(): Promise<unknown> {\n return cell;\n },\n async save(state: S): Promise<void> {\n cell = state;\n },\n migrate(raw: unknown): S | null {\n return parse(raw);\n },\n };\n}\n"]}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { Store, SubId, Sub } from '../index.js';
|
|
2
|
+
import WebSocket from 'ws';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* @demlik/tea/node — Node host adapter for `@demlik/tea`.
|
|
6
|
+
*
|
|
7
|
+
* Every host adapter ships two halves (mirrors `@demlik/tea/do`):
|
|
8
|
+
*
|
|
9
|
+
* 1. `fileStore<S>(path, parse)` — a `Store<S>` over a JSON file on disk.
|
|
10
|
+
* Atomic save (temp + rename). Absent file → `load()` resolves `null`
|
|
11
|
+
* (fresh boot). Structurally malformed JSON throws at `load()` (an infra
|
|
12
|
+
* error), matching `doStore`; a shape mismatch is NOT a throw — the
|
|
13
|
+
* substrate calls `migrate`, which returns `null` and boots fresh.
|
|
14
|
+
* Use for resumable scripts / local repro of prod machines. Ephemeral
|
|
15
|
+
* CLIs can skip persistence with `memoryStore` from `@demlik/tea/mem`.
|
|
16
|
+
*
|
|
17
|
+
* 2. `nodeSubscribe<M, Ctx>()` — handler registry for the three node-native
|
|
18
|
+
* Sub types this package owns:
|
|
19
|
+
* - `node_ws` — opens a `ws` WebSocket; routes open/message/close/
|
|
20
|
+
* error into Msgs. Registers the live socket in
|
|
21
|
+
* `ctx.wsRegistry` so a Cmd handler can write to it
|
|
22
|
+
* (see `sendToWebSocket`). Cleanup closes the socket
|
|
23
|
+
* for ANY non-CLOSED state.
|
|
24
|
+
* - `node_timer` — setTimeout / setInterval; cleanup clears it.
|
|
25
|
+
* - `node_signal` — a process signal (SIGINT/SIGTERM/…) → Msg; cleanup
|
|
26
|
+
* detaches the listener.
|
|
27
|
+
*
|
|
28
|
+
* Unlike tea-do — whose `alarm()` / `webSocketMessage()` are DO lifecycle
|
|
29
|
+
* methods the handler can't observe from the outside, hence its registries —
|
|
30
|
+
* a node process is continuously alive: each handler holds its resource in
|
|
31
|
+
* closure and the returned cleanup closes it. The substrate's subscription
|
|
32
|
+
* reconciler runs that cleanup the instant `subscriptions(state)` stops listing
|
|
33
|
+
* the Sub — end-then-start choreography for free (Rule 9, [invariant 4]).
|
|
34
|
+
* A lifecycle-bound resource (a socket, a timer) becomes impossible to leak
|
|
35
|
+
* from a reducer cell, because no reducer cell owns its teardown.
|
|
36
|
+
*
|
|
37
|
+
* `ctx.wsRegistry` is the one shared surface: `node_ws` populates it; a Cmd
|
|
38
|
+
* interpreter reads it to write to the open socket. Same shape as tea-do's
|
|
39
|
+
* `wsRegistry`.
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* `Store<S>` backed by a JSON file at `path`.
|
|
44
|
+
*
|
|
45
|
+
* `parse` is REQUIRED because the file is a real serialization boundary — the
|
|
46
|
+
* bytes that come back through `JSON.parse` are structurally `unknown`, and the
|
|
47
|
+
* caller (who owns `S`) is the only party that can validate the shape.
|
|
48
|
+
* Returning `null` from `parse` means "no usable persisted state" — the
|
|
49
|
+
* substrate boots `init` with `loaded = null`. `parse` must NOT throw per the
|
|
50
|
+
* `Store<S>.migrate` contract.
|
|
51
|
+
*/
|
|
52
|
+
declare function fileStore<S>(path: string, parse: (raw: unknown) => S | null): Store<S>;
|
|
53
|
+
/**
|
|
54
|
+
* A node WebSocket sub. The substrate diffs subs by `id`; the same `id` across
|
|
55
|
+
* transitions = the same socket (kept open). Emit a DIFFERENT `id` to force a
|
|
56
|
+
* reconnect.
|
|
57
|
+
*
|
|
58
|
+
* Only `onMessage` returns `M | null` (null drops the frame) because inbound
|
|
59
|
+
* frames are the boundary that needs a parse-and-maybe-discard seam. `onOpen`/
|
|
60
|
+
* `onClose`/`onError` always dispatch — a spurious one is dropped in the reducer
|
|
61
|
+
* (the cell ignores it), not here.
|
|
62
|
+
*/
|
|
63
|
+
type NodeWsSub<M> = {
|
|
64
|
+
id: SubId;
|
|
65
|
+
type: "node_ws";
|
|
66
|
+
url: string;
|
|
67
|
+
onMessage: (data: string) => M | null;
|
|
68
|
+
onOpen?: () => M;
|
|
69
|
+
onClose?: (code: number, reason: string) => M;
|
|
70
|
+
onError?: (message: string) => M;
|
|
71
|
+
};
|
|
72
|
+
/**
|
|
73
|
+
* A node timer sub. One-shot `setTimeout` by default; `repeat: true` →
|
|
74
|
+
* `setInterval`. Cleanup clears the timer — a timer keyed to a phase is
|
|
75
|
+
* cancelled the moment the machine leaves that phase.
|
|
76
|
+
*/
|
|
77
|
+
type NodeTimerSub<M> = {
|
|
78
|
+
id: SubId;
|
|
79
|
+
type: "node_timer";
|
|
80
|
+
delayMs: number;
|
|
81
|
+
msg: M;
|
|
82
|
+
repeat?: boolean;
|
|
83
|
+
};
|
|
84
|
+
/**
|
|
85
|
+
* A process-signal sub: `signal` (SIGINT/SIGTERM/…) → `msg`. Cleanup detaches
|
|
86
|
+
* the listener. Lets graceful shutdown be a real machine transition rather than
|
|
87
|
+
* an out-of-band `process.on` that the reducer can't see.
|
|
88
|
+
*/
|
|
89
|
+
type NodeSignalSub<M> = {
|
|
90
|
+
id: SubId;
|
|
91
|
+
type: "node_signal";
|
|
92
|
+
signal: NodeJS.Signals;
|
|
93
|
+
msg: M;
|
|
94
|
+
};
|
|
95
|
+
type NodeSub<M> = NodeWsSub<M> | NodeTimerSub<M> | NodeSignalSub<M>;
|
|
96
|
+
type AssertNodeSubIsSub<T extends Sub = NodeSub<unknown>> = T;
|
|
97
|
+
type NodeWsRegistry = Map<SubId, WebSocket>;
|
|
98
|
+
/**
|
|
99
|
+
* The Ctx fields the node Subs depend on. A consumer's Ctx must extend this so
|
|
100
|
+
* `node_ws` can register the live socket and Cmd handlers can reach it.
|
|
101
|
+
*
|
|
102
|
+
* Deliberately NOT parameterized by Msg (unlike tea-do's `DoSubscribeCtx<M>`):
|
|
103
|
+
* the registry holds raw `WebSocket`s, and each Sub variant carries its own
|
|
104
|
+
* Msg-typed callbacks. Trip-wire: if a future node Sub holds a Msg-typed
|
|
105
|
+
* callback on ctx (the way tea-do's registries do), this becomes
|
|
106
|
+
* `NodeSubscribeCtx<M>`.
|
|
107
|
+
*/
|
|
108
|
+
interface NodeSubscribeCtx {
|
|
109
|
+
wsRegistry: NodeWsRegistry;
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Write a frame to a registered `node_ws` socket. No-op (returns `false`) when
|
|
113
|
+
* the socket is absent, not OPEN, or `send()` throws. The Cmd-side companion to
|
|
114
|
+
* the `node_ws` Sub: the Sub owns the socket's lifetime; this writes to it.
|
|
115
|
+
*
|
|
116
|
+
* The contract is a boolean no-op, never a throw: the socket can transition out
|
|
117
|
+
* of OPEN between the `readyState` check and `send()` (or `ws` can throw on an
|
|
118
|
+
* errored underlying connection), and a Cmd interpreter must not crash on that.
|
|
119
|
+
*/
|
|
120
|
+
declare function sendToWebSocket(ctx: NodeSubscribeCtx, id: SubId, data: string): boolean;
|
|
121
|
+
/**
|
|
122
|
+
* Build the `subscribe` handlers for `node_ws`, `node_timer`, and
|
|
123
|
+
* `node_signal`. Matches `Machine#subscribe`'s
|
|
124
|
+
* `[K in U["type"]]: (sub, ctx, dispatch) => () => void` contract for the
|
|
125
|
+
* `NodeSub<M>` variants. Generic `Ctx` must extend `NodeSubscribeCtx` so the
|
|
126
|
+
* `node_ws` handler can register its socket.
|
|
127
|
+
*/
|
|
128
|
+
declare function nodeSubscribe<M, Ctx extends NodeSubscribeCtx>(): {
|
|
129
|
+
node_ws: (sub: NodeWsSub<M>, ctx: Ctx, dispatch: (msg: M) => void) => () => void;
|
|
130
|
+
node_timer: (sub: NodeTimerSub<M>, ctx: Ctx, dispatch: (msg: M) => void) => () => void;
|
|
131
|
+
node_signal: (sub: NodeSignalSub<M>, ctx: Ctx, dispatch: (msg: M) => void) => () => void;
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
export { type AssertNodeSubIsSub, type NodeSignalSub, type NodeSub, type NodeSubscribeCtx, type NodeTimerSub, type NodeWsRegistry, type NodeWsSub, fileStore, nodeSubscribe, sendToWebSocket };
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { mkdir, writeFile, rename, readFile } from 'fs/promises';
|
|
2
|
+
import { dirname } from 'path';
|
|
3
|
+
import WebSocket from 'ws';
|
|
4
|
+
|
|
5
|
+
// src/node/index.ts
|
|
6
|
+
function fileStore(path, parse) {
|
|
7
|
+
return {
|
|
8
|
+
async load() {
|
|
9
|
+
let raw;
|
|
10
|
+
try {
|
|
11
|
+
raw = await readFile(path, "utf8");
|
|
12
|
+
} catch (err) {
|
|
13
|
+
if (err.code === "ENOENT") return null;
|
|
14
|
+
throw err;
|
|
15
|
+
}
|
|
16
|
+
return JSON.parse(raw);
|
|
17
|
+
},
|
|
18
|
+
async save(state) {
|
|
19
|
+
await mkdir(dirname(path), { recursive: true });
|
|
20
|
+
const tmp = `${path}.${process.pid}.tmp`;
|
|
21
|
+
await writeFile(tmp, JSON.stringify(state), "utf8");
|
|
22
|
+
await rename(tmp, path);
|
|
23
|
+
},
|
|
24
|
+
migrate(raw) {
|
|
25
|
+
return parse(raw);
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
function sendToWebSocket(ctx, id, data) {
|
|
30
|
+
const socket = ctx.wsRegistry.get(id);
|
|
31
|
+
if (!socket || socket.readyState !== WebSocket.OPEN) return false;
|
|
32
|
+
try {
|
|
33
|
+
socket.send(data);
|
|
34
|
+
return true;
|
|
35
|
+
} catch {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
function nodeSubscribe() {
|
|
40
|
+
return {
|
|
41
|
+
node_ws: (sub, ctx, dispatch) => {
|
|
42
|
+
const socket = new WebSocket(sub.url);
|
|
43
|
+
ctx.wsRegistry.set(sub.id, socket);
|
|
44
|
+
socket.on("open", () => {
|
|
45
|
+
if (sub.onOpen) dispatch(sub.onOpen());
|
|
46
|
+
});
|
|
47
|
+
socket.on("message", (data) => {
|
|
48
|
+
const msg = sub.onMessage(data.toString());
|
|
49
|
+
if (msg !== null) dispatch(msg);
|
|
50
|
+
});
|
|
51
|
+
socket.on("close", (code, reason) => {
|
|
52
|
+
if (sub.onClose) dispatch(sub.onClose(code, reason.toString()));
|
|
53
|
+
});
|
|
54
|
+
socket.on("error", (err) => {
|
|
55
|
+
if (sub.onError) dispatch(sub.onError(err.message));
|
|
56
|
+
});
|
|
57
|
+
return () => {
|
|
58
|
+
ctx.wsRegistry.delete(sub.id);
|
|
59
|
+
socket.removeAllListeners();
|
|
60
|
+
socket.on("error", () => {
|
|
61
|
+
});
|
|
62
|
+
if (socket.readyState === WebSocket.OPEN) {
|
|
63
|
+
socket.close(1e3, "tea-node sub cleanup");
|
|
64
|
+
} else if (socket.readyState !== WebSocket.CLOSED) {
|
|
65
|
+
socket.terminate();
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
},
|
|
69
|
+
node_timer: (sub, _ctx, dispatch) => {
|
|
70
|
+
if (sub.repeat) {
|
|
71
|
+
const handle2 = setInterval(() => dispatch(sub.msg), sub.delayMs);
|
|
72
|
+
return () => clearInterval(handle2);
|
|
73
|
+
}
|
|
74
|
+
const handle = setTimeout(() => dispatch(sub.msg), sub.delayMs);
|
|
75
|
+
return () => clearTimeout(handle);
|
|
76
|
+
},
|
|
77
|
+
node_signal: (sub, _ctx, dispatch) => {
|
|
78
|
+
const handler = () => dispatch(sub.msg);
|
|
79
|
+
process.on(sub.signal, handler);
|
|
80
|
+
return () => {
|
|
81
|
+
process.off(sub.signal, handler);
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export { fileStore, nodeSubscribe, sendToWebSocket };
|
|
88
|
+
//# sourceMappingURL=index.js.map
|
|
89
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/node/index.ts"],"names":["handle"],"mappings":"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","file":"index.js","sourcesContent":["/**\n * @demlik/tea/node — Node host adapter for `@demlik/tea`.\n *\n * Every host adapter ships two halves (mirrors `@demlik/tea/do`):\n *\n * 1. `fileStore<S>(path, parse)` — a `Store<S>` over a JSON file on disk.\n * Atomic save (temp + rename). Absent file → `load()` resolves `null`\n * (fresh boot). Structurally malformed JSON throws at `load()` (an infra\n * error), matching `doStore`; a shape mismatch is NOT a throw — the\n * substrate calls `migrate`, which returns `null` and boots fresh.\n * Use for resumable scripts / local repro of prod machines. Ephemeral\n * CLIs can skip persistence with `memoryStore` from `@demlik/tea/mem`.\n *\n * 2. `nodeSubscribe<M, Ctx>()` — handler registry for the three node-native\n * Sub types this package owns:\n * - `node_ws` — opens a `ws` WebSocket; routes open/message/close/\n * error into Msgs. Registers the live socket in\n * `ctx.wsRegistry` so a Cmd handler can write to it\n * (see `sendToWebSocket`). Cleanup closes the socket\n * for ANY non-CLOSED state.\n * - `node_timer` — setTimeout / setInterval; cleanup clears it.\n * - `node_signal` — a process signal (SIGINT/SIGTERM/…) → Msg; cleanup\n * detaches the listener.\n *\n * Unlike tea-do — whose `alarm()` / `webSocketMessage()` are DO lifecycle\n * methods the handler can't observe from the outside, hence its registries —\n * a node process is continuously alive: each handler holds its resource in\n * closure and the returned cleanup closes it. The substrate's subscription\n * reconciler runs that cleanup the instant `subscriptions(state)` stops listing\n * the Sub — end-then-start choreography for free (Rule 9, [invariant 4]).\n * A lifecycle-bound resource (a socket, a timer) becomes impossible to leak\n * from a reducer cell, because no reducer cell owns its teardown.\n *\n * `ctx.wsRegistry` is the one shared surface: `node_ws` populates it; a Cmd\n * interpreter reads it to write to the open socket. Same shape as tea-do's\n * `wsRegistry`.\n */\n\nimport { mkdir, readFile, rename, writeFile } from \"node:fs/promises\";\nimport { dirname } from \"node:path\";\nimport type { Store, Sub, SubId } from \"../index\";\nimport WebSocket, { type RawData } from \"ws\";\n\n// ─────────────────────────────────────────────────────────────────────────────\n// fileStore — `Store<S>` over a JSON file.\n// ─────────────────────────────────────────────────────────────────────────────\n\n/**\n * `Store<S>` backed by a JSON file at `path`.\n *\n * `parse` is REQUIRED because the file is a real serialization boundary — the\n * bytes that come back through `JSON.parse` are structurally `unknown`, and the\n * caller (who owns `S`) is the only party that can validate the shape.\n * Returning `null` from `parse` means \"no usable persisted state\" — the\n * substrate boots `init` with `loaded = null`. `parse` must NOT throw per the\n * `Store<S>.migrate` contract.\n */\nexport function fileStore<S>(path: string, parse: (raw: unknown) => S | null): Store<S> {\n return {\n async load(): Promise<unknown> {\n let raw: string;\n try {\n raw = await readFile(path, \"utf8\");\n } catch (err) {\n if ((err as NodeJS.ErrnoException).code === \"ENOENT\") return null;\n throw err;\n }\n // JSON.parse throws on structural malformation — propagate (infra error),\n // matching `doStore`. The decoded value is intentionally returned as\n // `unknown`; the substrate's `migrate` callback is the boundary parse.\n return JSON.parse(raw);\n },\n async save(state: S): Promise<void> {\n await mkdir(dirname(path), { recursive: true });\n // Write to a pid-scoped temp then atomically rename. The rename gives\n // torn-write safety (a reader sees the old file or the new one, never a\n // partial) and survives process crash / Ctrl-C — the only failure a local\n // resumable run resumes from. fsync (file + dir) would add power-loss\n // durability the use case doesn't need; the substrate serializes saves\n // through run()'s queue, so there's never a second concurrent writer.\n const tmp = `${path}.${process.pid}.tmp`;\n await writeFile(tmp, JSON.stringify(state), \"utf8\");\n await rename(tmp, path);\n },\n migrate(raw: unknown): S | null {\n return parse(raw);\n },\n };\n}\n\n// ─────────────────────────────────────────────────────────────────────────────\n// NodeSub — the three node-native subscription variants.\n// ─────────────────────────────────────────────────────────────────────────────\n\n/**\n * A node WebSocket sub. The substrate diffs subs by `id`; the same `id` across\n * transitions = the same socket (kept open). Emit a DIFFERENT `id` to force a\n * reconnect.\n *\n * Only `onMessage` returns `M | null` (null drops the frame) because inbound\n * frames are the boundary that needs a parse-and-maybe-discard seam. `onOpen`/\n * `onClose`/`onError` always dispatch — a spurious one is dropped in the reducer\n * (the cell ignores it), not here.\n */\nexport type NodeWsSub<M> = {\n id: SubId;\n type: \"node_ws\";\n url: string;\n onMessage: (data: string) => M | null;\n onOpen?: () => M;\n onClose?: (code: number, reason: string) => M;\n onError?: (message: string) => M;\n};\n\n/**\n * A node timer sub. One-shot `setTimeout` by default; `repeat: true` →\n * `setInterval`. Cleanup clears the timer — a timer keyed to a phase is\n * cancelled the moment the machine leaves that phase.\n */\nexport type NodeTimerSub<M> = {\n id: SubId;\n type: \"node_timer\";\n delayMs: number;\n msg: M;\n repeat?: boolean;\n};\n\n/**\n * A process-signal sub: `signal` (SIGINT/SIGTERM/…) → `msg`. Cleanup detaches\n * the listener. Lets graceful shutdown be a real machine transition rather than\n * an out-of-band `process.on` that the reducer can't see.\n */\nexport type NodeSignalSub<M> = {\n id: SubId;\n type: \"node_signal\";\n signal: NodeJS.Signals;\n msg: M;\n};\n\nexport type NodeSub<M> = NodeWsSub<M> | NodeTimerSub<M> | NodeSignalSub<M>;\n\n// Compile-time guarantee: `NodeSub<M>` is structurally a `Sub` (has `id`+`type`).\n// Erased at runtime — the `extends Sub` constraint on the alias's type\n// parameter fails to compile if `NodeSub` ever drifts off the `Sub` shape.\nexport type AssertNodeSubIsSub<T extends Sub = NodeSub<unknown>> = T;\n\n// ─────────────────────────────────────────────────────────────────────────────\n// Ctx — what the node Subs read / populate.\n// ─────────────────────────────────────────────────────────────────────────────\n\nexport type NodeWsRegistry = Map<SubId, WebSocket>;\n\n/**\n * The Ctx fields the node Subs depend on. A consumer's Ctx must extend this so\n * `node_ws` can register the live socket and Cmd handlers can reach it.\n *\n * Deliberately NOT parameterized by Msg (unlike tea-do's `DoSubscribeCtx<M>`):\n * the registry holds raw `WebSocket`s, and each Sub variant carries its own\n * Msg-typed callbacks. Trip-wire: if a future node Sub holds a Msg-typed\n * callback on ctx (the way tea-do's registries do), this becomes\n * `NodeSubscribeCtx<M>`.\n */\nexport interface NodeSubscribeCtx {\n wsRegistry: NodeWsRegistry;\n}\n\n/**\n * Write a frame to a registered `node_ws` socket. No-op (returns `false`) when\n * the socket is absent, not OPEN, or `send()` throws. The Cmd-side companion to\n * the `node_ws` Sub: the Sub owns the socket's lifetime; this writes to it.\n *\n * The contract is a boolean no-op, never a throw: the socket can transition out\n * of OPEN between the `readyState` check and `send()` (or `ws` can throw on an\n * errored underlying connection), and a Cmd interpreter must not crash on that.\n */\nexport function sendToWebSocket(ctx: NodeSubscribeCtx, id: SubId, data: string): boolean {\n const socket = ctx.wsRegistry.get(id);\n if (!socket || socket.readyState !== WebSocket.OPEN) return false;\n try {\n socket.send(data);\n return true;\n } catch {\n return false;\n }\n}\n\n// ─────────────────────────────────────────────────────────────────────────────\n// nodeSubscribe — the handler registry.\n// ─────────────────────────────────────────────────────────────────────────────\n\n/**\n * Build the `subscribe` handlers for `node_ws`, `node_timer`, and\n * `node_signal`. Matches `Machine#subscribe`'s\n * `[K in U[\"type\"]]: (sub, ctx, dispatch) => () => void` contract for the\n * `NodeSub<M>` variants. Generic `Ctx` must extend `NodeSubscribeCtx` so the\n * `node_ws` handler can register its socket.\n */\nexport function nodeSubscribe<M, Ctx extends NodeSubscribeCtx>(): {\n node_ws: (sub: NodeWsSub<M>, ctx: Ctx, dispatch: (msg: M) => void) => () => void;\n node_timer: (sub: NodeTimerSub<M>, ctx: Ctx, dispatch: (msg: M) => void) => () => void;\n node_signal: (sub: NodeSignalSub<M>, ctx: Ctx, dispatch: (msg: M) => void) => () => void;\n} {\n return {\n node_ws: (sub, ctx, dispatch) => {\n const socket = new WebSocket(sub.url);\n ctx.wsRegistry.set(sub.id, socket);\n\n socket.on(\"open\", () => {\n if (sub.onOpen) dispatch(sub.onOpen());\n });\n socket.on(\"message\", (data: RawData) => {\n const msg = sub.onMessage(data.toString());\n if (msg !== null) dispatch(msg);\n });\n socket.on(\"close\", (code: number, reason: Buffer) => {\n if (sub.onClose) dispatch(sub.onClose(code, reason.toString()));\n });\n socket.on(\"error\", (err: Error) => {\n if (sub.onError) dispatch(sub.onError(err.message));\n });\n\n return () => {\n ctx.wsRegistry.delete(sub.id);\n socket.removeAllListeners();\n // Swallow teardown-induced errors: terminate()-ing a CONNECTING socket\n // emits an 'error' (\"closed before the connection was established\"),\n // and with our listeners gone that would be an unhandled 'error' that\n // crashes the process. A no-op listener absorbs it.\n socket.on(\"error\", () => {});\n // Close for ANY non-closed state: a graceful 1000 when OPEN, a hard\n // terminate() otherwise (CONNECTING/CLOSING) so a socket caught mid-\n // handshake can't dangle. This is the teardown the hand-driven Cmd-pair\n // approach (`cleanup_browser` closing the ws only `if OPEN`) could not\n // guarantee.\n if (socket.readyState === WebSocket.OPEN) {\n socket.close(1000, \"tea-node sub cleanup\");\n } else if (socket.readyState !== WebSocket.CLOSED) {\n socket.terminate();\n }\n };\n },\n\n node_timer: (sub, _ctx, dispatch) => {\n // Two reconcile lifecycles under one variant: a one-shot (`setTimeout`)\n // that `subscriptions(state)` should drop on the render after it fires,\n // vs a repeating (`setInterval`) one the state keeps listing across\n // renders. Same Sub.type; the `repeat` flag picks the install + cleanup.\n if (sub.repeat) {\n const handle = setInterval(() => dispatch(sub.msg), sub.delayMs);\n return () => clearInterval(handle);\n }\n const handle = setTimeout(() => dispatch(sub.msg), sub.delayMs);\n return () => clearTimeout(handle);\n },\n\n node_signal: (sub, _ctx, dispatch) => {\n const handler = () => dispatch(sub.msg);\n process.on(sub.signal, handler);\n return () => {\n process.off(sub.signal, handler);\n };\n },\n };\n}\n"]}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import * as fc from 'fast-check';
|
|
2
|
+
import { Cmd, Sub, Machine } from '../../index.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Build a Ctx value whose every property access throws. Use as the `ctx`
|
|
6
|
+
* argument to PBT runners when the reducer under test is pure (does not
|
|
7
|
+
* read from ctx). Accidental ctx access during property generation
|
|
8
|
+
* surfaces as a labelled error instead of a silent `undefined` or
|
|
9
|
+
* downstream type-error.
|
|
10
|
+
*
|
|
11
|
+
* Returns a typed `Ctx` (via `as unknown as Ctx`) — the proxy satisfies the
|
|
12
|
+
* type-shape requirement at the call site; the runtime contract is
|
|
13
|
+
* "throw on first read." The `as unknown` cast is the one boundary cast
|
|
14
|
+
* this helper requires; the proxy is structurally a `Ctx` for every
|
|
15
|
+
* property accessor.
|
|
16
|
+
*
|
|
17
|
+
* @example
|
|
18
|
+
* import { stubCtxThrowingProxy } from "../index";
|
|
19
|
+
* const ctx = stubCtxThrowingProxy<MyCtx>();
|
|
20
|
+
* propertyTerminates(machine, ctx, ...);
|
|
21
|
+
* // If anything reads ctx.someField during the run, an Error throws:
|
|
22
|
+
* // "@demlik/tea/pbt: ctx access denied (PBT operates on the pure
|
|
23
|
+
* // reducer; ctx field 'someField' was read)"
|
|
24
|
+
*/
|
|
25
|
+
declare function stubCtxThrowingProxy<Ctx>(): Ctx;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Strict per-variant Msg arbitrary table. Mapped type: every key in
|
|
29
|
+
* `M["type"]` MUST have an arbitrary entry whose `T` is the narrowed
|
|
30
|
+
* variant. Adding a new Msg variant without an entry is a compile error
|
|
31
|
+
* (same load-bearing discipline as `Reducer<S, M, C>` in the substrate).
|
|
32
|
+
*
|
|
33
|
+
* @example
|
|
34
|
+
* type Msg = { type: "start" } | { type: "tick"; now: number };
|
|
35
|
+
* const table: MsgArbitraryTable<Msg> = {
|
|
36
|
+
* start: arbConstantMsg("start"),
|
|
37
|
+
* tick: arbRecordMsg("tick", { now: fc.integer({ min: 0 }) }),
|
|
38
|
+
* };
|
|
39
|
+
*/
|
|
40
|
+
type MsgArbitraryTable<M extends {
|
|
41
|
+
type: string;
|
|
42
|
+
}> = {
|
|
43
|
+
[K in M["type"]]: fc.Arbitrary<Extract<M, {
|
|
44
|
+
type: K;
|
|
45
|
+
}>>;
|
|
46
|
+
};
|
|
47
|
+
/**
|
|
48
|
+
* Build a single `fc.Arbitrary<M>` from a `MsgArbitraryTable<M>`. Picks one
|
|
49
|
+
* variant per generation via `fc.oneof`; optional per-variant `weights`
|
|
50
|
+
* bias the distribution (default uniform).
|
|
51
|
+
*
|
|
52
|
+
* @param table Per-variant arbitrary table (strict — every variant required).
|
|
53
|
+
* @param opts Optional bias: `weights` is a `Partial<Record<M["type"], number>>`.
|
|
54
|
+
* Variants without an explicit weight default to 1.
|
|
55
|
+
*
|
|
56
|
+
* @example
|
|
57
|
+
* const msgArb = arbMsg<Msg>({
|
|
58
|
+
* start: arbConstantMsg("start"),
|
|
59
|
+
* tick: arbRecordMsg("tick", { now: fc.integer() }),
|
|
60
|
+
* stop: arbConstantMsg("stop"),
|
|
61
|
+
* }, { weights: { tick: 5, start: 1, stop: 1 } });
|
|
62
|
+
*/
|
|
63
|
+
declare function arbMsg<M extends {
|
|
64
|
+
type: string;
|
|
65
|
+
}>(table: MsgArbitraryTable<M>, opts?: {
|
|
66
|
+
weights?: Partial<Record<M["type"], number>>;
|
|
67
|
+
}): fc.Arbitrary<M>;
|
|
68
|
+
/**
|
|
69
|
+
* Build a payload-free Msg variant arbitrary. Sugar for
|
|
70
|
+
* `fc.constant({ type })` that narrows the result to `{ type: T }` literal.
|
|
71
|
+
*
|
|
72
|
+
* @example
|
|
73
|
+
* arbConstantMsg("stop") // fc.Arbitrary<{ type: "stop" }>
|
|
74
|
+
*/
|
|
75
|
+
declare function arbConstantMsg<T extends string>(type: T): fc.Arbitrary<{
|
|
76
|
+
type: T;
|
|
77
|
+
}>;
|
|
78
|
+
/**
|
|
79
|
+
* Build a record-shaped Msg variant arbitrary. Sugar for
|
|
80
|
+
* `fc.record({ type: fc.constant(type), ...fields })` that stamps the
|
|
81
|
+
* discriminator literal and combines with the per-field arbitraries.
|
|
82
|
+
*
|
|
83
|
+
* @example
|
|
84
|
+
* arbRecordMsg("tick", { now: fc.integer({ min: 0 }) })
|
|
85
|
+
* // fc.Arbitrary<{ type: "tick"; now: number }>
|
|
86
|
+
*/
|
|
87
|
+
declare function arbRecordMsg<T extends string, R>(type: T, fields: {
|
|
88
|
+
[K in keyof R]: fc.Arbitrary<R[K]>;
|
|
89
|
+
}): fc.Arbitrary<{
|
|
90
|
+
type: T;
|
|
91
|
+
} & R>;
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Build an arbitrary of Msg sequences over a per-Msg arbitrary. Optionally
|
|
95
|
+
* bounded in length and / or prefixed with a fixed sequence.
|
|
96
|
+
*
|
|
97
|
+
* - `minLength` (default 0) and `maxLength` (default 30) bound the random
|
|
98
|
+
* array length. `prefix` is prepended AFTER the random tail is built; the
|
|
99
|
+
* final length is `prefix.length + tail.length`.
|
|
100
|
+
* - `prefix` is the load-bearing escape hatch for "the first msg is the
|
|
101
|
+
* one that exercises the cell I care about" (e.g. Test A's
|
|
102
|
+
* `[{ type: "ws:client:hello" }]` prefix to land in the
|
|
103
|
+
* `auditing.ws:client:hello` cell on the first transition).
|
|
104
|
+
*
|
|
105
|
+
* @example
|
|
106
|
+
* arbMsgSequence(msgArb, { minLength: 1, maxLength: 30 });
|
|
107
|
+
* arbMsgSequence(msgArb, { prefix: [{ type: "ws:client:hello" }], maxLength: 20 });
|
|
108
|
+
*/
|
|
109
|
+
declare function arbMsgSequence<M extends {
|
|
110
|
+
type: string;
|
|
111
|
+
}>(msgArb: fc.Arbitrary<M>, opts?: {
|
|
112
|
+
readonly minLength?: number;
|
|
113
|
+
readonly maxLength?: number;
|
|
114
|
+
readonly prefix?: readonly NoInfer<M>[];
|
|
115
|
+
}): fc.Arbitrary<readonly M[]>;
|
|
116
|
+
/**
|
|
117
|
+
* State-aware Msg sequence arbitrary — generates only msgs whose
|
|
118
|
+
* precondition holds in the current state. Backed by `fc.commands`
|
|
119
|
+
* internally (Phase 6 of `.patterns/tea/tea-pbt-design.md`).
|
|
120
|
+
*
|
|
121
|
+
* **NOT IMPLEMENTED IN v1.** The export exists today so the surface is
|
|
122
|
+
* forward-compatible; the design doc §7 ships this in Phase 6 alongside
|
|
123
|
+
* shrinkers. Calling it throws.
|
|
124
|
+
*/
|
|
125
|
+
declare function arbGuidedSequence<S, M extends {
|
|
126
|
+
type: string;
|
|
127
|
+
}, C extends Cmd, U extends Sub, Ctx>(_machine: Machine<S, M, C, U, Ctx>, _ctx: Ctx, _cells: {
|
|
128
|
+
[K in M["type"]]?: (state: S) => fc.Arbitrary<Extract<M, {
|
|
129
|
+
type: K;
|
|
130
|
+
}>> | null;
|
|
131
|
+
}, _opts?: {
|
|
132
|
+
maxLength?: number;
|
|
133
|
+
}): fc.Arbitrary<readonly M[]>;
|
|
134
|
+
|
|
135
|
+
export { type MsgArbitraryTable, arbConstantMsg, arbGuidedSequence, arbMsg, arbMsgSequence, arbRecordMsg, stubCtxThrowingProxy };
|