rastack 0.0.48 → 0.0.50

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.
Files changed (59) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/components/auto-form/AutoForm.tsx +13 -0
  3. package/components/auto-form/use-auto-form.ts +28 -0
  4. package/components/types.ts +8 -0
  5. package/dist/admin.js +13 -13
  6. package/dist/compile/analyze.d.ts +26 -0
  7. package/dist/compile/analyze.js +70 -3
  8. package/dist/compile/entities.d.ts +19 -0
  9. package/dist/compile/entities.js +87 -13
  10. package/dist/compile/index.d.ts +23 -4
  11. package/dist/compile/index.js +86 -7
  12. package/dist/compile/model.d.ts +38 -0
  13. package/dist/compile/openapi.d.ts +9 -0
  14. package/dist/compile/openapi.js +14 -0
  15. package/dist/define/index.d.ts +107 -13
  16. package/dist/define/index.js +1 -1
  17. package/dist/import/tabular.d.ts +8 -2
  18. package/dist/import/tabular.js +1 -1
  19. package/dist/rastack-admin.d.ts +14 -7
  20. package/dist/rastack-admin.js +85 -40
  21. package/dist/rastack-import.js +4 -1
  22. package/dist/validate/adapters.js +2 -0
  23. package/dist/validate/index.d.ts +1 -0
  24. package/dist/validate/index.js +1 -0
  25. package/dist/validate/machine.d.ts +23 -2
  26. package/dist/validate/machine.js +35 -2
  27. package/dist/validate/transitions.d.ts +86 -0
  28. package/dist/validate/transitions.js +199 -0
  29. package/dist/wasm/rastack_wasm.js +1 -1
  30. package/dist/wasm/rastack_wasm_bg.wasm +0 -0
  31. package/hooks/data.ts +209 -0
  32. package/hooks/entity.ts +222 -0
  33. package/hooks/form/entity-form.ts +354 -0
  34. package/hooks/form/form.ts +8 -1
  35. package/hooks/form/index.ts +7 -1
  36. package/hooks/index.ts +4 -0
  37. package/hooks/manifest.ts +41 -0
  38. package/hooks/registry.ts +39 -0
  39. package/package.json +1 -1
  40. package/provider/provider.tsx +8 -1
  41. package/provider/types.ts +7 -2
  42. package/src/compile/analyze.ts +83 -4
  43. package/src/compile/entities.ts +111 -11
  44. package/src/compile/index.ts +108 -11
  45. package/src/compile/model.ts +40 -0
  46. package/src/compile/openapi.ts +13 -1
  47. package/src/define/index.ts +148 -20
  48. package/src/import/tabular.ts +9 -2
  49. package/src/rastack-admin.ts +105 -46
  50. package/src/rastack-import.ts +4 -1
  51. package/src/validate/adapters.ts +1 -0
  52. package/src/validate/index.ts +1 -0
  53. package/src/validate/machine.ts +55 -3
  54. package/src/validate/transitions.ts +232 -0
  55. package/test/components.spec.ts +22 -0
  56. package/test/transitions.spec.ts +372 -0
  57. package/test/typed-hooks.spec.ts +407 -0
  58. package/wasm/rastack_wasm.js +1 -1
  59. package/wasm/rastack_wasm_bg.wasm +0 -0
@@ -18,6 +18,7 @@ import {
18
18
  type FieldState,
19
19
  type RecordRun,
20
20
  type SpecRelation,
21
+ type TransitionsModel,
21
22
  } from "../validate";
22
23
 
23
24
  /** A parsed tabular source: one header row + string cell rows. */
@@ -36,7 +37,7 @@ export interface ColumnMapping {
36
37
  export interface ImportIssue {
37
38
  row: number;
38
39
  field: string;
39
- gate: FieldState;
40
+ gate: FieldState | "transition";
40
41
  constraint: string;
41
42
  message: string;
42
43
  }
@@ -94,6 +95,12 @@ export interface ValidateDatasetOptions {
94
95
  relatedIds?: Record<string, Set<string>>;
95
96
  /** Custom FK resolver (wins over `relatedIds`). */
96
97
  resolveRelation?: (relation: SpecRelation, value: unknown) => boolean;
98
+ /**
99
+ * The resource's state machine (`schema.rastack.json` `transitions`).
100
+ * Imported rows are creates, so the record-level `transition` gate rejects
101
+ * rows born mid-machine (a state other than the initial one).
102
+ */
103
+ transitions?: TransitionsModel;
97
104
  }
98
105
 
99
106
  /**
@@ -108,7 +115,7 @@ export function validateDataset(
108
115
  options: ValidateDatasetOptions = {},
109
116
  ): ImportReport {
110
117
  const { columns, missingFields } = mapHeaders(data.headers, specs);
111
- const machine = compileRecordMachine(specs);
118
+ const machine = compileRecordMachine(specs, options.transitions);
112
119
  const ctx = createDatasetContext(machine, options);
113
120
 
114
121
  const rows: RecordRun[] = [];
@@ -1,22 +1,36 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  /**
4
- * `rastack admin` — the full, Django-admin-style console, served by the native
5
- * `rastack-server` against the real Iceberg warehouse.
4
+ * `rastack admin` — the full, Django-admin-style console, served **in the
5
+ * browser over the prebuilt WebAssembly engine** (exactly like `rastack dev`).
6
6
  *
7
7
  * rastack admin [resourcesDir] [--warehouse <dir>] [--port <n>] [--out <dir>] [--no-open]
8
8
  *
9
- * Unlike the old server-rendered `/admin`, the console is React API Stack's own
10
- * React admin (`tools/admin`, built from `rastack/components` + `rastack/theme`)
11
- * — this command compiles the resources, points the server at the prebuilt
12
- * bundle (`RASTACK_ADMIN_ASSETS`), and boots it. The bundle talks back to the
13
- * same server's `/api` over HTTP, so edits persist to the real warehouse.
9
+ * The console is React API Stack's own React admin (`tools/admin`, built from
10
+ * `rastack/components` + `rastack/theme`). This command compiles the resources,
11
+ * then boots a dependency-free static server that hands the browser everything
12
+ * it needs to run the admin locally over WASM — no native server, no cargo:
13
+ * • the bundled React admin (`/dev/admin.js`), which defaults to `mode:"local"`,
14
+ * • the prebuilt WASM engine (`/wasm/…`) shipped in the package,
15
+ * • the compiled `schema.rastack.json` / `openapi.json`,
16
+ * • the committed Iceberg warehouse (`/data/warehouse/…`) to seed from.
17
+ *
18
+ * The engine is the single prebuilt `.wasm` bundle shipped in the npm package;
19
+ * if it is missing the command errors and points at `rastack update` — it never
20
+ * falls back to a from-source cargo build.
14
21
  */
15
22
 
16
- import { execFileSync, spawn, spawnSync } from "child_process";
23
+ import { execFileSync, spawnSync } from "child_process";
17
24
  import * as fs from "fs";
25
+ import * as http from "http";
18
26
  import * as path from "path";
19
- import { parseAdminArgs } from "./dev/harness";
27
+ import {
28
+ WASM_ENTRY,
29
+ contentType,
30
+ parseAdminArgs,
31
+ renderShell,
32
+ wasmBundleCandidates,
33
+ } from "./dev/harness";
20
34
 
21
35
  const HERE = __dirname; // dist/ when installed — the bundle lives beside us.
22
36
 
@@ -32,6 +46,48 @@ function compile(resourcesDir: string, outDir: string): boolean {
32
46
  return fs.existsSync(path.join(outDir, "schema.rastack.json"));
33
47
  }
34
48
 
49
+ /** Locate the prebuilt wasm bundle dir shipped in the package (no cargo fallback). */
50
+ function resolveWasmDir(): string | null {
51
+ for (const dir of wasmBundleCandidates(HERE)) {
52
+ if (fs.existsSync(path.join(dir, WASM_ENTRY))) return dir;
53
+ }
54
+ return null;
55
+ }
56
+
57
+ /** Serve one file if it exists and stays within `root`; else 404/403. */
58
+ function serveUnder(
59
+ res: http.ServerResponse,
60
+ root: string,
61
+ relPath: string,
62
+ ): void {
63
+ const safeRoot = path.resolve(root);
64
+ const rel = decodeURIComponent(relPath).replace(/^\/+/, "");
65
+ const target = path.resolve(safeRoot, rel);
66
+ // Path-traversal guard: the resolved target must stay inside the root.
67
+ if (target !== safeRoot && !target.startsWith(safeRoot + path.sep)) {
68
+ res.writeHead(403).end("Forbidden");
69
+ return;
70
+ }
71
+ fs.readFile(target, (err, data) => {
72
+ if (err) {
73
+ res.writeHead(404).end("Not found");
74
+ return;
75
+ }
76
+ res.writeHead(200, { "Content-Type": contentType(target) }).end(data);
77
+ });
78
+ }
79
+
80
+ /** Serve an exact file (no traversal concern — a fixed internal artifact). */
81
+ function serveFile(res: http.ServerResponse, file: string): void {
82
+ fs.readFile(file, (err, data) => {
83
+ if (err) {
84
+ res.writeHead(404).end("Not found");
85
+ return;
86
+ }
87
+ res.writeHead(200, { "Content-Type": contentType(file) }).end(data);
88
+ });
89
+ }
90
+
35
91
  /** Best-effort open the default browser at `url`. */
36
92
  function openBrowser(url: string): void {
37
93
  const cmd =
@@ -57,54 +113,57 @@ function main(argv: string[]): void {
57
113
  process.exit(1);
58
114
  }
59
115
 
60
- const bundle = path.join(HERE, "admin.js");
61
- if (!fs.existsSync(bundle)) {
62
- console.error(" ✗ admin bundle (dist/admin.js) not found — reinstall rastack.");
116
+ const wasmDir = resolveWasmDir();
117
+ if (!wasmDir) {
118
+ console.error(
119
+ "\n ✗ No prebuilt WASM engine found in this rastack install.\n" +
120
+ " Update rastack to pull the prebuilt bundle:\n" +
121
+ " rastack update\n",
122
+ );
63
123
  process.exit(1);
64
124
  }
65
125
 
66
- const repoRoot = path.resolve(HERE, "..", "..");
67
- const binary =
68
- process.env.RASTACK_SERVER_BIN ||
69
- path.join(repoRoot, "rust", "target", "release", "rastack-server");
70
-
71
- const manifest = path.join(args.outDir, "schema.rastack.json");
72
- const serverArgs = [
73
- "admin",
74
- "--manifest",
75
- manifest,
76
- "--warehouse",
77
- args.warehouseDir,
78
- "--addr",
79
- `127.0.0.1:${args.port}`,
80
- // Local dev context: the admin runs unauthenticated, exactly like the old
81
- // native admin. Production admins run the server with --jwks/--admin-group.
82
- "--insecure-dev-auth",
83
- ];
84
-
85
- const url = `http://127.0.0.1:${args.port}/admin/`;
86
- // The server serves the React bundle from here; hand the path via env so the
87
- // Rust side stays free of JS-toolchain knowledge.
88
- const child = spawn(binary, serverArgs, {
89
- stdio: "inherit",
90
- env: { ...process.env, RASTACK_ADMIN_ASSETS: HERE },
91
- });
92
-
93
- child.on("error", () => {
126
+ const adminBundle = path.join(HERE, "admin.js");
127
+ if (!fs.existsSync(adminBundle)) {
94
128
  console.error(
95
- ` ✗ Could not run the Rust server (${binary}). Build it first:\n` +
96
- ` cd rust && cargo build --release\n`,
129
+ " ✗ admin bundle (dist/admin.js) not found — reinstall rastack.",
97
130
  );
98
131
  process.exit(1);
132
+ }
133
+
134
+ const server = http.createServer((req, res) => {
135
+ const url = (req.url || "/").split("?")[0];
136
+ if (req.method !== "GET") {
137
+ res.writeHead(405).end("Method not allowed");
138
+ return;
139
+ }
140
+ if (url === "/" || url === "/index.html") {
141
+ res
142
+ .writeHead(200, { "Content-Type": "text/html; charset=utf-8" })
143
+ .end(renderShell("React API Stack — admin"));
144
+ return;
145
+ }
146
+ if (url === "/dev/admin.js") return serveFile(res, adminBundle);
147
+ if (url.startsWith("/wasm/"))
148
+ return serveUnder(res, wasmDir, url.slice("/wasm/".length));
149
+ if (url === "/schema.rastack.json" || url === "/openapi.json")
150
+ return serveUnder(res, args.outDir, path.basename(url));
151
+ if (url.startsWith("/data/warehouse/"))
152
+ return serveUnder(
153
+ res,
154
+ args.warehouseDir,
155
+ url.slice("/data/warehouse/".length),
156
+ );
157
+ res.writeHead(404).end("Not found");
99
158
  });
100
- child.on("exit", (code) => process.exit(code ?? 0));
101
159
 
102
- // Give the server a moment to bind, then print + open.
103
- setTimeout(() => {
160
+ server.listen(args.port, () => {
161
+ const url = `http://localhost:${args.port}`;
162
+ console.log(` ✓ WASM engine: ${path.relative(process.cwd(), wasmDir)}`);
104
163
  console.log(`\n ▸ ${url}\n`);
105
164
  console.log(" Press Ctrl+C to stop.\n");
106
165
  if (args.open) openBrowser(url);
107
- }, 700);
166
+ });
108
167
  }
109
168
 
110
169
  if (require.main === module) {
@@ -148,7 +148,10 @@ function main(): void {
148
148
  `[${data.headers.length} column(s), ${data.rows.length} row(s)]\n`,
149
149
  );
150
150
 
151
- const report = validateDataset(specs, data, { relatedIds });
151
+ const report = validateDataset(specs, data, {
152
+ relatedIds,
153
+ transitions: resource.transitions,
154
+ });
152
155
  printReport(report);
153
156
 
154
157
  const outPath = getArg("--out");
@@ -33,6 +33,7 @@ export function specFromManifestField(field: FieldModel): FieldSpec {
33
33
  };
34
34
  if (field.maxLength !== undefined) spec.maxLength = field.maxLength;
35
35
  if (field.unique) spec.unique = true;
36
+ if (field.options && field.options.length > 0) spec.options = field.options;
36
37
  if (field.default !== undefined) spec.default = field.default;
37
38
  if (field.type === "fk" && field.relation) spec.relation = field.relation;
38
39
  return spec;
@@ -1,2 +1,3 @@
1
1
  export * from "./machine";
2
2
  export * from "./adapters";
3
+ export * from "./transitions";
@@ -34,8 +34,18 @@
34
34
  * single keystroke — passes through them untouched, exactly like the server,
35
35
  * where uniqueness and FK existence are checked against the table, not the
36
36
  * payload.
37
+ *
38
+ * Record machines add one **record-level** gate on top of the per-field
39
+ * chains: `transition`. A resource that declares a `transitions` block gets
40
+ * its state-field writes guarded against the declared edges (with the
41
+ * persisted row supplied via `ctx.previous`), so an illegal state jump fails
42
+ * a form, an import row, and a server write with the same message — see
43
+ * `./transitions.ts`.
37
44
  */
38
45
 
46
+ import type { TransitionsModel } from "../compile/model";
47
+ import { describeTransitions, transitionGuard } from "./transitions";
48
+
39
49
  // ---------------------------------------------------------------------------
40
50
  // The field spec — the normalised constraint set a machine is compiled from
41
51
  // ---------------------------------------------------------------------------
@@ -143,6 +153,12 @@ export interface ValidationContext {
143
153
  seen?: Map<string, Set<string>>;
144
154
  /** Resolve whether a FK value exists on the target resource (the `resolved` gate). */
145
155
  resolveRelation?: (relation: SpecRelation, value: unknown) => boolean;
156
+ /**
157
+ * The persisted row this record updates (the record-level `transition`
158
+ * guard). Absent = create semantics: the state field must hold the initial
159
+ * state. Only consulted by record machines compiled with a transitions block.
160
+ */
161
+ previous?: Record<string, unknown>;
146
162
  }
147
163
 
148
164
  /** The result of running a value through a field machine. */
@@ -442,12 +458,19 @@ export function runFieldMachine(
442
458
  /** A record machine is the composition of its fields' machines. */
443
459
  export interface RecordMachine {
444
460
  fields: FieldMachine[];
461
+ /**
462
+ * The resource's declarative state machine, when it declares one. Runs as a
463
+ * record-level gate after the per-field gates: a write that moves the state
464
+ * field without a declared edge fails at the `transition` gate.
465
+ */
466
+ transitions?: TransitionsModel;
445
467
  }
446
468
 
447
469
  /** One field's failure inside a record run. */
448
470
  export interface RecordError {
449
471
  field: string;
450
- gate: FieldState;
472
+ /** The gate that rejected the value — a field gate, or the record-level `transition` gate. */
473
+ gate: FieldState | "transition";
451
474
  constraint: string;
452
475
  message: string;
453
476
  }
@@ -462,8 +485,13 @@ export interface RecordRun {
462
485
  errors: RecordError[];
463
486
  }
464
487
 
465
- export function compileRecordMachine(specs: FieldSpec[]): RecordMachine {
466
- return { fields: specs.map(compileFieldMachine) };
488
+ export function compileRecordMachine(
489
+ specs: FieldSpec[],
490
+ transitions?: TransitionsModel,
491
+ ): RecordMachine {
492
+ const machine: RecordMachine = { fields: specs.map(compileFieldMachine) };
493
+ if (transitions) machine.transitions = transitions;
494
+ return machine;
467
495
  }
468
496
 
469
497
  export function runRecordMachine(
@@ -485,6 +513,30 @@ export function runRecordMachine(
485
513
  }
486
514
  }
487
515
 
516
+ // The record-level `transition` gate: with the per-field gates passed, a
517
+ // state-field change must follow a declared edge (against `ctx.previous`;
518
+ // no previous row = create semantics). A field-level failure on the state
519
+ // field already explains itself, so the gate only runs when that field is
520
+ // clean — one error per cause, like every other gate.
521
+ const transitions = machine.transitions;
522
+ if (transitions && !fields[transitions.field]?.failed) {
523
+ // Guard against what was actually submitted: an absent state field is not
524
+ // a transition, but the field machine defaults it in `values`.
525
+ const submitted =
526
+ record[transitions.field] === undefined
527
+ ? {}
528
+ : { [transitions.field]: values[transitions.field] };
529
+ const guard = transitionGuard(transitions, ctx.previous, submitted);
530
+ if (!guard.ok) {
531
+ errors.push({
532
+ field: transitions.field,
533
+ gate: "transition",
534
+ constraint: `transitions: ${describeTransitions(transitions).join("; ")}`,
535
+ message: guard.message,
536
+ });
537
+ }
538
+ }
539
+
488
540
  return {
489
541
  state: errors.length === 0 ? "valid" : "invalid",
490
542
  values,
@@ -0,0 +1,232 @@
1
+ /**
2
+ * Declarative state transitions — the record-level layer of the constraint
3
+ * state machine.
4
+ *
5
+ * A resource opts in with a `transitions` block (on `resource()` options, or a
6
+ * `static transitions` on an entity class):
7
+ *
8
+ * ```ts
9
+ * transitions: {
10
+ * field: "status",
11
+ * states: ["scheduled", "boarding", "departed", "cancelled"],
12
+ * initial: "scheduled",
13
+ * on: {
14
+ * board: { from: "scheduled", to: "boarding" },
15
+ * depart: { from: "boarding", to: "departed" },
16
+ * cancel: { from: ["scheduled", "boarding"], to: "cancelled",
17
+ * set: { gate: null } },
18
+ * },
19
+ * }
20
+ * ```
21
+ *
22
+ * The block compiles into the manifest verbatim (with `from` normalised to a
23
+ * list), and this module is the *shared executable form* of it: the same pure
24
+ * functions guard a form submit (`useForm(...).transition("board")`), the
25
+ * record machine (`runRecordMachine` with a `previous` row in context), and —
26
+ * re-implemented 1:1 in `rastack-api-core` — every server/WASM write. A write
27
+ * that jumps between states without a declared edge is rejected on every
28
+ * surface, which is what makes a transition "a backend function that is
29
+ * state-machine safe": the only way to move a record between states is a
30
+ * named edge, and the edge's `set` patches are applied by the engine.
31
+ */
32
+
33
+ import type { TransitionEdgeModel, TransitionsModel } from "../compile/model";
34
+
35
+ export type { TransitionEdgeModel, TransitionsModel } from "../compile/model";
36
+
37
+ /** A transition available from a given state, ready for a UI action list. */
38
+ export interface AvailableTransition {
39
+ name: string;
40
+ to: string;
41
+ set?: Record<string, unknown>;
42
+ }
43
+
44
+ /** The outcome of a transition guard or application. */
45
+ export type TransitionResult =
46
+ | { ok: true; patch: Record<string, unknown> }
47
+ | { ok: false; message: string };
48
+
49
+ /**
50
+ * Normalise a raw (authored) transitions block into the canonical model shape:
51
+ * `from` becomes a string list, non-string states are dropped, and anything
52
+ * structurally broken yields `undefined` (the compiler then reports it).
53
+ */
54
+ export function normalizeTransitions(raw: unknown): TransitionsModel | undefined {
55
+ if (!raw || typeof raw !== "object") return undefined;
56
+ const block = raw as Record<string, unknown>;
57
+ if (typeof block.field !== "string" || !block.field) return undefined;
58
+ if (!Array.isArray(block.states)) return undefined;
59
+ const states = block.states.filter((s): s is string => typeof s === "string");
60
+ if (!states.length) return undefined;
61
+ if (!block.on || typeof block.on !== "object") return undefined;
62
+
63
+ const on: Record<string, TransitionEdgeModel> = {};
64
+ for (const [name, rawEdge] of Object.entries(block.on as Record<string, unknown>)) {
65
+ if (!rawEdge || typeof rawEdge !== "object") continue;
66
+ const edge = rawEdge as Record<string, unknown>;
67
+ const from = (Array.isArray(edge.from) ? edge.from : [edge.from]).filter(
68
+ (s): s is string => typeof s === "string",
69
+ );
70
+ if (!from.length || typeof edge.to !== "string") continue;
71
+ const model: TransitionEdgeModel = { from, to: edge.to };
72
+ if (edge.set && typeof edge.set === "object" && !Array.isArray(edge.set)) {
73
+ model.set = edge.set as Record<string, unknown>;
74
+ }
75
+ on[name] = model;
76
+ }
77
+
78
+ const model: TransitionsModel = { field: block.field, states, on };
79
+ if (typeof block.initial === "string") model.initial = block.initial;
80
+ return model;
81
+ }
82
+
83
+ /**
84
+ * Sanity-check a transitions block against its resource's fields. Returns
85
+ * human-readable problems (empty = valid); the compiler surfaces each as an
86
+ * error diagnostic, mirroring the circular-dependency check.
87
+ */
88
+ export function transitionProblems(
89
+ transitions: TransitionsModel,
90
+ fields: Array<{ name: string }>,
91
+ ): string[] {
92
+ const problems: string[] = [];
93
+ const fieldNames = new Set(fields.map((f) => f.name));
94
+ const states = new Set(transitions.states);
95
+
96
+ if (!fieldNames.has(transitions.field)) {
97
+ problems.push(`transitions.field "${transitions.field}" is not a field`);
98
+ }
99
+ if (transitions.initial !== undefined && !states.has(transitions.initial)) {
100
+ problems.push(`initial state "${transitions.initial}" is not in states`);
101
+ }
102
+ for (const [name, edge] of Object.entries(transitions.on)) {
103
+ for (const from of edge.from) {
104
+ if (!states.has(from)) {
105
+ problems.push(`transition "${name}": from-state "${from}" is not in states`);
106
+ }
107
+ }
108
+ if (!states.has(edge.to)) {
109
+ problems.push(`transition "${name}": to-state "${edge.to}" is not in states`);
110
+ }
111
+ for (const key of Object.keys(edge.set ?? {})) {
112
+ if (key === transitions.field) {
113
+ problems.push(
114
+ `transition "${name}": set must not patch the state field itself`,
115
+ );
116
+ } else if (!fieldNames.has(key)) {
117
+ problems.push(`transition "${name}": set targets unknown field "${key}"`);
118
+ }
119
+ }
120
+ }
121
+ return problems;
122
+ }
123
+
124
+ /** The state a record is in — its state-field value, else the initial state. */
125
+ export function currentState(
126
+ transitions: TransitionsModel,
127
+ record: Record<string, unknown> | undefined,
128
+ ): string | undefined {
129
+ const value = record?.[transitions.field];
130
+ if (value !== undefined && value !== null && value !== "") return String(value);
131
+ return transitions.initial;
132
+ }
133
+
134
+ /** Every transition that may fire from `state` — the form's action list. */
135
+ export function allowedTransitions(
136
+ transitions: TransitionsModel,
137
+ state: unknown,
138
+ ): AvailableTransition[] {
139
+ const from = state === undefined || state === null ? transitions.initial : String(state);
140
+ if (from === undefined) return [];
141
+ return Object.entries(transitions.on)
142
+ .filter(([, edge]) => edge.from.includes(from))
143
+ .map(([name, edge]) => ({ name, to: edge.to, set: edge.set }));
144
+ }
145
+
146
+ /** The declared edge covering `from → to`, if any. */
147
+ export function findTransition(
148
+ transitions: TransitionsModel,
149
+ from: string,
150
+ to: string,
151
+ ): { name: string; edge: TransitionEdgeModel } | undefined {
152
+ for (const [name, edge] of Object.entries(transitions.on)) {
153
+ if (edge.to === to && edge.from.includes(from)) return { name, edge };
154
+ }
155
+ return undefined;
156
+ }
157
+
158
+ /**
159
+ * Guard one write. `previous` is the persisted row (`undefined` = create).
160
+ * A create must start at the initial state; an update that changes the state
161
+ * field must follow a declared edge. Anything else passes untouched — the
162
+ * guard constrains only the state field.
163
+ */
164
+ export function transitionGuard(
165
+ transitions: TransitionsModel,
166
+ previous: Record<string, unknown> | undefined,
167
+ next: Record<string, unknown>,
168
+ ): TransitionResult {
169
+ const value = next[transitions.field];
170
+
171
+ if (previous === undefined) {
172
+ // Create: absent state falls back to the initial default; a supplied state
173
+ // must *be* the initial state — records cannot be born mid-machine.
174
+ if (value === undefined || value === null || value === "") {
175
+ return { ok: true, patch: {} };
176
+ }
177
+ if (transitions.initial !== undefined && String(value) !== transitions.initial) {
178
+ return {
179
+ ok: false,
180
+ message: `${transitions.field}: new records start at "${transitions.initial}", not "${String(value)}"`,
181
+ };
182
+ }
183
+ return { ok: true, patch: {} };
184
+ }
185
+
186
+ // Update: an untouched (or unchanged) state field is not a transition.
187
+ if (value === undefined) return { ok: true, patch: {} };
188
+ const from = currentState(transitions, previous);
189
+ const to = String(value);
190
+ if (from === to) return { ok: true, patch: {} };
191
+
192
+ const match = from !== undefined && findTransition(transitions, from, to);
193
+ if (!match) {
194
+ return {
195
+ ok: false,
196
+ message: `${transitions.field}: no transition from "${String(from)}" to "${to}"`,
197
+ };
198
+ }
199
+ return { ok: true, patch: { ...match.edge.set } };
200
+ }
201
+
202
+ /**
203
+ * Fire a *named* transition from a record's current state: validates the edge
204
+ * is available and returns the full patch to write — the state change plus the
205
+ * edge's `set` effects. This is the client half of the backend function; the
206
+ * server re-derives the same patch from the same manifest.
207
+ */
208
+ export function applyTransition(
209
+ transitions: TransitionsModel,
210
+ name: string,
211
+ record: Record<string, unknown> | undefined,
212
+ ): TransitionResult {
213
+ const edge = transitions.on[name];
214
+ if (!edge) {
215
+ return { ok: false, message: `unknown transition "${name}"` };
216
+ }
217
+ const state = currentState(transitions, record);
218
+ if (state === undefined || !edge.from.includes(state)) {
219
+ return {
220
+ ok: false,
221
+ message: `"${name}" is not available from "${String(state)}" (needs ${edge.from.join(" | ")})`,
222
+ };
223
+ }
224
+ return { ok: true, patch: { [transitions.field]: edge.to, ...edge.set } };
225
+ }
226
+
227
+ /** Human-readable edge list — `"board: scheduled → boarding"` — for UI hints. */
228
+ export function describeTransitions(transitions: TransitionsModel): string[] {
229
+ return Object.entries(transitions.on).map(
230
+ ([name, edge]) => `${name}: ${edge.from.join(" | ")} → ${edge.to}`,
231
+ );
232
+ }
@@ -383,4 +383,26 @@ describe("buildAutoFormModel — wiring a form hook into field descriptors", ()
383
383
  const model = buildAutoFormModel(form, { exclude: ["status"] });
384
384
  expect(model.fields.map((f) => f.name)).toEqual(["code", "active"]);
385
385
  });
386
+
387
+ it("feature-detects state-machine transitions off a useForm(Entity) result", () => {
388
+ // A form without transitions renders no actions.
389
+ expect(buildAutoFormModel(form).transitions).toEqual([]);
390
+
391
+ const transition = jest.fn();
392
+ const machineForm: AutoFormLike = {
393
+ ...form,
394
+ transitions: [
395
+ { name: "board", label: "Board", to: "boarding" },
396
+ { name: "cancel", to: "cancelled" }, // no label — humanised
397
+ ],
398
+ transition,
399
+ };
400
+ const model = buildAutoFormModel(machineForm);
401
+ expect(model.transitions.map((t) => [t.name, t.label, t.to])).toEqual([
402
+ ["board", "Board", "boarding"],
403
+ ["cancel", "Cancel", "cancelled"],
404
+ ]);
405
+ model.transitions[0].fire();
406
+ expect(transition).toHaveBeenCalledWith("board");
407
+ });
386
408
  });