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,354 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `useForm` — pass your type in, get the whole form out.
|
|
3
|
+
*
|
|
4
|
+
* ```tsx
|
|
5
|
+
* const form = useForm(Flight, { id });
|
|
6
|
+
*
|
|
7
|
+
* <AutoForm form={form} />
|
|
8
|
+
* form.transitions // [{ name: "board", to: "boarding", … }] — from the current state
|
|
9
|
+
* form.transition("board") // fire a declared edge: state change + `set` patches, then save
|
|
10
|
+
* ```
|
|
11
|
+
*
|
|
12
|
+
* The generic replacement for the generated `useUpdate{Model}Form` hooks:
|
|
13
|
+
* the entity reference resolves against the compiled manifest, the write
|
|
14
|
+
* JSON Schema is derived from the *same* `writeSchema()` the OpenAPI emitter
|
|
15
|
+
* uses (so the form validates exactly the contract the API enforces —
|
|
16
|
+
* datatypes, `maxLength`, `enum`, FK relations, the constraint state
|
|
17
|
+
* machine), the create/put/patch mutations are built from the URL convention,
|
|
18
|
+
* and — when the resource declares a `transitions` block — the form exposes
|
|
19
|
+
* the state machine as typed actions.
|
|
20
|
+
*
|
|
21
|
+
* `form.transition(name)` is the client half of the declarative backend
|
|
22
|
+
* function: it validates the edge against the record's current state, stages
|
|
23
|
+
* the state change plus the edge's `set` patches on top of the form values,
|
|
24
|
+
* and submits. The server (`rastack-api-core` — HTTP, Lambda, and in-browser
|
|
25
|
+
* WASM alike) re-derives and re-enforces the same edge from the same
|
|
26
|
+
* manifest, so an illegal jump is rejected even if a client bypasses the
|
|
27
|
+
* form. Transition *names* are typed: `useForm(Flight).transition(…)` only
|
|
28
|
+
* accepts the names `Flight` declares.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { useMemo } from "react";
|
|
32
|
+
import { useSchemaForm } from "./form";
|
|
33
|
+
import type {
|
|
34
|
+
FormProps,
|
|
35
|
+
FormStructure,
|
|
36
|
+
FormUpdate,
|
|
37
|
+
FormValuesType,
|
|
38
|
+
} from "./interfaces";
|
|
39
|
+
import { useGetQuery } from "../query/fetch";
|
|
40
|
+
import { useUpdateQuery } from "../query/update";
|
|
41
|
+
import type { IGetQueryResult } from "../query/interfaces";
|
|
42
|
+
import {
|
|
43
|
+
EntityClass,
|
|
44
|
+
EntityRef,
|
|
45
|
+
EntityRow,
|
|
46
|
+
humanizeName,
|
|
47
|
+
isEntityRef,
|
|
48
|
+
ResolvedEntity,
|
|
49
|
+
resolveEntityRef,
|
|
50
|
+
} from "../entity";
|
|
51
|
+
import { useRastackManifest } from "../manifest";
|
|
52
|
+
import type { EntityTypes, RegisteredEntityKey } from "../registry";
|
|
53
|
+
import { writeSchema } from "../../src/compile/openapi";
|
|
54
|
+
import type {
|
|
55
|
+
FieldModel,
|
|
56
|
+
Manifest,
|
|
57
|
+
ResourceModel,
|
|
58
|
+
} from "../../src/compile/model";
|
|
59
|
+
import {
|
|
60
|
+
allowedTransitions,
|
|
61
|
+
applyTransition,
|
|
62
|
+
currentState,
|
|
63
|
+
} from "../../src/validate/transitions";
|
|
64
|
+
import type { TransitionNamesOf } from "../../src/define";
|
|
65
|
+
|
|
66
|
+
export interface UseEntityFormOptions<TRow> {
|
|
67
|
+
/** Editing an existing record: its id. Absent = a create form. */
|
|
68
|
+
id?: string | number;
|
|
69
|
+
/** Initial/override values, merged over defaults and the fetched record. */
|
|
70
|
+
formValues?: Partial<TRow>;
|
|
71
|
+
/** `"put"` to save with PUT; default is POST on create / PATCH on update. */
|
|
72
|
+
updateType?: FormUpdate;
|
|
73
|
+
onSuccess?: (row: TRow) => void;
|
|
74
|
+
/** Manifest override — tests, or callers outside `<RAStackProvider>`. */
|
|
75
|
+
manifest?: Manifest;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** One transition available from the record's current state. */
|
|
79
|
+
export interface FormTransition {
|
|
80
|
+
name: string;
|
|
81
|
+
/** Humanised name — a ready-made button label. */
|
|
82
|
+
label: string;
|
|
83
|
+
/** The state the transition enters. */
|
|
84
|
+
to: string;
|
|
85
|
+
/** The edge's `set` patches (applied alongside the state change). */
|
|
86
|
+
set?: Record<string, unknown>;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface EntityFormResult<TRow, TName extends string = string>
|
|
90
|
+
extends FormStructure<Partial<TRow>, Record<string, unknown>> {
|
|
91
|
+
/** The resolved entity — urls, manifest resource, state machine. */
|
|
92
|
+
entity: ResolvedEntity;
|
|
93
|
+
/** The persisted record being edited (id mode), once fetched. */
|
|
94
|
+
record: TRow | undefined;
|
|
95
|
+
isRecordLoading: boolean;
|
|
96
|
+
/** The record's current machine state (resources with `transitions`). */
|
|
97
|
+
state: string | undefined;
|
|
98
|
+
/** The transitions available from the current state — render as actions. */
|
|
99
|
+
transitions: FormTransition[];
|
|
100
|
+
/** Whether a named transition may fire from the current state. */
|
|
101
|
+
canTransition: (name: TName) => boolean;
|
|
102
|
+
/**
|
|
103
|
+
* Fire a declared transition: guard the edge, stage `{ [field]: to, ...set }`
|
|
104
|
+
* over the current form values, and save. Throws on an edge the state
|
|
105
|
+
* machine does not allow — the same rule the engine enforces on write.
|
|
106
|
+
*/
|
|
107
|
+
transition: (name: TName) => void;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Form-friendly initial values straight from the manifest resource. */
|
|
111
|
+
export function initialValuesFromResource(
|
|
112
|
+
resource: ResourceModel,
|
|
113
|
+
): Record<string, unknown> {
|
|
114
|
+
const values: Record<string, unknown> = {};
|
|
115
|
+
for (const field of resource.fields) {
|
|
116
|
+
if (field.primaryKey) continue;
|
|
117
|
+
values[field.name] = initialFieldValue(field);
|
|
118
|
+
}
|
|
119
|
+
return values;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function initialFieldValue(field: FieldModel): unknown {
|
|
123
|
+
if (field.default !== undefined) return field.default;
|
|
124
|
+
if (field.type === "bool") return false;
|
|
125
|
+
return "";
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** The writable (non-PK) slice of a fetched record, for form seeding. */
|
|
129
|
+
function writableValues(
|
|
130
|
+
resource: ResourceModel,
|
|
131
|
+
record: Record<string, unknown> | undefined,
|
|
132
|
+
): Record<string, unknown> {
|
|
133
|
+
if (!record) return {};
|
|
134
|
+
const values: Record<string, unknown> = {};
|
|
135
|
+
for (const field of resource.fields) {
|
|
136
|
+
if (field.primaryKey) continue;
|
|
137
|
+
if (record[field.name] !== undefined) values[field.name] = record[field.name];
|
|
138
|
+
}
|
|
139
|
+
return values;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function useEntityForm<TRow = any, TName extends string = string>(
|
|
143
|
+
entity: EntityRef<TRow>,
|
|
144
|
+
options: UseEntityFormOptions<TRow> = {},
|
|
145
|
+
): EntityFormResult<TRow, TName> {
|
|
146
|
+
const contextManifest = useRastackManifest();
|
|
147
|
+
const manifest = options.manifest ?? contextManifest;
|
|
148
|
+
const resolvedEntity = useMemo(
|
|
149
|
+
() => resolveEntityRef(entity, manifest),
|
|
150
|
+
[entity, manifest],
|
|
151
|
+
);
|
|
152
|
+
const resource = resolvedEntity.resource;
|
|
153
|
+
if (!resource) {
|
|
154
|
+
throw new Error(
|
|
155
|
+
`useForm: the ${resolvedEntity.key} resource is not in the manifest — pass \`manifest\` to <RAStackProvider> (or in the hook's options)`,
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const id = options.id !== undefined && options.id !== null ? String(options.id) : undefined;
|
|
160
|
+
const listQueryCacheKey = `${resolvedEntity.key}-list`;
|
|
161
|
+
const itemQueryCacheKey = `${resolvedEntity.key}-detail`;
|
|
162
|
+
|
|
163
|
+
// The persisted record (id mode) — seeds the form and anchors the state
|
|
164
|
+
// machine's "from" state.
|
|
165
|
+
const recordQuery: IGetQueryResult<any> = useGetQuery<
|
|
166
|
+
Record<string, unknown>,
|
|
167
|
+
any
|
|
168
|
+
>({
|
|
169
|
+
getUrl: resolvedEntity.detailUrl(id ?? ""),
|
|
170
|
+
queryKey: itemQueryCacheKey,
|
|
171
|
+
pathParameters: [],
|
|
172
|
+
requiredParams: [],
|
|
173
|
+
isAuthenticated: id !== undefined,
|
|
174
|
+
options: { params: {} },
|
|
175
|
+
});
|
|
176
|
+
const record = recordQuery.data as Record<string, unknown> | undefined;
|
|
177
|
+
|
|
178
|
+
// The same write schema the OpenAPI emitter publishes — derived from the
|
|
179
|
+
// manifest at runtime instead of imported from generated code.
|
|
180
|
+
const createSchema = useMemo(() => writeSchema(resource, false), [resource]);
|
|
181
|
+
const patchSchema = useMemo(() => writeSchema(resource, true), [resource]);
|
|
182
|
+
|
|
183
|
+
const createMutation = useUpdateQuery<any, any, any>({
|
|
184
|
+
postUrl: resolvedEntity.listUrl,
|
|
185
|
+
listQueryCacheKey,
|
|
186
|
+
});
|
|
187
|
+
const putMutation = useUpdateQuery<any, any, any>({
|
|
188
|
+
putUrl: (params: any) => resolvedEntity.detailUrl(params.id),
|
|
189
|
+
listQueryCacheKey,
|
|
190
|
+
itemQueryCacheKey,
|
|
191
|
+
});
|
|
192
|
+
const patchMutation = useUpdateQuery<any, any, any>({
|
|
193
|
+
patchUrl: (params: any) => resolvedEntity.detailUrl(params.id),
|
|
194
|
+
listQueryCacheKey,
|
|
195
|
+
itemQueryCacheKey,
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
const formValues = useMemo(
|
|
199
|
+
() => ({
|
|
200
|
+
...initialValuesFromResource(resource),
|
|
201
|
+
...writableValues(resource, record),
|
|
202
|
+
...options.formValues,
|
|
203
|
+
}),
|
|
204
|
+
[resource, record, options.formValues],
|
|
205
|
+
);
|
|
206
|
+
|
|
207
|
+
const form = useSchemaForm({
|
|
208
|
+
id,
|
|
209
|
+
formValues: formValues as any,
|
|
210
|
+
updateType: options.updateType as any,
|
|
211
|
+
mutations: {
|
|
212
|
+
post: { mutation: createMutation, validation: createSchema },
|
|
213
|
+
put: { mutation: putMutation, validation: createSchema },
|
|
214
|
+
patch: { mutation: patchMutation, validation: patchSchema },
|
|
215
|
+
},
|
|
216
|
+
onSuccess: options.onSuccess as any,
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
// -- the state machine, as form actions ------------------------------------
|
|
220
|
+
const spec = resolvedEntity.transitions;
|
|
221
|
+
const state = spec
|
|
222
|
+
? currentState(spec, record ?? (formValues as Record<string, unknown>))
|
|
223
|
+
: undefined;
|
|
224
|
+
const transitions: FormTransition[] = useMemo(
|
|
225
|
+
() =>
|
|
226
|
+
spec
|
|
227
|
+
? allowedTransitions(spec, state).map((t) => ({
|
|
228
|
+
name: t.name,
|
|
229
|
+
label: humanizeName(t.name),
|
|
230
|
+
to: t.to,
|
|
231
|
+
set: t.set,
|
|
232
|
+
}))
|
|
233
|
+
: [],
|
|
234
|
+
[spec, state],
|
|
235
|
+
);
|
|
236
|
+
|
|
237
|
+
const canTransition = (name: TName): boolean =>
|
|
238
|
+
transitions.some((t) => t.name === name);
|
|
239
|
+
|
|
240
|
+
const transition = (name: TName): void => {
|
|
241
|
+
if (!spec) {
|
|
242
|
+
throw new Error(`useForm: ${resolvedEntity.key} declares no transitions`);
|
|
243
|
+
}
|
|
244
|
+
if (id === undefined) {
|
|
245
|
+
throw new Error(
|
|
246
|
+
`useForm: transitions fire on a persisted record — pass \`id\``,
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
const applied = applyTransition(spec, name, record ?? formValues);
|
|
250
|
+
if (!applied.ok) {
|
|
251
|
+
throw new Error(`useForm: ${applied.message}`);
|
|
252
|
+
}
|
|
253
|
+
// The transition *is* the save: current form values + the edge's patch,
|
|
254
|
+
// written through the normal update mutation. The engine re-checks the
|
|
255
|
+
// edge and re-applies `set` server-side — the client stages it so the
|
|
256
|
+
// optimistic UI and the persisted row agree.
|
|
257
|
+
const payload = {
|
|
258
|
+
...(form.responseValues as Record<string, unknown>),
|
|
259
|
+
...applied.patch,
|
|
260
|
+
};
|
|
261
|
+
const mutation = options.updateType === "put" ? putMutation : patchMutation;
|
|
262
|
+
mutation.mutate(
|
|
263
|
+
{ params: { id }, payload },
|
|
264
|
+
options.onSuccess
|
|
265
|
+
? { onSuccess: (row: any) => options.onSuccess!(row) }
|
|
266
|
+
: undefined,
|
|
267
|
+
);
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
return {
|
|
271
|
+
...(form as FormStructure<Partial<TRow>, Record<string, unknown>>),
|
|
272
|
+
entity: resolvedEntity,
|
|
273
|
+
record: record as TRow | undefined,
|
|
274
|
+
isRecordLoading: id !== undefined && recordQuery.isLoading === true,
|
|
275
|
+
state,
|
|
276
|
+
transitions,
|
|
277
|
+
canTransition,
|
|
278
|
+
transition,
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// ---------------------------------------------------------------------------
|
|
283
|
+
// The public `useForm` — entity-first, legacy-props compatible
|
|
284
|
+
// ---------------------------------------------------------------------------
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Pass your type to `useForm` (class, `resource()` value, or `"app.model"`),
|
|
288
|
+
* or the legacy explicit-props object (what generated form hooks call) — the
|
|
289
|
+
* argument shape picks the implementation. A call site's shape never changes
|
|
290
|
+
* between renders, so the dispatch is hook-order safe.
|
|
291
|
+
*/
|
|
292
|
+
// A registered "app.model" string infers its rows through the EntityTypes
|
|
293
|
+
// registry (`rastack compile` emits rastack-env.d.ts) — no type argument.
|
|
294
|
+
export function useForm<K extends RegisteredEntityKey>(
|
|
295
|
+
entity: K,
|
|
296
|
+
options?: UseEntityFormOptions<EntityTypes[K]>,
|
|
297
|
+
): EntityFormResult<EntityTypes[K], string>;
|
|
298
|
+
export function useForm<E extends EntityClass<any> | { app: string; model: string }>(
|
|
299
|
+
entity: E,
|
|
300
|
+
options?: UseEntityFormOptions<EntityRow<E>>,
|
|
301
|
+
): EntityFormResult<EntityRow<E>, TransitionNamesOf<E>>;
|
|
302
|
+
export function useForm<TRow = any>(
|
|
303
|
+
entity: string,
|
|
304
|
+
options?: UseEntityFormOptions<TRow>,
|
|
305
|
+
): EntityFormResult<TRow, string>;
|
|
306
|
+
// The legacy explicit-props signature — what the generated per-entity form
|
|
307
|
+
// hooks call (with explicit type arguments), kept verbatim for compatibility.
|
|
308
|
+
export function useForm<
|
|
309
|
+
TFormValues extends object,
|
|
310
|
+
TPostParams,
|
|
311
|
+
TPutParams,
|
|
312
|
+
TPatchParams,
|
|
313
|
+
TPostPayload,
|
|
314
|
+
TPutPayload,
|
|
315
|
+
TPatchPayload,
|
|
316
|
+
TPostResult,
|
|
317
|
+
TPutResult,
|
|
318
|
+
TPatchResult,
|
|
319
|
+
TFormUpdate extends FormUpdate,
|
|
320
|
+
TSourceId extends string | undefined,
|
|
321
|
+
>(
|
|
322
|
+
props: FormProps<
|
|
323
|
+
TFormValues,
|
|
324
|
+
TPostParams,
|
|
325
|
+
TPutParams,
|
|
326
|
+
TPatchParams,
|
|
327
|
+
TPostPayload,
|
|
328
|
+
TPutPayload,
|
|
329
|
+
TPatchPayload,
|
|
330
|
+
TPostResult,
|
|
331
|
+
TPutResult,
|
|
332
|
+
TPatchResult,
|
|
333
|
+
TFormUpdate,
|
|
334
|
+
TSourceId
|
|
335
|
+
>,
|
|
336
|
+
): FormStructure<
|
|
337
|
+
TFormValues,
|
|
338
|
+
FormValuesType<
|
|
339
|
+
TFormUpdate,
|
|
340
|
+
TSourceId,
|
|
341
|
+
TPutParams,
|
|
342
|
+
TPutPayload,
|
|
343
|
+
TPostParams,
|
|
344
|
+
TPostPayload,
|
|
345
|
+
TPatchParams,
|
|
346
|
+
TPatchPayload
|
|
347
|
+
>
|
|
348
|
+
>;
|
|
349
|
+
export function useForm(entityOrProps: any, options?: any): any {
|
|
350
|
+
if (isEntityRef(entityOrProps)) {
|
|
351
|
+
return useEntityForm(entityOrProps, options);
|
|
352
|
+
}
|
|
353
|
+
return useSchemaForm(entityOrProps);
|
|
354
|
+
}
|
package/hooks/form/form.ts
CHANGED
|
@@ -11,7 +11,14 @@ import { formikJSONSchemaValidation } from "./validate-schema";
|
|
|
11
11
|
import { getFieldMachine, getFieldStructure } from "./structure";
|
|
12
12
|
import { describeMachine, runFieldMachine } from "../../src/validate";
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
/**
|
|
15
|
+
* The schema-driven form core: Formik + AJV over an explicit `mutations`
|
|
16
|
+
* bundle (one mutation + write JSON Schema per HTTP method). The generic
|
|
17
|
+
* `useForm(Entity)` (see `./entity-form.ts`) derives that bundle from the
|
|
18
|
+
* manifest and delegates here, and the generated per-entity form hooks call
|
|
19
|
+
* it with compile-time `Deref*` schemas — one form brain either way.
|
|
20
|
+
*/
|
|
21
|
+
export function useSchemaForm<
|
|
15
22
|
TFormValues extends object,
|
|
16
23
|
TPostParams,
|
|
17
24
|
TPutParams,
|
package/hooks/form/index.ts
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
|
-
export {
|
|
1
|
+
export { useSchemaForm } from "./form";
|
|
2
|
+
export { useForm, useEntityForm, initialValuesFromResource } from "./entity-form";
|
|
3
|
+
export type {
|
|
4
|
+
UseEntityFormOptions,
|
|
5
|
+
EntityFormResult,
|
|
6
|
+
FormTransition,
|
|
7
|
+
} from "./entity-form";
|
|
2
8
|
export type {
|
|
3
9
|
FormStructure,
|
|
4
10
|
FormUpdateMethod,
|
package/hooks/index.ts
CHANGED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The compiled manifest (`schema.rastack.json`) as React context — what lets
|
|
3
|
+
* `useData(Airport)` / `useForm(Flight)` resolve a plain TypeScript type to
|
|
4
|
+
* its resource at runtime with no generated code in between.
|
|
5
|
+
*
|
|
6
|
+
* `<RAStackProvider manifest={…}>` supplies it (in every mode — the manifest
|
|
7
|
+
* is required for the WASM engine anyway, and in `remote` mode it is the only
|
|
8
|
+
* piece of compiler output the client needs). Hooks read it with
|
|
9
|
+
* {@link useRastackManifest}; tests and one-off callers can bypass the context
|
|
10
|
+
* by passing `manifest` directly in a hook's options.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { createContext, createElement, useContext, useMemo } from "react";
|
|
14
|
+
import type { ReactNode } from "react";
|
|
15
|
+
import type { Manifest } from "../src/compile/model";
|
|
16
|
+
|
|
17
|
+
export const RastackManifestContext = createContext<Manifest | undefined>(
|
|
18
|
+
undefined,
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
export interface RastackManifestProviderProps {
|
|
22
|
+
/** `schema.rastack.json` — parsed object or JSON string. */
|
|
23
|
+
manifest?: Manifest | object | string;
|
|
24
|
+
children?: ReactNode;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Provide the compiled manifest to every `useData`/`useForm` beneath it. */
|
|
28
|
+
export function RastackManifestProvider(props: RastackManifestProviderProps) {
|
|
29
|
+
const { manifest, children } = props;
|
|
30
|
+
const value = useMemo<Manifest | undefined>(() => {
|
|
31
|
+
if (manifest === undefined || manifest === null) return undefined;
|
|
32
|
+
if (typeof manifest === "string") return JSON.parse(manifest) as Manifest;
|
|
33
|
+
return manifest as Manifest;
|
|
34
|
+
}, [manifest]);
|
|
35
|
+
return createElement(RastackManifestContext.Provider, { value }, children);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The compiled manifest, when a provider supplies one. */
|
|
39
|
+
export function useRastackManifest(): Manifest | undefined {
|
|
40
|
+
return useContext(RastackManifestContext);
|
|
41
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The type-level entity registry — what lets a bare string infer its rows:
|
|
3
|
+
*
|
|
4
|
+
* ```tsx
|
|
5
|
+
* const terminals = useData("airports.terminal"); // rows: Terminal[]
|
|
6
|
+
* ```
|
|
7
|
+
*
|
|
8
|
+
* TypeScript can't conjure an interface out of a string on its own, so the
|
|
9
|
+
* registry maps manifest keys (`"app.model"`) to your own types via ordinary
|
|
10
|
+
* declaration merging. **You don't write it by hand**: `rastack compile`
|
|
11
|
+
* emits `rastack-env.d.ts` alongside the manifest —
|
|
12
|
+
*
|
|
13
|
+
* ```ts
|
|
14
|
+
* declare module "rastack/hooks/registry" {
|
|
15
|
+
* interface EntityTypes {
|
|
16
|
+
* "airports.terminal": import("../resources/airports").Terminal;
|
|
17
|
+
* }
|
|
18
|
+
* }
|
|
19
|
+
* ```
|
|
20
|
+
*
|
|
21
|
+
* — one entry per class/interface entity in the compiled graph, pointing at
|
|
22
|
+
* *your* declaration (types only, no runtime code, nothing to import). With
|
|
23
|
+
* the file in the program, `useData` / `useRecord` / `useForm` overloads
|
|
24
|
+
* resolve `"app.model"` arguments through {@link EntityTypes}; unregistered
|
|
25
|
+
* strings fall back to `any` (or an explicit `useData<ITerminal>(…)`
|
|
26
|
+
* argument, which always still works).
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/** `"app.model"` → row type. Populated by declaration merging — see above. */
|
|
30
|
+
// eslint-disable-next-line @typescript-eslint/no-empty-interface
|
|
31
|
+
export interface EntityTypes {}
|
|
32
|
+
|
|
33
|
+
/** Every registered manifest key. `never` until the registry is augmented. */
|
|
34
|
+
export type RegisteredEntityKey = Extract<keyof EntityTypes, string>;
|
|
35
|
+
|
|
36
|
+
/** The row type behind a manifest key, else `any` for unregistered strings. */
|
|
37
|
+
export type RowForKey<K extends string> = K extends keyof EntityTypes
|
|
38
|
+
? EntityTypes[K]
|
|
39
|
+
: any;
|
package/package.json
CHANGED
package/provider/provider.tsx
CHANGED
|
@@ -8,6 +8,7 @@ import React, {
|
|
|
8
8
|
useState,
|
|
9
9
|
} from "react";
|
|
10
10
|
import { API, configureApi, resetApi } from "../hooks/query/api";
|
|
11
|
+
import { RastackManifestProvider } from "../hooks/manifest";
|
|
11
12
|
import {
|
|
12
13
|
RastackClient,
|
|
13
14
|
RastackEngine,
|
|
@@ -227,7 +228,13 @@ export function RAStackProvider({
|
|
|
227
228
|
[config.mode, status, config.identity],
|
|
228
229
|
);
|
|
229
230
|
|
|
230
|
-
return
|
|
231
|
+
return (
|
|
232
|
+
<RastackContext.Provider value={client}>
|
|
233
|
+
<RastackManifestProvider manifest={config.manifest}>
|
|
234
|
+
{children}
|
|
235
|
+
</RastackManifestProvider>
|
|
236
|
+
</RastackContext.Provider>
|
|
237
|
+
);
|
|
231
238
|
}
|
|
232
239
|
|
|
233
240
|
function pickPersistence(
|
package/provider/types.ts
CHANGED
|
@@ -96,8 +96,13 @@ export interface RAStackProviderConfig {
|
|
|
96
96
|
*/
|
|
97
97
|
maxOfflineMs?: number;
|
|
98
98
|
|
|
99
|
-
// --
|
|
100
|
-
/**
|
|
99
|
+
// -- all modes --
|
|
100
|
+
/**
|
|
101
|
+
* `schema.rastack.json` — object or JSON string. Required for WASM modes
|
|
102
|
+
* (it drives the in-browser engine), and recommended in `remote` mode too:
|
|
103
|
+
* it is what lets the generic `useData(Entity)` / `useForm(Entity)` hooks
|
|
104
|
+
* resolve a plain TypeScript type to its resource at runtime.
|
|
105
|
+
*/
|
|
101
106
|
manifest?: object | string;
|
|
102
107
|
/** `openapi.json` — object or JSON string. Optional (`/api/schema/`). */
|
|
103
108
|
openapi?: object | string;
|
package/src/compile/analyze.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import * as path from "path";
|
|
1
2
|
import * as ts from "typescript";
|
|
2
3
|
import {
|
|
3
4
|
AccessModel,
|
|
@@ -9,6 +10,8 @@ import {
|
|
|
9
10
|
SyncModel,
|
|
10
11
|
} from "./model";
|
|
11
12
|
import { resourceSourceFiles } from "./program";
|
|
13
|
+
import { normalizeTransitions } from "../validate/transitions";
|
|
14
|
+
import type { EntityTypeExport } from "./entities";
|
|
12
15
|
|
|
13
16
|
/**
|
|
14
17
|
* Statically analyse resource definitions and produce the canonical model.
|
|
@@ -23,14 +26,34 @@ export function analyze(
|
|
|
23
26
|
program: ts.Program,
|
|
24
27
|
fileNames: string[],
|
|
25
28
|
): ResourceModel[] {
|
|
29
|
+
return analyzeDsl(program, fileNames).resources;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Analyse the DSL surface, also collecting where each `resource()` const
|
|
34
|
+
* lives — `export const Flight = resource(…)` registers in the emitted
|
|
35
|
+
* `rastack-env.d.ts` as `RowOf<typeof Flight>`, so string references
|
|
36
|
+
* (`useData("airports.flight")`) infer rows for DSL resources too.
|
|
37
|
+
*/
|
|
38
|
+
export function analyzeDsl(
|
|
39
|
+
program: ts.Program,
|
|
40
|
+
fileNames: string[],
|
|
41
|
+
): { resources: ResourceModel[]; types: EntityTypeExport[] } {
|
|
26
42
|
const checker = program.getTypeChecker();
|
|
27
43
|
const resources: ResourceModel[] = [];
|
|
44
|
+
const types: EntityTypeExport[] = [];
|
|
28
45
|
|
|
29
46
|
for (const sourceFile of resourceSourceFiles(program, fileNames)) {
|
|
30
47
|
ts.forEachChild(sourceFile, function walk(node) {
|
|
31
48
|
if (isResourceCall(node)) {
|
|
32
49
|
const model = analyzeResourceCall(node, checker);
|
|
33
|
-
if (model)
|
|
50
|
+
if (model) {
|
|
51
|
+
resources.push(model);
|
|
52
|
+
const constExport = resourceConstExport(node);
|
|
53
|
+
if (constExport) {
|
|
54
|
+
types.push({ key: `${model.app}.${model.model}`, ...constExport });
|
|
55
|
+
}
|
|
56
|
+
}
|
|
34
57
|
}
|
|
35
58
|
ts.forEachChild(node, walk);
|
|
36
59
|
});
|
|
@@ -40,7 +63,31 @@ export function analyze(
|
|
|
40
63
|
resources.sort((a, b) =>
|
|
41
64
|
a.app === b.app ? a.model.localeCompare(b.model) : a.app.localeCompare(b.app),
|
|
42
65
|
);
|
|
43
|
-
|
|
66
|
+
types.sort((a, b) => a.key.localeCompare(b.key));
|
|
67
|
+
return { resources, types };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The `const X = resource(…)` binding a resource call is assigned to. */
|
|
71
|
+
function resourceConstExport(
|
|
72
|
+
call: ts.CallExpression,
|
|
73
|
+
): Omit<EntityTypeExport, "key"> | undefined {
|
|
74
|
+
const decl = call.parent;
|
|
75
|
+
if (!ts.isVariableDeclaration(decl) || !ts.isIdentifier(decl.name)) {
|
|
76
|
+
return undefined;
|
|
77
|
+
}
|
|
78
|
+
const statement = decl.parent?.parent;
|
|
79
|
+
const exported =
|
|
80
|
+
!!statement &&
|
|
81
|
+
ts.isVariableStatement(statement) &&
|
|
82
|
+
!!ts.getModifiers(statement)?.some(
|
|
83
|
+
(m) => m.kind === ts.SyntaxKind.ExportKeyword,
|
|
84
|
+
);
|
|
85
|
+
return {
|
|
86
|
+
typeName: decl.name.text,
|
|
87
|
+
fileName: path.resolve(call.getSourceFile().fileName),
|
|
88
|
+
exported,
|
|
89
|
+
kind: "resourceConst",
|
|
90
|
+
};
|
|
44
91
|
}
|
|
45
92
|
|
|
46
93
|
function isResourceCall(node: ts.Node): node is ts.CallExpression {
|
|
@@ -84,7 +131,7 @@ function analyzeResourceCall(
|
|
|
84
131
|
}
|
|
85
132
|
|
|
86
133
|
const options = literalToValue(call.arguments[3]) ?? {};
|
|
87
|
-
|
|
134
|
+
const resource: ResourceModel = {
|
|
88
135
|
app,
|
|
89
136
|
model,
|
|
90
137
|
fields,
|
|
@@ -96,6 +143,28 @@ function analyzeResourceCall(
|
|
|
96
143
|
permission: options.permission,
|
|
97
144
|
access: normaliseAccess(options.access),
|
|
98
145
|
};
|
|
146
|
+
applyTransitions(resource, options.transitions);
|
|
147
|
+
return resource;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Attach a normalised `transitions` block to a resource and derive what the
|
|
152
|
+
* machine implies about its state field: the `states` become the field's
|
|
153
|
+
* allowed-value set (`enum` in OpenAPI, the `member` gate in validation), and
|
|
154
|
+
* `initial` becomes its default when the field doesn't declare one.
|
|
155
|
+
*/
|
|
156
|
+
export function applyTransitions(resource: ResourceModel, raw: unknown): void {
|
|
157
|
+
const transitions = normalizeTransitions(raw);
|
|
158
|
+
if (!transitions) return;
|
|
159
|
+
resource.transitions = transitions;
|
|
160
|
+
|
|
161
|
+
const stateField = resource.fields.find((f) => f.name === transitions.field);
|
|
162
|
+
if (stateField && stateField.type !== "fk") {
|
|
163
|
+
stateField.options = [...transitions.states];
|
|
164
|
+
if (stateField.default === undefined && transitions.initial !== undefined) {
|
|
165
|
+
stateField.default = transitions.initial;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
99
168
|
}
|
|
100
169
|
|
|
101
170
|
/**
|
|
@@ -171,9 +240,19 @@ function propertyName(name: ts.PropertyName): string | undefined {
|
|
|
171
240
|
* Evaluate a *static literal* AST node (string/number/bool/null/array/object)
|
|
172
241
|
* into a JS value. Anything non-literal (a function, an identifier) yields
|
|
173
242
|
* `undefined` — the options and constraint blocks are always literals.
|
|
243
|
+
* Exported for the class-entity analyser, which reads `static transitions`
|
|
244
|
+
* blocks with the same evaluator.
|
|
174
245
|
*/
|
|
175
|
-
function literalToValue(node: ts.Expression | undefined): any {
|
|
246
|
+
export function literalToValue(node: ts.Expression | undefined): any {
|
|
176
247
|
if (!node) return undefined;
|
|
248
|
+
// Look through `as const` / `satisfies` / parens — the value is unchanged.
|
|
249
|
+
if (
|
|
250
|
+
ts.isAsExpression(node) ||
|
|
251
|
+
ts.isSatisfiesExpression(node) ||
|
|
252
|
+
ts.isParenthesizedExpression(node)
|
|
253
|
+
) {
|
|
254
|
+
return literalToValue(node.expression);
|
|
255
|
+
}
|
|
177
256
|
if (ts.isStringLiteralLike(node)) return node.text;
|
|
178
257
|
if (ts.isNumericLiteral(node)) return Number(node.text);
|
|
179
258
|
if (node.kind === ts.SyntaxKind.TrueKeyword) return true;
|