rastack 0.0.49 → 0.0.51
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/CHANGELOG.md +4 -0
- package/components/auto-form/AutoForm.tsx +13 -0
- package/components/auto-form/use-auto-form.ts +28 -0
- package/components/types.ts +8 -0
- package/dist/admin.js +13 -13
- package/dist/compile/analyze.d.ts +20 -0
- package/dist/compile/analyze.js +60 -49
- package/dist/compile/entities.d.ts +19 -0
- package/dist/compile/entities.js +87 -13
- package/dist/compile/index.d.ts +23 -4
- package/dist/compile/index.js +86 -7
- package/dist/compile/model.d.ts +38 -0
- package/dist/compile/openapi.d.ts +9 -0
- package/dist/compile/openapi.js +14 -0
- package/dist/define/index.d.ts +162 -21
- package/dist/define/index.js +28 -22
- package/dist/define/manifest.d.ts +64 -0
- package/dist/define/manifest.js +250 -0
- package/dist/import/tabular.d.ts +8 -2
- package/dist/import/tabular.js +1 -1
- package/dist/plugin/core.d.ts +108 -0
- package/dist/plugin/core.js +198 -0
- package/dist/plugin/index.d.ts +112 -0
- package/dist/plugin/index.js +203 -0
- package/dist/rastack-import.js +4 -1
- package/dist/validate/adapters.js +2 -0
- package/dist/validate/index.d.ts +1 -0
- package/dist/validate/index.js +1 -0
- package/dist/validate/machine.d.ts +23 -2
- package/dist/validate/machine.js +35 -2
- package/dist/validate/transitions.d.ts +86 -0
- package/dist/validate/transitions.js +199 -0
- package/dist/wasm/rastack_wasm.js +1 -1
- package/dist/wasm/rastack_wasm_bg.wasm +0 -0
- package/hooks/data.ts +221 -0
- package/hooks/entity.ts +228 -0
- package/hooks/form/entity-form.ts +358 -0
- package/hooks/form/form.ts +8 -1
- package/hooks/form/index.ts +7 -1
- package/hooks/index.ts +4 -0
- package/hooks/manifest.ts +77 -0
- package/hooks/registry.ts +56 -0
- package/package.json +1 -1
- package/plugin.ts +8 -0
- package/provider/provider.tsx +26 -5
- package/provider/types.ts +15 -3
- package/src/compile/analyze.ts +74 -45
- package/src/compile/entities.ts +111 -11
- package/src/compile/index.ts +108 -11
- package/src/compile/model.ts +40 -0
- package/src/compile/openapi.ts +13 -1
- package/src/define/index.ts +233 -29
- package/src/define/manifest.ts +278 -0
- package/src/import/tabular.ts +9 -2
- package/src/plugin/core.ts +236 -0
- package/src/plugin/index.ts +243 -0
- package/src/rastack-import.ts +4 -1
- package/src/validate/adapters.ts +1 -0
- package/src/validate/index.ts +1 -0
- package/src/validate/machine.ts +55 -3
- package/src/validate/transitions.ts +232 -0
- package/test/components.spec.ts +22 -0
- package/test/plugin.spec.ts +315 -0
- package/test/runtime-manifest.spec.ts +309 -0
- package/test/transitions.spec.ts +372 -0
- package/test/typed-hooks.spec.ts +412 -0
- package/wasm/rastack_wasm.js +1 -1
- package/wasm/rastack_wasm_bg.wasm +0 -0
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `rastack/plugin` — the schema compiles itself inside your dev server.
|
|
3
|
+
*
|
|
4
|
+
* Class and interface entities are pure types, so the manifest has to be
|
|
5
|
+
* derived by the TypeScript checker — but not by a command you run. Add the
|
|
6
|
+
* plugin to the bundler you already use and the whole `rastack compile`
|
|
7
|
+
* pass disappears into the dev loop:
|
|
8
|
+
*
|
|
9
|
+
* ```ts
|
|
10
|
+
* // vite.config.ts
|
|
11
|
+
* import { rastackPlugin } from "rastack/plugin";
|
|
12
|
+
* export default defineConfig({
|
|
13
|
+
* plugins: [rastackPlugin({ resourcesDir: "src/entities" })],
|
|
14
|
+
* });
|
|
15
|
+
* ```
|
|
16
|
+
*
|
|
17
|
+
* ```tsx
|
|
18
|
+
* // app — the manifest is a live import, recompiled on every save
|
|
19
|
+
* import manifest from "virtual:rastack-manifest";
|
|
20
|
+
* <RAStackProvider mode="local" manifest={manifest}>…</RAStackProvider>
|
|
21
|
+
* ```
|
|
22
|
+
*
|
|
23
|
+
* What it does on your behalf, on every relevant file save:
|
|
24
|
+
* - recompiles the entity graph (classes, interfaces, `resource()` calls);
|
|
25
|
+
* - serves the manifest as the `virtual:rastack-manifest` module and
|
|
26
|
+
* hot-invalidates it;
|
|
27
|
+
* - rewrites `.rastack/rastack-env.d.ts` (string-key row types) and
|
|
28
|
+
* `schema.rastack.json`/`openapi.json`, so the committed artifacts and the
|
|
29
|
+
* registry never go stale;
|
|
30
|
+
* - on a broken in-progress edit, reports the error and keeps serving the
|
|
31
|
+
* last good manifest.
|
|
32
|
+
*
|
|
33
|
+
* The plugin object is Vite/Rollup-compatible (Vite-specific hooks are
|
|
34
|
+
* ignored by plain Rollup). For dev servers with other plugin systems
|
|
35
|
+
* (Next/webpack, Metro/Expo), `startRastackWatcher` does the same
|
|
36
|
+
* recompile-on-save against the filesystem — call it once from your config —
|
|
37
|
+
* and `rastack dev` remains the zero-config path.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
import * as fs from "fs";
|
|
41
|
+
import * as path from "path";
|
|
42
|
+
import {
|
|
43
|
+
createSchemaState,
|
|
44
|
+
isEntitySource,
|
|
45
|
+
registerModuleCode,
|
|
46
|
+
RESOLVED_MANIFEST_ID,
|
|
47
|
+
VIRTUAL_MANIFEST_ID,
|
|
48
|
+
VIRTUAL_REGISTER_ID,
|
|
49
|
+
type RastackPluginOptions,
|
|
50
|
+
type SchemaState,
|
|
51
|
+
} from "./core";
|
|
52
|
+
|
|
53
|
+
export {
|
|
54
|
+
createSchemaState,
|
|
55
|
+
GLOBAL_MANIFEST_KEY,
|
|
56
|
+
isEntitySource,
|
|
57
|
+
manifestModuleCode,
|
|
58
|
+
registerModuleCode,
|
|
59
|
+
resolveOptions,
|
|
60
|
+
RESOLVED_MANIFEST_ID,
|
|
61
|
+
runCompile,
|
|
62
|
+
VIRTUAL_MANIFEST_ID,
|
|
63
|
+
VIRTUAL_REGISTER_ID,
|
|
64
|
+
virtualModuleDts,
|
|
65
|
+
} from "./core";
|
|
66
|
+
export type {
|
|
67
|
+
RastackPluginOptions,
|
|
68
|
+
ResolvedPluginOptions,
|
|
69
|
+
SchemaState,
|
|
70
|
+
} from "./core";
|
|
71
|
+
|
|
72
|
+
/** One HTML tag descriptor in Vite's `transformIndexHtml` shape. */
|
|
73
|
+
export interface HtmlTagDescriptor {
|
|
74
|
+
tag: string;
|
|
75
|
+
attrs?: Record<string, string>;
|
|
76
|
+
children?: string;
|
|
77
|
+
injectTo?: "head" | "head-prepend" | "body" | "body-prepend";
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The minimal bundler-plugin surface we emit (Vite superset of Rollup). */
|
|
81
|
+
export interface RastackBundlerPlugin {
|
|
82
|
+
name: string;
|
|
83
|
+
buildStart(): void;
|
|
84
|
+
resolveId(id: string): string | undefined;
|
|
85
|
+
load(id: string): string | undefined;
|
|
86
|
+
watchChange(id: string): void;
|
|
87
|
+
/**
|
|
88
|
+
* Vite-only: auto-register the schema. Injects a module script that sets
|
|
89
|
+
* `globalThis.__RASTACK_MANIFEST__` before the app boots, so the provider
|
|
90
|
+
* and hooks need no `manifest` wiring at all. Object form with
|
|
91
|
+
* `order: "pre"` — the tag must be injected *before* Vite scans the HTML
|
|
92
|
+
* entry, or the build ships the virtual import verbatim.
|
|
93
|
+
*/
|
|
94
|
+
transformIndexHtml: {
|
|
95
|
+
order: "pre";
|
|
96
|
+
handler(html: string, ctx?: { server?: unknown }): HtmlTagDescriptor[];
|
|
97
|
+
};
|
|
98
|
+
/** Vite-only: recompile + hot-invalidate the virtual manifest module. */
|
|
99
|
+
handleHotUpdate(ctx: {
|
|
100
|
+
file: string;
|
|
101
|
+
server: {
|
|
102
|
+
moduleGraph: {
|
|
103
|
+
getModuleById(id: string): unknown;
|
|
104
|
+
invalidateModule(mod: any): void;
|
|
105
|
+
};
|
|
106
|
+
};
|
|
107
|
+
modules: unknown[];
|
|
108
|
+
}): unknown[] | void;
|
|
109
|
+
/** The live schema state — exposed for tests and advanced callers. */
|
|
110
|
+
api: SchemaState;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The bundler plugin. Works as-is in Vite and Rollup; the returned object is
|
|
115
|
+
* plain data + closures, so any bundler with resolve/load/watch hooks can
|
|
116
|
+
* adapt it.
|
|
117
|
+
*/
|
|
118
|
+
export function rastackPlugin(
|
|
119
|
+
options: RastackPluginOptions = {},
|
|
120
|
+
): RastackBundlerPlugin {
|
|
121
|
+
const state = createSchemaState(options);
|
|
122
|
+
|
|
123
|
+
return {
|
|
124
|
+
name: "rastack",
|
|
125
|
+
|
|
126
|
+
buildStart() {
|
|
127
|
+
// Compile eagerly so authoring errors surface at server start, and the
|
|
128
|
+
// artifacts (env.d.ts, schema.rastack.json) exist before first load.
|
|
129
|
+
state.manifest();
|
|
130
|
+
},
|
|
131
|
+
|
|
132
|
+
resolveId(id: string) {
|
|
133
|
+
if (id === VIRTUAL_MANIFEST_ID) return RESOLVED_MANIFEST_ID;
|
|
134
|
+
if (id === VIRTUAL_REGISTER_ID) return VIRTUAL_REGISTER_ID;
|
|
135
|
+
return undefined;
|
|
136
|
+
},
|
|
137
|
+
|
|
138
|
+
load(id: string) {
|
|
139
|
+
if (id === RESOLVED_MANIFEST_ID) return state.code();
|
|
140
|
+
if (id === VIRTUAL_REGISTER_ID) return registerModuleCode();
|
|
141
|
+
return undefined;
|
|
142
|
+
},
|
|
143
|
+
|
|
144
|
+
watchChange(id: string) {
|
|
145
|
+
if (isEntitySource(id, state.options)) state.invalidate();
|
|
146
|
+
},
|
|
147
|
+
|
|
148
|
+
handleHotUpdate(ctx) {
|
|
149
|
+
if (!isEntitySource(ctx.file, state.options)) return;
|
|
150
|
+
state.invalidate();
|
|
151
|
+
const mod = ctx.server.moduleGraph.getModuleById(RESOLVED_MANIFEST_ID);
|
|
152
|
+
if (mod) {
|
|
153
|
+
ctx.server.moduleGraph.invalidateModule(mod);
|
|
154
|
+
return [...ctx.modules, mod];
|
|
155
|
+
}
|
|
156
|
+
},
|
|
157
|
+
|
|
158
|
+
transformIndexHtml: {
|
|
159
|
+
order: "pre",
|
|
160
|
+
handler(_html: string, ctx?: { server?: unknown }) {
|
|
161
|
+
// Dev serves resolved ids at /@id/<id>; build gets an inline module
|
|
162
|
+
// script that (because this runs `pre`, before Vite scans the HTML
|
|
163
|
+
// entry) is bundled, its import resolving back through
|
|
164
|
+
// resolveId/load above.
|
|
165
|
+
const attrs: Record<string, string> = { type: "module" };
|
|
166
|
+
const tag: HtmlTagDescriptor = ctx?.server
|
|
167
|
+
? {
|
|
168
|
+
tag: "script",
|
|
169
|
+
attrs: { ...attrs, src: `/@id/${VIRTUAL_REGISTER_ID}` },
|
|
170
|
+
}
|
|
171
|
+
: {
|
|
172
|
+
tag: "script",
|
|
173
|
+
attrs,
|
|
174
|
+
children: `import ${JSON.stringify(VIRTUAL_REGISTER_ID)};`,
|
|
175
|
+
};
|
|
176
|
+
return [{ ...tag, injectTo: "head-prepend" }];
|
|
177
|
+
},
|
|
178
|
+
},
|
|
179
|
+
|
|
180
|
+
api: state,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Alias so `import { rastack } from "rastack/plugin"` reads naturally. */
|
|
185
|
+
export const rastack = rastackPlugin;
|
|
186
|
+
|
|
187
|
+
export interface RastackWatcher {
|
|
188
|
+
/** Stop watching. */
|
|
189
|
+
close(): void;
|
|
190
|
+
/** The live schema state (same object the recompiles flow through). */
|
|
191
|
+
state: SchemaState;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* The plugin for everything that isn't Rollup-shaped: watch the resources
|
|
196
|
+
* directory and rewrite the artifacts (`schema.rastack.json`,
|
|
197
|
+
* `openapi.json`, `rastack-env.d.ts`) on every save. Call once from any
|
|
198
|
+
* Node-side config (next.config.js, metro.config.js, a dev script):
|
|
199
|
+
*
|
|
200
|
+
* ```js
|
|
201
|
+
* // next.config.js
|
|
202
|
+
* const { startRastackWatcher } = require("rastack/plugin");
|
|
203
|
+
* if (process.env.NODE_ENV === "development") startRastackWatcher();
|
|
204
|
+
* ```
|
|
205
|
+
*
|
|
206
|
+
* The app then imports `.rastack/schema.rastack.json` directly (a plain JSON
|
|
207
|
+
* import every bundler hot-reloads) instead of the virtual module.
|
|
208
|
+
*/
|
|
209
|
+
export function startRastackWatcher(
|
|
210
|
+
options: RastackPluginOptions = {},
|
|
211
|
+
): RastackWatcher {
|
|
212
|
+
const state = createSchemaState(options);
|
|
213
|
+
state.manifest(); // initial compile (writes the artifacts)
|
|
214
|
+
|
|
215
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
216
|
+
const watcher = fs.watch(
|
|
217
|
+
state.options.resourcesDir,
|
|
218
|
+
{ recursive: true },
|
|
219
|
+
(_event, filename) => {
|
|
220
|
+
// fs.watch reports paths relative to the watched directory.
|
|
221
|
+
const file = filename
|
|
222
|
+
? path.join(state.options.resourcesDir, String(filename))
|
|
223
|
+
: undefined;
|
|
224
|
+
if (!file || !isEntitySource(file, state.options)) {
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
state.invalidate();
|
|
228
|
+
// Debounce bursts (editors write twice, git checkouts touch many files).
|
|
229
|
+
if (timer) clearTimeout(timer);
|
|
230
|
+
timer = setTimeout(() => {
|
|
231
|
+
state.manifest();
|
|
232
|
+
}, 100);
|
|
233
|
+
},
|
|
234
|
+
);
|
|
235
|
+
|
|
236
|
+
return {
|
|
237
|
+
close: () => {
|
|
238
|
+
if (timer) clearTimeout(timer);
|
|
239
|
+
watcher.close();
|
|
240
|
+
},
|
|
241
|
+
state,
|
|
242
|
+
};
|
|
243
|
+
}
|
package/src/rastack-import.ts
CHANGED
|
@@ -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, {
|
|
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");
|
package/src/validate/adapters.ts
CHANGED
|
@@ -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;
|
package/src/validate/index.ts
CHANGED
package/src/validate/machine.ts
CHANGED
|
@@ -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
|
|
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(
|
|
466
|
-
|
|
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
|
+
}
|
package/test/components.spec.ts
CHANGED
|
@@ -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
|
});
|