rastack 0.0.48 → 0.0.50

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/components/auto-form/AutoForm.tsx +13 -0
  3. package/components/auto-form/use-auto-form.ts +28 -0
  4. package/components/types.ts +8 -0
  5. package/dist/admin.js +13 -13
  6. package/dist/compile/analyze.d.ts +26 -0
  7. package/dist/compile/analyze.js +70 -3
  8. package/dist/compile/entities.d.ts +19 -0
  9. package/dist/compile/entities.js +87 -13
  10. package/dist/compile/index.d.ts +23 -4
  11. package/dist/compile/index.js +86 -7
  12. package/dist/compile/model.d.ts +38 -0
  13. package/dist/compile/openapi.d.ts +9 -0
  14. package/dist/compile/openapi.js +14 -0
  15. package/dist/define/index.d.ts +107 -13
  16. package/dist/define/index.js +1 -1
  17. package/dist/import/tabular.d.ts +8 -2
  18. package/dist/import/tabular.js +1 -1
  19. package/dist/rastack-admin.d.ts +14 -7
  20. package/dist/rastack-admin.js +85 -40
  21. package/dist/rastack-import.js +4 -1
  22. package/dist/validate/adapters.js +2 -0
  23. package/dist/validate/index.d.ts +1 -0
  24. package/dist/validate/index.js +1 -0
  25. package/dist/validate/machine.d.ts +23 -2
  26. package/dist/validate/machine.js +35 -2
  27. package/dist/validate/transitions.d.ts +86 -0
  28. package/dist/validate/transitions.js +199 -0
  29. package/dist/wasm/rastack_wasm.js +1 -1
  30. package/dist/wasm/rastack_wasm_bg.wasm +0 -0
  31. package/hooks/data.ts +209 -0
  32. package/hooks/entity.ts +222 -0
  33. package/hooks/form/entity-form.ts +354 -0
  34. package/hooks/form/form.ts +8 -1
  35. package/hooks/form/index.ts +7 -1
  36. package/hooks/index.ts +4 -0
  37. package/hooks/manifest.ts +41 -0
  38. package/hooks/registry.ts +39 -0
  39. package/package.json +1 -1
  40. package/provider/provider.tsx +8 -1
  41. package/provider/types.ts +7 -2
  42. package/src/compile/analyze.ts +83 -4
  43. package/src/compile/entities.ts +111 -11
  44. package/src/compile/index.ts +108 -11
  45. package/src/compile/model.ts +40 -0
  46. package/src/compile/openapi.ts +13 -1
  47. package/src/define/index.ts +148 -20
  48. package/src/import/tabular.ts +9 -2
  49. package/src/rastack-admin.ts +105 -46
  50. package/src/rastack-import.ts +4 -1
  51. package/src/validate/adapters.ts +1 -0
  52. package/src/validate/index.ts +1 -0
  53. package/src/validate/machine.ts +55 -3
  54. package/src/validate/transitions.ts +232 -0
  55. package/test/components.spec.ts +22 -0
  56. package/test/transitions.spec.ts +372 -0
  57. package/test/typed-hooks.spec.ts +407 -0
  58. package/wasm/rastack_wasm.js +1 -1
  59. package/wasm/rastack_wasm_bg.wasm +0 -0
@@ -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: 80, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`.
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
+ }
@@ -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
+ }