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.
- 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 +26 -0
- package/dist/compile/analyze.js +70 -3
- 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 +107 -13
- package/dist/define/index.js +1 -1
- package/dist/import/tabular.d.ts +8 -2
- package/dist/import/tabular.js +1 -1
- package/dist/rastack-admin.d.ts +14 -7
- package/dist/rastack-admin.js +85 -40
- 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 +209 -0
- package/hooks/entity.ts +222 -0
- package/hooks/form/entity-form.ts +354 -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 +41 -0
- package/hooks/registry.ts +39 -0
- package/package.json +1 -1
- package/provider/provider.tsx +8 -1
- package/provider/types.ts +7 -2
- package/src/compile/analyze.ts +83 -4
- 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 +148 -20
- package/src/import/tabular.ts +9 -2
- package/src/rastack-admin.ts +105 -46
- 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/transitions.spec.ts +372 -0
- package/test/typed-hooks.spec.ts +407 -0
- package/wasm/rastack_wasm.js +1 -1
- package/wasm/rastack_wasm_bg.wasm +0 -0
|
@@ -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,209 @@
|
|
|
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 { Manifest, ResourceModel } from "../src/compile/model";
|
|
53
|
+
|
|
54
|
+
export interface UseDataOptions<TRow>
|
|
55
|
+
extends ListQueryOptions<Record<string, unknown>, TRow> {
|
|
56
|
+
/** Gate the query (replaces the generated hooks' auth wiring). Default on. */
|
|
57
|
+
enabled?: boolean;
|
|
58
|
+
/** Manifest override — tests, or callers outside `<RAStackProvider>`. */
|
|
59
|
+
manifest?: Manifest;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* What `useData` returns: a DataTable-ready list query + typed mutations.
|
|
64
|
+
* `remove` is the DELETE mutation — it intentionally replaces react-query's
|
|
65
|
+
* cache-eviction `remove()` on the result (evicting the list cache by hand is
|
|
66
|
+
* not part of this surface; deleting records is).
|
|
67
|
+
*/
|
|
68
|
+
export interface UseDataResult<TRow>
|
|
69
|
+
extends Omit<IListQuery<Record<string, unknown>, TRow>, "remove"> {
|
|
70
|
+
/** The resolved entity — urls, manifest resource, transitions. */
|
|
71
|
+
entity: ResolvedEntity;
|
|
72
|
+
/** POST to the collection. `mutate({ params: {}, payload })`. */
|
|
73
|
+
create: IUpdateQueryResult<Record<string, unknown>, Partial<TRow>, TRow>;
|
|
74
|
+
/** PATCH one record. `mutate({ params: { id }, payload })`. */
|
|
75
|
+
update: IUpdateQueryResult<
|
|
76
|
+
{ id: string | number } & Record<string, unknown>,
|
|
77
|
+
Partial<TRow>,
|
|
78
|
+
TRow
|
|
79
|
+
>;
|
|
80
|
+
/** DELETE one record. `mutate({ id })`. */
|
|
81
|
+
remove: IDeleteQueryResult<TRow>;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Column headers derived from the manifest (`admin.listDisplay`), if any. */
|
|
85
|
+
function manifestHeader(
|
|
86
|
+
resource: ResourceModel | undefined,
|
|
87
|
+
): ReturnedHeader[] | undefined {
|
|
88
|
+
const admin = resource?.admin;
|
|
89
|
+
const listDisplay =
|
|
90
|
+
admin && Array.isArray(admin.listDisplay) ? admin.listDisplay : undefined;
|
|
91
|
+
return listDisplay?.map((key) => ({ key, name: humanizeName(key) }));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Rows are inferred from the reference itself — no type argument needed:
|
|
95
|
+
// a registered "app.model" string resolves through the EntityTypes registry
|
|
96
|
+
// (`rastack compile` emits rastack-env.d.ts), a class carries its own row
|
|
97
|
+
// type, and a resource() value derives one from its fields. The trailing
|
|
98
|
+
// generic overload keeps explicit `useData<ITerminal>("…")` (and
|
|
99
|
+
// unregistered strings) working.
|
|
100
|
+
export function useData<K extends RegisteredEntityKey>(
|
|
101
|
+
entity: K,
|
|
102
|
+
options?: UseDataOptions<EntityTypes[K]>,
|
|
103
|
+
): UseDataResult<EntityTypes[K]>;
|
|
104
|
+
export function useData<E extends EntityClass<any> | EntityResource>(
|
|
105
|
+
entity: E,
|
|
106
|
+
options?: UseDataOptions<EntityRow<E>>,
|
|
107
|
+
): UseDataResult<EntityRow<E>>;
|
|
108
|
+
export function useData<TRow = any>(
|
|
109
|
+
entity: EntityRef<TRow>,
|
|
110
|
+
options?: UseDataOptions<TRow>,
|
|
111
|
+
): UseDataResult<TRow>;
|
|
112
|
+
export function useData(
|
|
113
|
+
entity: EntityRef,
|
|
114
|
+
options?: UseDataOptions<any>,
|
|
115
|
+
): UseDataResult<any> {
|
|
116
|
+
const contextManifest = useRastackManifest();
|
|
117
|
+
const manifest = options?.manifest ?? contextManifest;
|
|
118
|
+
const resolvedEntity = useMemo(
|
|
119
|
+
() => resolveEntityRef(entity, manifest),
|
|
120
|
+
[entity, manifest],
|
|
121
|
+
);
|
|
122
|
+
const listQueryCacheKey = `${resolvedEntity.key}-list`;
|
|
123
|
+
const itemQueryCacheKey = `${resolvedEntity.key}-detail`;
|
|
124
|
+
|
|
125
|
+
const query = useGetPaginatedQueryList<Record<string, unknown>, any>({
|
|
126
|
+
getUrl: resolvedEntity.listUrl,
|
|
127
|
+
queryKey: listQueryCacheKey,
|
|
128
|
+
pathParameters: [],
|
|
129
|
+
isAuthenticated: options?.enabled ?? true,
|
|
130
|
+
options: {
|
|
131
|
+
// `?search=` is the server convention for resources with `search`
|
|
132
|
+
// fields; harmless (ignored) otherwise.
|
|
133
|
+
searchKey: "search",
|
|
134
|
+
...options,
|
|
135
|
+
},
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
const create = useUpdateQuery<Record<string, unknown>, any, any>({
|
|
139
|
+
postUrl: resolvedEntity.listUrl,
|
|
140
|
+
listQueryCacheKey,
|
|
141
|
+
});
|
|
142
|
+
const update = useUpdateQuery<
|
|
143
|
+
{ id: string | number } & Record<string, unknown>,
|
|
144
|
+
any,
|
|
145
|
+
any
|
|
146
|
+
>({
|
|
147
|
+
patchUrl: (params) => resolvedEntity.detailUrl(params.id),
|
|
148
|
+
listQueryCacheKey,
|
|
149
|
+
itemQueryCacheKey,
|
|
150
|
+
});
|
|
151
|
+
const remove = useDeleteQuery<any>({
|
|
152
|
+
deleteUrl: (id) => resolvedEntity.detailUrl(id),
|
|
153
|
+
listQueryCacheKey,
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
return {
|
|
157
|
+
...query,
|
|
158
|
+
header: query.header ?? manifestHeader(resolvedEntity.resource),
|
|
159
|
+
entity: resolvedEntity,
|
|
160
|
+
create,
|
|
161
|
+
update,
|
|
162
|
+
remove,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export interface UseRecordOptions {
|
|
167
|
+
enabled?: boolean;
|
|
168
|
+
manifest?: Manifest;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** One record by id — the generic twin of the generated detail hooks. */
|
|
172
|
+
export function useRecord<K extends RegisteredEntityKey>(
|
|
173
|
+
entity: K,
|
|
174
|
+
id: string | number | undefined,
|
|
175
|
+
options?: UseRecordOptions,
|
|
176
|
+
): IGetQueryResult<EntityTypes[K]> & { entity: ResolvedEntity };
|
|
177
|
+
export function useRecord<E extends EntityClass<any> | EntityResource>(
|
|
178
|
+
entity: E,
|
|
179
|
+
id: string | number | undefined,
|
|
180
|
+
options?: UseRecordOptions,
|
|
181
|
+
): IGetQueryResult<EntityRow<E>> & { entity: ResolvedEntity };
|
|
182
|
+
export function useRecord<TRow = any>(
|
|
183
|
+
entity: EntityRef<TRow>,
|
|
184
|
+
id: string | number | undefined,
|
|
185
|
+
options?: UseRecordOptions,
|
|
186
|
+
): IGetQueryResult<TRow> & { entity: ResolvedEntity };
|
|
187
|
+
export function useRecord(
|
|
188
|
+
entity: EntityRef,
|
|
189
|
+
id: string | number | undefined,
|
|
190
|
+
options?: UseRecordOptions,
|
|
191
|
+
): IGetQueryResult<any> & { entity: ResolvedEntity } {
|
|
192
|
+
const contextManifest = useRastackManifest();
|
|
193
|
+
const manifest = options?.manifest ?? contextManifest;
|
|
194
|
+
const resolvedEntity = useMemo(
|
|
195
|
+
() => resolveEntityRef(entity, manifest),
|
|
196
|
+
[entity, manifest],
|
|
197
|
+
);
|
|
198
|
+
|
|
199
|
+
const query = useGetQuery<Record<string, unknown>, any>({
|
|
200
|
+
getUrl: resolvedEntity.detailUrl(id ?? ""),
|
|
201
|
+
queryKey: `${resolvedEntity.key}-detail`,
|
|
202
|
+
pathParameters: [],
|
|
203
|
+
requiredParams: [],
|
|
204
|
+
isAuthenticated: (options?.enabled ?? true) && id !== undefined && id !== null,
|
|
205
|
+
options: { params: {} },
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
return Object.assign(query, { entity: resolvedEntity });
|
|
209
|
+
}
|
package/hooks/entity.ts
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Entity references — how `useData(...)` / `useForm(...)` know which resource
|
|
3
|
+
* a plain TypeScript type is, with **no generated hooks in between**.
|
|
4
|
+
*
|
|
5
|
+
* A hook accepts any of:
|
|
6
|
+
*
|
|
7
|
+
* - **the entity class itself** — `useData(Airport)`. Classes are values, so
|
|
8
|
+
* the class *is* the reference; its name resolves against the manifest with
|
|
9
|
+
* the same normalisation the compiler applies (`IFlight` → `flight`).
|
|
10
|
+
* - **a `resource()` definition** — `useData(Flight)`. The DSL value carries
|
|
11
|
+
* `app`/`model` (and its literal-typed options, so transition names are
|
|
12
|
+
* typed) at runtime.
|
|
13
|
+
* - **an `"app.model"` (or bare `"model"`) string** — the escape hatch for
|
|
14
|
+
* interface entities, which have no runtime value:
|
|
15
|
+
* `useData<Terminal>("airports.terminal")`.
|
|
16
|
+
* - **any `{ app, model }` object** — e.g. a sync `EntityDescriptor`.
|
|
17
|
+
*
|
|
18
|
+
* Resolution is a pure function over the compiled manifest
|
|
19
|
+
* (`schema.rastack.json`), which `<RAStackProvider manifest={…}>` supplies via
|
|
20
|
+
* context — the type system names the entity, the manifest carries its
|
|
21
|
+
* schema, and the codegen'd per-entity hook layer disappears.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import type {
|
|
25
|
+
Manifest,
|
|
26
|
+
ResourceModel,
|
|
27
|
+
TransitionsModel,
|
|
28
|
+
} from "../src/compile/model";
|
|
29
|
+
import type { ResourceType, RowOf } from "../src/define";
|
|
30
|
+
import type { EntityTypes } from "./registry";
|
|
31
|
+
import { normalizeTransitions } from "../src/validate/transitions";
|
|
32
|
+
|
|
33
|
+
/** A class used as an entity reference (`useData(Airport)`). */
|
|
34
|
+
export type EntityClass<TRow = any> = new (...args: any[]) => TRow;
|
|
35
|
+
|
|
36
|
+
/** A `resource()` definition used as an entity reference (`useData(Flight)`). */
|
|
37
|
+
export type EntityResource = ResourceType<string, string, any, any>;
|
|
38
|
+
|
|
39
|
+
/** Anything that can name an entity. */
|
|
40
|
+
export type EntityRef<TRow = any> =
|
|
41
|
+
| string
|
|
42
|
+
| EntityClass<TRow>
|
|
43
|
+
| { app: string; model: string };
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The row type an entity reference implies — every reference form infers:
|
|
47
|
+
* classes carry it (`InstanceType`), `resource()` values derive it from their
|
|
48
|
+
* fields ({@link RowOf}), and `"app.model"` strings resolve through the
|
|
49
|
+
* {@link EntityTypes} registry `rastack compile` emits (`rastack-env.d.ts`).
|
|
50
|
+
*/
|
|
51
|
+
export type EntityRow<E> =
|
|
52
|
+
E extends EntityClass<infer R>
|
|
53
|
+
? R
|
|
54
|
+
: E extends EntityResource
|
|
55
|
+
? RowOf<E>
|
|
56
|
+
: E extends keyof EntityTypes
|
|
57
|
+
? EntityTypes[E]
|
|
58
|
+
: any;
|
|
59
|
+
|
|
60
|
+
/** A reference resolved against the manifest — everything a hook needs. */
|
|
61
|
+
export interface ResolvedEntity {
|
|
62
|
+
app: string;
|
|
63
|
+
model: string;
|
|
64
|
+
/** `"app.model"` — cache keys, sync store keys. */
|
|
65
|
+
key: string;
|
|
66
|
+
/** `/api/{app}/v1/{model}/` — the URL convention is unchanged. */
|
|
67
|
+
listUrl: string;
|
|
68
|
+
detailUrl: (id: string | number) => string;
|
|
69
|
+
/** The manifest resource (present whenever a manifest was available). */
|
|
70
|
+
resource?: ResourceModel;
|
|
71
|
+
/** The resource's state machine, when it declares one. */
|
|
72
|
+
transitions?: TransitionsModel;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The manifest model name a TypeScript type name maps to — the *same*
|
|
77
|
+
* normalisation the compiler applies (`entityNaming` in `compile/entities.ts`):
|
|
78
|
+
* a leading `I` prefix is stripped (`IFlight` → `Flight`) and the first letter
|
|
79
|
+
* lower-cased.
|
|
80
|
+
*/
|
|
81
|
+
export function entityModelName(typeName: string): string {
|
|
82
|
+
const stripped = /^I[A-Z]/.test(typeName) ? typeName.slice(1) : typeName;
|
|
83
|
+
return stripped.charAt(0).toLowerCase() + stripped.slice(1);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function resolved(
|
|
87
|
+
app: string,
|
|
88
|
+
model: string,
|
|
89
|
+
resource?: ResourceModel,
|
|
90
|
+
fallbackTransitions?: unknown,
|
|
91
|
+
): ResolvedEntity {
|
|
92
|
+
const listUrl = `/api/${app}/v1/${model}/`;
|
|
93
|
+
return {
|
|
94
|
+
app,
|
|
95
|
+
model,
|
|
96
|
+
key: `${app}.${model}`,
|
|
97
|
+
listUrl,
|
|
98
|
+
detailUrl: (id) => `${listUrl}${id}/`,
|
|
99
|
+
resource,
|
|
100
|
+
transitions:
|
|
101
|
+
resource?.transitions ?? normalizeTransitions(fallbackTransitions),
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function findByModel(manifest: Manifest, model: string): ResourceModel[] {
|
|
106
|
+
return manifest.resources.filter((r) => r.model === model);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Resolve an entity reference against the manifest. Throws a descriptive
|
|
111
|
+
* error on an unknown or ambiguous reference — resolution failures are
|
|
112
|
+
* authoring mistakes and should fail loudly, not degrade into 404s.
|
|
113
|
+
*/
|
|
114
|
+
export function resolveEntityRef(
|
|
115
|
+
ref: EntityRef,
|
|
116
|
+
manifest?: Manifest,
|
|
117
|
+
): ResolvedEntity {
|
|
118
|
+
// `{ app, model }` — a `resource()` value, an EntityDescriptor, or a plain
|
|
119
|
+
// object. The manifest enriches it (fields, transitions) when available.
|
|
120
|
+
if (typeof ref === "object" && ref !== null) {
|
|
121
|
+
const { app, model } = ref as { app?: unknown; model?: unknown };
|
|
122
|
+
if (typeof app !== "string" || typeof model !== "string") {
|
|
123
|
+
throw new Error(
|
|
124
|
+
"useData/useForm: an object entity reference needs string `app` and `model` properties",
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
const resource = manifest?.resources.find(
|
|
128
|
+
(r) => r.app === app && r.model === model,
|
|
129
|
+
);
|
|
130
|
+
const opts = (ref as { options?: { transitions?: unknown } }).options;
|
|
131
|
+
return resolved(app, model, resource, opts?.transitions);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// A class — the flagship form. Its name resolves like the compiler resolves
|
|
135
|
+
// it, and a `static transitions` block rides along as a manifest fallback.
|
|
136
|
+
if (typeof ref === "function") {
|
|
137
|
+
const name = ref.name;
|
|
138
|
+
if (!name) {
|
|
139
|
+
throw new Error("useData/useForm: anonymous classes cannot name an entity");
|
|
140
|
+
}
|
|
141
|
+
if (!manifest) {
|
|
142
|
+
throw new Error(
|
|
143
|
+
`useData/useForm: resolving the ${name} class needs the compiled manifest — pass \`manifest\` to <RAStackProvider> (or in the hook's options)`,
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
const model = entityModelName(name);
|
|
147
|
+
const matches = findByModel(manifest, model);
|
|
148
|
+
if (matches.length === 1) {
|
|
149
|
+
return resolved(
|
|
150
|
+
matches[0].app,
|
|
151
|
+
matches[0].model,
|
|
152
|
+
matches[0],
|
|
153
|
+
(ref as { transitions?: unknown }).transitions,
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
if (matches.length === 0) {
|
|
157
|
+
throw new Error(
|
|
158
|
+
`useData/useForm: no resource named "${model}" in the manifest — is ${name} part of the compiled resource graph (\`rastack compile\`)?`,
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
throw new Error(
|
|
162
|
+
`useData/useForm: "${model}" exists in ${matches.length} apps (${matches
|
|
163
|
+
.map((m) => `${m.app}.${m.model}`)
|
|
164
|
+
.join(", ")}) — pass the explicit "app.model" string instead`,
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// A string — `"app.model"`, or a bare `"model"` resolved via the manifest.
|
|
169
|
+
if (typeof ref === "string" && ref) {
|
|
170
|
+
const dot = ref.indexOf(".");
|
|
171
|
+
if (dot > 0) {
|
|
172
|
+
const app = ref.slice(0, dot);
|
|
173
|
+
const model = ref.slice(dot + 1);
|
|
174
|
+
const resource = manifest?.resources.find(
|
|
175
|
+
(r) => r.app === app && r.model === model,
|
|
176
|
+
);
|
|
177
|
+
if (manifest && !resource) {
|
|
178
|
+
throw new Error(
|
|
179
|
+
`useData/useForm: no resource "${ref}" in the manifest`,
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
return resolved(app, model, resource);
|
|
183
|
+
}
|
|
184
|
+
if (!manifest) {
|
|
185
|
+
throw new Error(
|
|
186
|
+
`useData/useForm: resolving "${ref}" needs the compiled manifest — pass \`manifest\` to <RAStackProvider>, or use the full "app.model" form`,
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
const matches = findByModel(manifest, ref);
|
|
190
|
+
if (matches.length === 1) {
|
|
191
|
+
return resolved(matches[0].app, matches[0].model, matches[0]);
|
|
192
|
+
}
|
|
193
|
+
throw new Error(
|
|
194
|
+
matches.length === 0
|
|
195
|
+
? `useData/useForm: no resource named "${ref}" in the manifest`
|
|
196
|
+
: `useData/useForm: "${ref}" exists in ${matches.length} apps — use the "app.model" form`,
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
throw new Error("useData/useForm: unsupported entity reference");
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Whether a value is an entity reference (vs. legacy `useForm(props)`). */
|
|
204
|
+
export function isEntityRef(value: unknown): value is EntityRef {
|
|
205
|
+
if (typeof value === "string" || typeof value === "function") return true;
|
|
206
|
+
return (
|
|
207
|
+
!!value &&
|
|
208
|
+
typeof value === "object" &&
|
|
209
|
+
typeof (value as { app?: unknown }).app === "string" &&
|
|
210
|
+
typeof (value as { model?: unknown }).model === "string"
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** `snake_or_camel` → `Snake or camel` — column/action labels. */
|
|
215
|
+
export function humanizeName(key: string): string {
|
|
216
|
+
const spaced = key
|
|
217
|
+
.replace(/_/g, " ")
|
|
218
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
|
|
219
|
+
.toLowerCase()
|
|
220
|
+
.trim();
|
|
221
|
+
return spaced.charAt(0).toUpperCase() + spaced.slice(1);
|
|
222
|
+
}
|