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
|
@@ -36,6 +36,8 @@ function specFromManifestField(field) {
|
|
|
36
36
|
spec.maxLength = field.maxLength;
|
|
37
37
|
if (field.unique)
|
|
38
38
|
spec.unique = true;
|
|
39
|
+
if (field.options && field.options.length > 0)
|
|
40
|
+
spec.options = field.options;
|
|
39
41
|
if (field.default !== undefined)
|
|
40
42
|
spec.default = field.default;
|
|
41
43
|
if (field.type === "fk" && field.relation)
|
package/dist/validate/index.d.ts
CHANGED
package/dist/validate/index.js
CHANGED
|
@@ -16,3 +16,4 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
|
|
16
16
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
17
|
__exportStar(require("./machine"), exports);
|
|
18
18
|
__exportStar(require("./adapters"), exports);
|
|
19
|
+
__exportStar(require("./transitions"), exports);
|
|
@@ -34,7 +34,15 @@
|
|
|
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
|
*/
|
|
45
|
+
import type { TransitionsModel } from "../compile/model";
|
|
38
46
|
/** The `{ app, model }` a foreign-key field points at. */
|
|
39
47
|
export interface SpecRelation {
|
|
40
48
|
app: string;
|
|
@@ -107,6 +115,12 @@ export interface ValidationContext {
|
|
|
107
115
|
seen?: Map<string, Set<string>>;
|
|
108
116
|
/** Resolve whether a FK value exists on the target resource (the `resolved` gate). */
|
|
109
117
|
resolveRelation?: (relation: SpecRelation, value: unknown) => boolean;
|
|
118
|
+
/**
|
|
119
|
+
* The persisted row this record updates (the record-level `transition`
|
|
120
|
+
* guard). Absent = create semantics: the state field must hold the initial
|
|
121
|
+
* state. Only consulted by record machines compiled with a transitions block.
|
|
122
|
+
*/
|
|
123
|
+
previous?: Record<string, unknown>;
|
|
110
124
|
}
|
|
111
125
|
/** The result of running a value through a field machine. */
|
|
112
126
|
export interface FieldRun {
|
|
@@ -144,11 +158,18 @@ export declare function runFieldMachine(machine: FieldMachine, rawValue: unknown
|
|
|
144
158
|
/** A record machine is the composition of its fields' machines. */
|
|
145
159
|
export interface RecordMachine {
|
|
146
160
|
fields: FieldMachine[];
|
|
161
|
+
/**
|
|
162
|
+
* The resource's declarative state machine, when it declares one. Runs as a
|
|
163
|
+
* record-level gate after the per-field gates: a write that moves the state
|
|
164
|
+
* field without a declared edge fails at the `transition` gate.
|
|
165
|
+
*/
|
|
166
|
+
transitions?: TransitionsModel;
|
|
147
167
|
}
|
|
148
168
|
/** One field's failure inside a record run. */
|
|
149
169
|
export interface RecordError {
|
|
150
170
|
field: string;
|
|
151
|
-
gate
|
|
171
|
+
/** The gate that rejected the value — a field gate, or the record-level `transition` gate. */
|
|
172
|
+
gate: FieldState | "transition";
|
|
152
173
|
constraint: string;
|
|
153
174
|
message: string;
|
|
154
175
|
}
|
|
@@ -161,7 +182,7 @@ export interface RecordRun {
|
|
|
161
182
|
fields: Record<string, FieldRun>;
|
|
162
183
|
errors: RecordError[];
|
|
163
184
|
}
|
|
164
|
-
export declare function compileRecordMachine(specs: FieldSpec[]): RecordMachine;
|
|
185
|
+
export declare function compileRecordMachine(specs: FieldSpec[], transitions?: TransitionsModel): RecordMachine;
|
|
165
186
|
export declare function runRecordMachine(machine: RecordMachine, record: Record<string, unknown>, ctx?: ValidationContext): RecordRun;
|
|
166
187
|
/**
|
|
167
188
|
* Build the dataset-level context for a record machine: a seen-set per
|
package/dist/validate/machine.js
CHANGED
|
@@ -35,6 +35,13 @@
|
|
|
35
35
|
* single keystroke — passes through them untouched, exactly like the server,
|
|
36
36
|
* where uniqueness and FK existence are checked against the table, not the
|
|
37
37
|
* payload.
|
|
38
|
+
*
|
|
39
|
+
* Record machines add one **record-level** gate on top of the per-field
|
|
40
|
+
* chains: `transition`. A resource that declares a `transitions` block gets
|
|
41
|
+
* its state-field writes guarded against the declared edges (with the
|
|
42
|
+
* persisted row supplied via `ctx.previous`), so an illegal state jump fails
|
|
43
|
+
* a form, an import row, and a server write with the same message — see
|
|
44
|
+
* `./transitions.ts`.
|
|
38
45
|
*/
|
|
39
46
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
40
47
|
exports.GATE_ORDER = void 0;
|
|
@@ -44,6 +51,7 @@ exports.runFieldMachine = runFieldMachine;
|
|
|
44
51
|
exports.compileRecordMachine = compileRecordMachine;
|
|
45
52
|
exports.runRecordMachine = runRecordMachine;
|
|
46
53
|
exports.createDatasetContext = createDatasetContext;
|
|
54
|
+
const transitions_1 = require("./transitions");
|
|
47
55
|
/** The intermediate (gate) states, in canonical machine order. */
|
|
48
56
|
exports.GATE_ORDER = [
|
|
49
57
|
"present",
|
|
@@ -300,8 +308,11 @@ function runFieldMachine(machine, rawValue, ctx = EMPTY_CTX) {
|
|
|
300
308
|
path.push("valid");
|
|
301
309
|
return { state: "valid", path, value };
|
|
302
310
|
}
|
|
303
|
-
function compileRecordMachine(specs) {
|
|
304
|
-
|
|
311
|
+
function compileRecordMachine(specs, transitions) {
|
|
312
|
+
const machine = { fields: specs.map(compileFieldMachine) };
|
|
313
|
+
if (transitions)
|
|
314
|
+
machine.transitions = transitions;
|
|
315
|
+
return machine;
|
|
305
316
|
}
|
|
306
317
|
function runRecordMachine(machine, record, ctx = EMPTY_CTX) {
|
|
307
318
|
const fields = {};
|
|
@@ -316,6 +327,28 @@ function runRecordMachine(machine, record, ctx = EMPTY_CTX) {
|
|
|
316
327
|
errors.push({ field: name, ...run.failed });
|
|
317
328
|
}
|
|
318
329
|
}
|
|
330
|
+
// The record-level `transition` gate: with the per-field gates passed, a
|
|
331
|
+
// state-field change must follow a declared edge (against `ctx.previous`;
|
|
332
|
+
// no previous row = create semantics). A field-level failure on the state
|
|
333
|
+
// field already explains itself, so the gate only runs when that field is
|
|
334
|
+
// clean — one error per cause, like every other gate.
|
|
335
|
+
const transitions = machine.transitions;
|
|
336
|
+
if (transitions && !fields[transitions.field]?.failed) {
|
|
337
|
+
// Guard against what was actually submitted: an absent state field is not
|
|
338
|
+
// a transition, but the field machine defaults it in `values`.
|
|
339
|
+
const submitted = record[transitions.field] === undefined
|
|
340
|
+
? {}
|
|
341
|
+
: { [transitions.field]: values[transitions.field] };
|
|
342
|
+
const guard = (0, transitions_1.transitionGuard)(transitions, ctx.previous, submitted);
|
|
343
|
+
if (!guard.ok) {
|
|
344
|
+
errors.push({
|
|
345
|
+
field: transitions.field,
|
|
346
|
+
gate: "transition",
|
|
347
|
+
constraint: `transitions: ${(0, transitions_1.describeTransitions)(transitions).join("; ")}`,
|
|
348
|
+
message: guard.message,
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
}
|
|
319
352
|
return {
|
|
320
353
|
state: errors.length === 0 ? "valid" : "invalid",
|
|
321
354
|
values,
|
|
@@ -0,0 +1,86 @@
|
|
|
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
|
+
import type { TransitionEdgeModel, TransitionsModel } from "../compile/model";
|
|
33
|
+
export type { TransitionEdgeModel, TransitionsModel } from "../compile/model";
|
|
34
|
+
/** A transition available from a given state, ready for a UI action list. */
|
|
35
|
+
export interface AvailableTransition {
|
|
36
|
+
name: string;
|
|
37
|
+
to: string;
|
|
38
|
+
set?: Record<string, unknown>;
|
|
39
|
+
}
|
|
40
|
+
/** The outcome of a transition guard or application. */
|
|
41
|
+
export type TransitionResult = {
|
|
42
|
+
ok: true;
|
|
43
|
+
patch: Record<string, unknown>;
|
|
44
|
+
} | {
|
|
45
|
+
ok: false;
|
|
46
|
+
message: string;
|
|
47
|
+
};
|
|
48
|
+
/**
|
|
49
|
+
* Normalise a raw (authored) transitions block into the canonical model shape:
|
|
50
|
+
* `from` becomes a string list, non-string states are dropped, and anything
|
|
51
|
+
* structurally broken yields `undefined` (the compiler then reports it).
|
|
52
|
+
*/
|
|
53
|
+
export declare function normalizeTransitions(raw: unknown): TransitionsModel | undefined;
|
|
54
|
+
/**
|
|
55
|
+
* Sanity-check a transitions block against its resource's fields. Returns
|
|
56
|
+
* human-readable problems (empty = valid); the compiler surfaces each as an
|
|
57
|
+
* error diagnostic, mirroring the circular-dependency check.
|
|
58
|
+
*/
|
|
59
|
+
export declare function transitionProblems(transitions: TransitionsModel, fields: Array<{
|
|
60
|
+
name: string;
|
|
61
|
+
}>): string[];
|
|
62
|
+
/** The state a record is in — its state-field value, else the initial state. */
|
|
63
|
+
export declare function currentState(transitions: TransitionsModel, record: Record<string, unknown> | undefined): string | undefined;
|
|
64
|
+
/** Every transition that may fire from `state` — the form's action list. */
|
|
65
|
+
export declare function allowedTransitions(transitions: TransitionsModel, state: unknown): AvailableTransition[];
|
|
66
|
+
/** The declared edge covering `from → to`, if any. */
|
|
67
|
+
export declare function findTransition(transitions: TransitionsModel, from: string, to: string): {
|
|
68
|
+
name: string;
|
|
69
|
+
edge: TransitionEdgeModel;
|
|
70
|
+
} | undefined;
|
|
71
|
+
/**
|
|
72
|
+
* Guard one write. `previous` is the persisted row (`undefined` = create).
|
|
73
|
+
* A create must start at the initial state; an update that changes the state
|
|
74
|
+
* field must follow a declared edge. Anything else passes untouched — the
|
|
75
|
+
* guard constrains only the state field.
|
|
76
|
+
*/
|
|
77
|
+
export declare function transitionGuard(transitions: TransitionsModel, previous: Record<string, unknown> | undefined, next: Record<string, unknown>): TransitionResult;
|
|
78
|
+
/**
|
|
79
|
+
* Fire a *named* transition from a record's current state: validates the edge
|
|
80
|
+
* is available and returns the full patch to write — the state change plus the
|
|
81
|
+
* edge's `set` effects. This is the client half of the backend function; the
|
|
82
|
+
* server re-derives the same patch from the same manifest.
|
|
83
|
+
*/
|
|
84
|
+
export declare function applyTransition(transitions: TransitionsModel, name: string, record: Record<string, unknown> | undefined): TransitionResult;
|
|
85
|
+
/** Human-readable edge list — `"board: scheduled → boarding"` — for UI hints. */
|
|
86
|
+
export declare function describeTransitions(transitions: TransitionsModel): string[];
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Declarative state transitions — the record-level layer of the constraint
|
|
4
|
+
* state machine.
|
|
5
|
+
*
|
|
6
|
+
* A resource opts in with a `transitions` block (on `resource()` options, or a
|
|
7
|
+
* `static transitions` on an entity class):
|
|
8
|
+
*
|
|
9
|
+
* ```ts
|
|
10
|
+
* transitions: {
|
|
11
|
+
* field: "status",
|
|
12
|
+
* states: ["scheduled", "boarding", "departed", "cancelled"],
|
|
13
|
+
* initial: "scheduled",
|
|
14
|
+
* on: {
|
|
15
|
+
* board: { from: "scheduled", to: "boarding" },
|
|
16
|
+
* depart: { from: "boarding", to: "departed" },
|
|
17
|
+
* cancel: { from: ["scheduled", "boarding"], to: "cancelled",
|
|
18
|
+
* set: { gate: null } },
|
|
19
|
+
* },
|
|
20
|
+
* }
|
|
21
|
+
* ```
|
|
22
|
+
*
|
|
23
|
+
* The block compiles into the manifest verbatim (with `from` normalised to a
|
|
24
|
+
* list), and this module is the *shared executable form* of it: the same pure
|
|
25
|
+
* functions guard a form submit (`useForm(...).transition("board")`), the
|
|
26
|
+
* record machine (`runRecordMachine` with a `previous` row in context), and —
|
|
27
|
+
* re-implemented 1:1 in `rastack-api-core` — every server/WASM write. A write
|
|
28
|
+
* that jumps between states without a declared edge is rejected on every
|
|
29
|
+
* surface, which is what makes a transition "a backend function that is
|
|
30
|
+
* state-machine safe": the only way to move a record between states is a
|
|
31
|
+
* named edge, and the edge's `set` patches are applied by the engine.
|
|
32
|
+
*/
|
|
33
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
34
|
+
exports.normalizeTransitions = normalizeTransitions;
|
|
35
|
+
exports.transitionProblems = transitionProblems;
|
|
36
|
+
exports.currentState = currentState;
|
|
37
|
+
exports.allowedTransitions = allowedTransitions;
|
|
38
|
+
exports.findTransition = findTransition;
|
|
39
|
+
exports.transitionGuard = transitionGuard;
|
|
40
|
+
exports.applyTransition = applyTransition;
|
|
41
|
+
exports.describeTransitions = describeTransitions;
|
|
42
|
+
/**
|
|
43
|
+
* Normalise a raw (authored) transitions block into the canonical model shape:
|
|
44
|
+
* `from` becomes a string list, non-string states are dropped, and anything
|
|
45
|
+
* structurally broken yields `undefined` (the compiler then reports it).
|
|
46
|
+
*/
|
|
47
|
+
function normalizeTransitions(raw) {
|
|
48
|
+
if (!raw || typeof raw !== "object")
|
|
49
|
+
return undefined;
|
|
50
|
+
const block = raw;
|
|
51
|
+
if (typeof block.field !== "string" || !block.field)
|
|
52
|
+
return undefined;
|
|
53
|
+
if (!Array.isArray(block.states))
|
|
54
|
+
return undefined;
|
|
55
|
+
const states = block.states.filter((s) => typeof s === "string");
|
|
56
|
+
if (!states.length)
|
|
57
|
+
return undefined;
|
|
58
|
+
if (!block.on || typeof block.on !== "object")
|
|
59
|
+
return undefined;
|
|
60
|
+
const on = {};
|
|
61
|
+
for (const [name, rawEdge] of Object.entries(block.on)) {
|
|
62
|
+
if (!rawEdge || typeof rawEdge !== "object")
|
|
63
|
+
continue;
|
|
64
|
+
const edge = rawEdge;
|
|
65
|
+
const from = (Array.isArray(edge.from) ? edge.from : [edge.from]).filter((s) => typeof s === "string");
|
|
66
|
+
if (!from.length || typeof edge.to !== "string")
|
|
67
|
+
continue;
|
|
68
|
+
const model = { from, to: edge.to };
|
|
69
|
+
if (edge.set && typeof edge.set === "object" && !Array.isArray(edge.set)) {
|
|
70
|
+
model.set = edge.set;
|
|
71
|
+
}
|
|
72
|
+
on[name] = model;
|
|
73
|
+
}
|
|
74
|
+
const model = { field: block.field, states, on };
|
|
75
|
+
if (typeof block.initial === "string")
|
|
76
|
+
model.initial = block.initial;
|
|
77
|
+
return model;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Sanity-check a transitions block against its resource's fields. Returns
|
|
81
|
+
* human-readable problems (empty = valid); the compiler surfaces each as an
|
|
82
|
+
* error diagnostic, mirroring the circular-dependency check.
|
|
83
|
+
*/
|
|
84
|
+
function transitionProblems(transitions, fields) {
|
|
85
|
+
const problems = [];
|
|
86
|
+
const fieldNames = new Set(fields.map((f) => f.name));
|
|
87
|
+
const states = new Set(transitions.states);
|
|
88
|
+
if (!fieldNames.has(transitions.field)) {
|
|
89
|
+
problems.push(`transitions.field "${transitions.field}" is not a field`);
|
|
90
|
+
}
|
|
91
|
+
if (transitions.initial !== undefined && !states.has(transitions.initial)) {
|
|
92
|
+
problems.push(`initial state "${transitions.initial}" is not in states`);
|
|
93
|
+
}
|
|
94
|
+
for (const [name, edge] of Object.entries(transitions.on)) {
|
|
95
|
+
for (const from of edge.from) {
|
|
96
|
+
if (!states.has(from)) {
|
|
97
|
+
problems.push(`transition "${name}": from-state "${from}" is not in states`);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
if (!states.has(edge.to)) {
|
|
101
|
+
problems.push(`transition "${name}": to-state "${edge.to}" is not in states`);
|
|
102
|
+
}
|
|
103
|
+
for (const key of Object.keys(edge.set ?? {})) {
|
|
104
|
+
if (key === transitions.field) {
|
|
105
|
+
problems.push(`transition "${name}": set must not patch the state field itself`);
|
|
106
|
+
}
|
|
107
|
+
else if (!fieldNames.has(key)) {
|
|
108
|
+
problems.push(`transition "${name}": set targets unknown field "${key}"`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return problems;
|
|
113
|
+
}
|
|
114
|
+
/** The state a record is in — its state-field value, else the initial state. */
|
|
115
|
+
function currentState(transitions, record) {
|
|
116
|
+
const value = record?.[transitions.field];
|
|
117
|
+
if (value !== undefined && value !== null && value !== "")
|
|
118
|
+
return String(value);
|
|
119
|
+
return transitions.initial;
|
|
120
|
+
}
|
|
121
|
+
/** Every transition that may fire from `state` — the form's action list. */
|
|
122
|
+
function allowedTransitions(transitions, state) {
|
|
123
|
+
const from = state === undefined || state === null ? transitions.initial : String(state);
|
|
124
|
+
if (from === undefined)
|
|
125
|
+
return [];
|
|
126
|
+
return Object.entries(transitions.on)
|
|
127
|
+
.filter(([, edge]) => edge.from.includes(from))
|
|
128
|
+
.map(([name, edge]) => ({ name, to: edge.to, set: edge.set }));
|
|
129
|
+
}
|
|
130
|
+
/** The declared edge covering `from → to`, if any. */
|
|
131
|
+
function findTransition(transitions, from, to) {
|
|
132
|
+
for (const [name, edge] of Object.entries(transitions.on)) {
|
|
133
|
+
if (edge.to === to && edge.from.includes(from))
|
|
134
|
+
return { name, edge };
|
|
135
|
+
}
|
|
136
|
+
return undefined;
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Guard one write. `previous` is the persisted row (`undefined` = create).
|
|
140
|
+
* A create must start at the initial state; an update that changes the state
|
|
141
|
+
* field must follow a declared edge. Anything else passes untouched — the
|
|
142
|
+
* guard constrains only the state field.
|
|
143
|
+
*/
|
|
144
|
+
function transitionGuard(transitions, previous, next) {
|
|
145
|
+
const value = next[transitions.field];
|
|
146
|
+
if (previous === undefined) {
|
|
147
|
+
// Create: absent state falls back to the initial default; a supplied state
|
|
148
|
+
// must *be* the initial state — records cannot be born mid-machine.
|
|
149
|
+
if (value === undefined || value === null || value === "") {
|
|
150
|
+
return { ok: true, patch: {} };
|
|
151
|
+
}
|
|
152
|
+
if (transitions.initial !== undefined && String(value) !== transitions.initial) {
|
|
153
|
+
return {
|
|
154
|
+
ok: false,
|
|
155
|
+
message: `${transitions.field}: new records start at "${transitions.initial}", not "${String(value)}"`,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
return { ok: true, patch: {} };
|
|
159
|
+
}
|
|
160
|
+
// Update: an untouched (or unchanged) state field is not a transition.
|
|
161
|
+
if (value === undefined)
|
|
162
|
+
return { ok: true, patch: {} };
|
|
163
|
+
const from = currentState(transitions, previous);
|
|
164
|
+
const to = String(value);
|
|
165
|
+
if (from === to)
|
|
166
|
+
return { ok: true, patch: {} };
|
|
167
|
+
const match = from !== undefined && findTransition(transitions, from, to);
|
|
168
|
+
if (!match) {
|
|
169
|
+
return {
|
|
170
|
+
ok: false,
|
|
171
|
+
message: `${transitions.field}: no transition from "${String(from)}" to "${to}"`,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
return { ok: true, patch: { ...match.edge.set } };
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Fire a *named* transition from a record's current state: validates the edge
|
|
178
|
+
* is available and returns the full patch to write — the state change plus the
|
|
179
|
+
* edge's `set` effects. This is the client half of the backend function; the
|
|
180
|
+
* server re-derives the same patch from the same manifest.
|
|
181
|
+
*/
|
|
182
|
+
function applyTransition(transitions, name, record) {
|
|
183
|
+
const edge = transitions.on[name];
|
|
184
|
+
if (!edge) {
|
|
185
|
+
return { ok: false, message: `unknown transition "${name}"` };
|
|
186
|
+
}
|
|
187
|
+
const state = currentState(transitions, record);
|
|
188
|
+
if (state === undefined || !edge.from.includes(state)) {
|
|
189
|
+
return {
|
|
190
|
+
ok: false,
|
|
191
|
+
message: `"${name}" is not available from "${String(state)}" (needs ${edge.from.join(" | ")})`,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
return { ok: true, patch: { [transitions.field]: edge.to, ...edge.set } };
|
|
195
|
+
}
|
|
196
|
+
/** Human-readable edge list — `"board: scheduled → boarding"` — for UI hints. */
|
|
197
|
+
function describeTransitions(transitions) {
|
|
198
|
+
return Object.entries(transitions.on).map(([name, edge]) => `${name}: ${edge.from.join(" | ")} → ${edge.to}`);
|
|
199
|
+
}
|
|
@@ -375,7 +375,7 @@ function __wbg_get_imports() {
|
|
|
375
375
|
return ret;
|
|
376
376
|
},
|
|
377
377
|
__wbindgen_cast_0000000000000001: function(arg0, arg1) {
|
|
378
|
-
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx:
|
|
378
|
+
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 85, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`.
|
|
379
379
|
const ret = makeMutClosure(arg0, arg1, wasm_bindgen_1a7aa9db5392c486___convert__closures_____invoke___wasm_bindgen_1a7aa9db5392c486___JsValue__core_7d5f0a2ba6a62c33___result__Result_____wasm_bindgen_1a7aa9db5392c486___JsError___true_);
|
|
380
380
|
return ret;
|
|
381
381
|
},
|
|
Binary file
|
package/hooks/data.ts
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `useData` — pass your type in, get the data layer out.
|
|
3
|
+
*
|
|
4
|
+
* ```tsx
|
|
5
|
+
* const airports = useData(Airport); // a class
|
|
6
|
+
* const flights = useData(Flight); // a resource() value
|
|
7
|
+
* const gates = useData<ITerminal>("airports.terminal"); // an interface
|
|
8
|
+
*
|
|
9
|
+
* <DataTable query={airports} />
|
|
10
|
+
* airports.create.mutate({ params: {}, payload: { code: "LHR", name: "Heathrow" } });
|
|
11
|
+
* ```
|
|
12
|
+
*
|
|
13
|
+
* One generic hook replaces the generated `useList{Model}` /
|
|
14
|
+
* `usePost{Model}Create` / `useDelete{Model}` families: the entity reference
|
|
15
|
+
* resolves against the compiled manifest (see `./entity.ts`), the URLs follow
|
|
16
|
+
* the unchanged `/api/{app}/v1/{model}/` convention, and the result is the
|
|
17
|
+
* same `IListQuery` shape `<DataTable>` feature-detects — search, sorting and
|
|
18
|
+
* pagination included — plus typed `create` / `update` / `remove` mutations.
|
|
19
|
+
*
|
|
20
|
+
* `<RAStackProvider mode=…>` still decides where the API physically runs
|
|
21
|
+
* (HTTP, in-browser WASM, S3) by reconfiguring the shared axios client, so
|
|
22
|
+
* `useData` is transport-agnostic exactly like the generated hooks were.
|
|
23
|
+
* (Resources replicated with `sync: { mode: "local" }` keep their store-backed
|
|
24
|
+
* descriptor hooks — `useEntityList` et al. in `rastack/sync`; a store-backed
|
|
25
|
+
* `useData` dispatch is deferred, documented in `docs/typed-hooks.md`.)
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { useMemo } from "react";
|
|
29
|
+
import { useGetPaginatedQueryList } from "./query/list";
|
|
30
|
+
import { useGetQuery } from "./query/fetch";
|
|
31
|
+
import { useUpdateQuery } from "./query/update";
|
|
32
|
+
import { useDeleteQuery } from "./query/delete";
|
|
33
|
+
import type {
|
|
34
|
+
IDeleteQueryResult,
|
|
35
|
+
IGetQueryResult,
|
|
36
|
+
IListQuery,
|
|
37
|
+
IUpdateQueryResult,
|
|
38
|
+
ListQueryOptions,
|
|
39
|
+
ReturnedHeader,
|
|
40
|
+
} from "./query/interfaces";
|
|
41
|
+
import {
|
|
42
|
+
EntityClass,
|
|
43
|
+
EntityRef,
|
|
44
|
+
EntityResource,
|
|
45
|
+
EntityRow,
|
|
46
|
+
humanizeName,
|
|
47
|
+
ResolvedEntity,
|
|
48
|
+
resolveEntityRef,
|
|
49
|
+
} from "./entity";
|
|
50
|
+
import { useRastackManifest } from "./manifest";
|
|
51
|
+
import type { EntityTypes, RegisteredEntityKey } from "./registry";
|
|
52
|
+
import type { ResourceModel } from "../src/compile/model";
|
|
53
|
+
import { toManifest, type ManifestInput } from "../src/define/manifest";
|
|
54
|
+
|
|
55
|
+
export interface UseDataOptions<TRow>
|
|
56
|
+
extends ListQueryOptions<Record<string, unknown>, TRow> {
|
|
57
|
+
/** Gate the query (replaces the generated hooks' auth wiring). Default on. */
|
|
58
|
+
enabled?: boolean;
|
|
59
|
+
/**
|
|
60
|
+
* Manifest override — tests, or callers outside `<RAStackProvider>`.
|
|
61
|
+
* Accepts a compiled manifest or `resource()` definitions (array/module).
|
|
62
|
+
*/
|
|
63
|
+
manifest?: ManifestInput;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* What `useData` returns: a DataTable-ready list query + typed mutations.
|
|
68
|
+
* `remove` is the DELETE mutation — it intentionally replaces react-query's
|
|
69
|
+
* cache-eviction `remove()` on the result (evicting the list cache by hand is
|
|
70
|
+
* not part of this surface; deleting records is).
|
|
71
|
+
*/
|
|
72
|
+
export interface UseDataResult<TRow>
|
|
73
|
+
extends Omit<IListQuery<Record<string, unknown>, TRow>, "remove"> {
|
|
74
|
+
/** The resolved entity — urls, manifest resource, transitions. */
|
|
75
|
+
entity: ResolvedEntity;
|
|
76
|
+
/** POST to the collection. `mutate({ params: {}, payload })`. */
|
|
77
|
+
create: IUpdateQueryResult<Record<string, unknown>, Partial<TRow>, TRow>;
|
|
78
|
+
/** PATCH one record. `mutate({ params: { id }, payload })`. */
|
|
79
|
+
update: IUpdateQueryResult<
|
|
80
|
+
{ id: string | number } & Record<string, unknown>,
|
|
81
|
+
Partial<TRow>,
|
|
82
|
+
TRow
|
|
83
|
+
>;
|
|
84
|
+
/** DELETE one record. `mutate({ id })`. */
|
|
85
|
+
remove: IDeleteQueryResult<TRow>;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Column headers derived from the manifest (`admin.listDisplay`), if any. */
|
|
89
|
+
function manifestHeader(
|
|
90
|
+
resource: ResourceModel | undefined,
|
|
91
|
+
): ReturnedHeader[] | undefined {
|
|
92
|
+
const admin = resource?.admin;
|
|
93
|
+
const listDisplay =
|
|
94
|
+
admin && Array.isArray(admin.listDisplay) ? admin.listDisplay : undefined;
|
|
95
|
+
return listDisplay?.map((key) => ({ key, name: humanizeName(key) }));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Rows are inferred from the reference itself — no type argument needed:
|
|
99
|
+
// a registered "app.model" string resolves through the EntityTypes registry
|
|
100
|
+
// (`rastack compile` emits rastack-env.d.ts), a class carries its own row
|
|
101
|
+
// type, and a resource() value derives one from its fields. The trailing
|
|
102
|
+
// generic overload keeps explicit `useData<ITerminal>("…")` (and
|
|
103
|
+
// unregistered strings) working.
|
|
104
|
+
export function useData<K extends RegisteredEntityKey>(
|
|
105
|
+
entity: K,
|
|
106
|
+
options?: UseDataOptions<EntityTypes[K]>,
|
|
107
|
+
): UseDataResult<EntityTypes[K]>;
|
|
108
|
+
export function useData<E extends EntityClass<any> | EntityResource>(
|
|
109
|
+
entity: E,
|
|
110
|
+
options?: UseDataOptions<EntityRow<E>>,
|
|
111
|
+
): UseDataResult<EntityRow<E>>;
|
|
112
|
+
export function useData<TRow = any>(
|
|
113
|
+
entity: EntityRef<TRow>,
|
|
114
|
+
options?: UseDataOptions<TRow>,
|
|
115
|
+
): UseDataResult<TRow>;
|
|
116
|
+
export function useData(
|
|
117
|
+
entity: EntityRef,
|
|
118
|
+
options?: UseDataOptions<any>,
|
|
119
|
+
): UseDataResult<any> {
|
|
120
|
+
const contextManifest = useRastackManifest();
|
|
121
|
+
const optionManifest = useMemo(
|
|
122
|
+
() => toManifest(options?.manifest),
|
|
123
|
+
[options?.manifest],
|
|
124
|
+
);
|
|
125
|
+
const manifest = optionManifest ?? contextManifest;
|
|
126
|
+
const resolvedEntity = useMemo(
|
|
127
|
+
() => resolveEntityRef(entity, manifest),
|
|
128
|
+
[entity, manifest],
|
|
129
|
+
);
|
|
130
|
+
const listQueryCacheKey = `${resolvedEntity.key}-list`;
|
|
131
|
+
const itemQueryCacheKey = `${resolvedEntity.key}-detail`;
|
|
132
|
+
|
|
133
|
+
const query = useGetPaginatedQueryList<Record<string, unknown>, any>({
|
|
134
|
+
getUrl: resolvedEntity.listUrl,
|
|
135
|
+
queryKey: listQueryCacheKey,
|
|
136
|
+
pathParameters: [],
|
|
137
|
+
isAuthenticated: options?.enabled ?? true,
|
|
138
|
+
options: {
|
|
139
|
+
// `?search=` is the server convention for resources with `search`
|
|
140
|
+
// fields; harmless (ignored) otherwise.
|
|
141
|
+
searchKey: "search",
|
|
142
|
+
...options,
|
|
143
|
+
},
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
const create = useUpdateQuery<Record<string, unknown>, any, any>({
|
|
147
|
+
postUrl: resolvedEntity.listUrl,
|
|
148
|
+
listQueryCacheKey,
|
|
149
|
+
});
|
|
150
|
+
const update = useUpdateQuery<
|
|
151
|
+
{ id: string | number } & Record<string, unknown>,
|
|
152
|
+
any,
|
|
153
|
+
any
|
|
154
|
+
>({
|
|
155
|
+
patchUrl: (params) => resolvedEntity.detailUrl(params.id),
|
|
156
|
+
listQueryCacheKey,
|
|
157
|
+
itemQueryCacheKey,
|
|
158
|
+
});
|
|
159
|
+
const remove = useDeleteQuery<any>({
|
|
160
|
+
deleteUrl: (id) => resolvedEntity.detailUrl(id),
|
|
161
|
+
listQueryCacheKey,
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
return {
|
|
165
|
+
...query,
|
|
166
|
+
header: query.header ?? manifestHeader(resolvedEntity.resource),
|
|
167
|
+
entity: resolvedEntity,
|
|
168
|
+
create,
|
|
169
|
+
update,
|
|
170
|
+
remove,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export interface UseRecordOptions {
|
|
175
|
+
enabled?: boolean;
|
|
176
|
+
manifest?: ManifestInput;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** One record by id — the generic twin of the generated detail hooks. */
|
|
180
|
+
export function useRecord<K extends RegisteredEntityKey>(
|
|
181
|
+
entity: K,
|
|
182
|
+
id: string | number | undefined,
|
|
183
|
+
options?: UseRecordOptions,
|
|
184
|
+
): IGetQueryResult<EntityTypes[K]> & { entity: ResolvedEntity };
|
|
185
|
+
export function useRecord<E extends EntityClass<any> | EntityResource>(
|
|
186
|
+
entity: E,
|
|
187
|
+
id: string | number | undefined,
|
|
188
|
+
options?: UseRecordOptions,
|
|
189
|
+
): IGetQueryResult<EntityRow<E>> & { entity: ResolvedEntity };
|
|
190
|
+
export function useRecord<TRow = any>(
|
|
191
|
+
entity: EntityRef<TRow>,
|
|
192
|
+
id: string | number | undefined,
|
|
193
|
+
options?: UseRecordOptions,
|
|
194
|
+
): IGetQueryResult<TRow> & { entity: ResolvedEntity };
|
|
195
|
+
export function useRecord(
|
|
196
|
+
entity: EntityRef,
|
|
197
|
+
id: string | number | undefined,
|
|
198
|
+
options?: UseRecordOptions,
|
|
199
|
+
): IGetQueryResult<any> & { entity: ResolvedEntity } {
|
|
200
|
+
const contextManifest = useRastackManifest();
|
|
201
|
+
const optionManifest = useMemo(
|
|
202
|
+
() => toManifest(options?.manifest),
|
|
203
|
+
[options?.manifest],
|
|
204
|
+
);
|
|
205
|
+
const manifest = optionManifest ?? contextManifest;
|
|
206
|
+
const resolvedEntity = useMemo(
|
|
207
|
+
() => resolveEntityRef(entity, manifest),
|
|
208
|
+
[entity, manifest],
|
|
209
|
+
);
|
|
210
|
+
|
|
211
|
+
const query = useGetQuery<Record<string, unknown>, any>({
|
|
212
|
+
getUrl: resolvedEntity.detailUrl(id ?? ""),
|
|
213
|
+
queryKey: `${resolvedEntity.key}-detail`,
|
|
214
|
+
pathParameters: [],
|
|
215
|
+
requiredParams: [],
|
|
216
|
+
isAuthenticated: (options?.enabled ?? true) && id !== undefined && id !== null,
|
|
217
|
+
options: { params: {} },
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
return Object.assign(query, { entity: resolvedEntity });
|
|
221
|
+
}
|