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,407 @@
1
+ /**
2
+ * The pure cores behind `useData(Entity)` / `useForm(Entity)`: resolving a
3
+ * plain TypeScript type to its manifest resource, deriving the write schema
4
+ * (and its form field structures) from the manifest at runtime, and the
5
+ * type-level transition names. The React shells around these are thin; the
6
+ * decisions all live here.
7
+ */
8
+
9
+ import { mkdirSync, readFileSync, rmSync, writeFileSync, existsSync } from "fs";
10
+ import { join } from "path";
11
+ import {
12
+ entityModelName,
13
+ humanizeName,
14
+ isEntityRef,
15
+ resolveEntityRef,
16
+ } from "../hooks/entity";
17
+ import { useData } from "../hooks/data";
18
+ import type { RowForKey } from "../hooks/registry";
19
+ import { compile } from "../src/compile";
20
+ import { initialValuesFromResource } from "../hooks/form/entity-form";
21
+ import { getFieldMachine, getFieldStructure } from "../hooks/form/structure";
22
+ import { writeSchema } from "../src/compile/openapi";
23
+ import type { Manifest, ResourceModel } from "../src/compile/model";
24
+ import { resource, s, type TransitionNamesOf } from "../src/define";
25
+ import { runFieldMachine } from "../src/validate";
26
+
27
+ const airport: ResourceModel = {
28
+ app: "airports",
29
+ model: "airport",
30
+ fields: [
31
+ { name: "code", type: "string", maxLength: 3, unique: true },
32
+ { name: "name", type: "string", maxLength: 120 },
33
+ ],
34
+ relations: [],
35
+ admin: { listDisplay: ["code", "name"] },
36
+ };
37
+
38
+ const flight: ResourceModel = {
39
+ app: "airports",
40
+ model: "flight",
41
+ fields: [
42
+ { name: "number", type: "string", maxLength: 8 },
43
+ {
44
+ name: "status",
45
+ type: "string",
46
+ options: ["scheduled", "boarding", "departed"],
47
+ default: "scheduled",
48
+ },
49
+ { name: "active", type: "bool" },
50
+ { name: "airport", type: "fk", relation: { app: "airports", model: "airport" } },
51
+ ],
52
+ relations: [{ field: "airport", target: "airports.airport", kind: "fk" }],
53
+ transitions: {
54
+ field: "status",
55
+ states: ["scheduled", "boarding", "departed"],
56
+ initial: "scheduled",
57
+ on: {
58
+ board: { from: ["scheduled"], to: "boarding" },
59
+ depart: { from: ["boarding"], to: "departed" },
60
+ },
61
+ },
62
+ };
63
+
64
+ const manifest: Manifest = { version: 1, resources: [airport, flight] };
65
+
66
+ describe("resolveEntityRef — a type names its resource", () => {
67
+ it("resolves a class by its (compiler-normalised) name", () => {
68
+ class Airport {
69
+ code!: string;
70
+ }
71
+ const resolved = resolveEntityRef(Airport, manifest);
72
+ expect(resolved).toMatchObject({
73
+ app: "airports",
74
+ model: "airport",
75
+ key: "airports.airport",
76
+ listUrl: "/api/airports/v1/airport/",
77
+ });
78
+ expect(resolved.detailUrl(7)).toBe("/api/airports/v1/airport/7/");
79
+ expect(resolved.resource).toBe(airport);
80
+ });
81
+
82
+ it("strips the generated `I` prefix, like the compiler does", () => {
83
+ expect(entityModelName("IFlight")).toBe("flight");
84
+ expect(entityModelName("Flight")).toBe("flight");
85
+ expect(entityModelName("Item")).toBe("item"); // `It` is not an I-prefix… but `Item` is I+tem
86
+ });
87
+
88
+ it("resolves a `resource()` value directly — manifest optional", () => {
89
+ const Flight = resource("airports", "flight", {
90
+ number: s.string({ maxLength: 8 }),
91
+ });
92
+ expect(resolveEntityRef(Flight, manifest).resource).toBe(flight);
93
+ // Without a manifest the URLs still resolve (the DSL value carries app/model)…
94
+ const bare = resolveEntityRef(Flight);
95
+ expect(bare.listUrl).toBe("/api/airports/v1/flight/");
96
+ expect(bare.resource).toBeUndefined();
97
+ });
98
+
99
+ it("carries a resource()'s transitions even without a manifest", () => {
100
+ const Flight = resource(
101
+ "airports",
102
+ "flight",
103
+ { status: s.string({ maxLength: 20 }) },
104
+ {
105
+ transitions: {
106
+ field: "status",
107
+ states: ["scheduled", "boarding"],
108
+ on: { board: { from: "scheduled", to: "boarding" } },
109
+ },
110
+ },
111
+ );
112
+ const resolved = resolveEntityRef(Flight);
113
+ expect(resolved.transitions).toMatchObject({
114
+ on: { board: { from: ["scheduled"], to: "boarding" } },
115
+ });
116
+ });
117
+
118
+ it('resolves "app.model" and bare "model" strings', () => {
119
+ expect(resolveEntityRef("airports.flight", manifest).resource).toBe(flight);
120
+ expect(resolveEntityRef("flight", manifest).model).toBe("flight");
121
+ expect(() => resolveEntityRef("airports.spaceship", manifest)).toThrow(
122
+ /no resource "airports.spaceship"/,
123
+ );
124
+ });
125
+
126
+ it("fails loudly on ambiguity and missing manifests", () => {
127
+ const two: Manifest = {
128
+ version: 1,
129
+ resources: [flight, { ...flight, app: "cargo" }],
130
+ };
131
+ expect(() => resolveEntityRef("flight", two)).toThrow(/"app.model"/);
132
+ class Flight {
133
+ number!: string;
134
+ }
135
+ expect(() => resolveEntityRef(Flight, two)).toThrow(/airports\.flight, cargo\.flight/);
136
+ expect(() => resolveEntityRef(Flight)).toThrow(/manifest/);
137
+ });
138
+
139
+ it("classifies entity refs vs. legacy form props", () => {
140
+ expect(isEntityRef("airports.flight")).toBe(true);
141
+ expect(isEntityRef(class A {})).toBe(true);
142
+ expect(isEntityRef({ app: "a", model: "b" })).toBe(true);
143
+ expect(isEntityRef({ formValues: {}, mutations: {} })).toBe(false);
144
+ });
145
+ });
146
+
147
+ describe("useForm(Entity) — the manifest-derived form core", () => {
148
+ it("derives form-friendly initial values from the resource", () => {
149
+ expect(initialValuesFromResource(flight)).toEqual({
150
+ number: "",
151
+ status: "scheduled", // the machine's initial state, via the field default
152
+ active: false,
153
+ airport: "",
154
+ });
155
+ });
156
+
157
+ it("derives the same write schema the OpenAPI contract publishes", () => {
158
+ const schema = writeSchema(flight, false);
159
+ expect(schema.properties.status.enum).toEqual([
160
+ "scheduled",
161
+ "boarding",
162
+ "departed",
163
+ ]);
164
+ expect(schema.properties.airport["x-rastack-relation"]).toEqual({
165
+ app: "airports",
166
+ model: "airport",
167
+ });
168
+ expect(schema["x-rastack-transitions"]).toBe(flight.transitions);
169
+ });
170
+
171
+ it("renders the state field as a select and runs its member gate", () => {
172
+ const schema = writeSchema(flight, false);
173
+
174
+ const structure = getFieldStructure(schema, "status");
175
+ expect(structure.type).toBe("select");
176
+ expect(structure.options).toEqual([
177
+ { label: "scheduled", value: "scheduled" },
178
+ { label: "boarding", value: "boarding" },
179
+ { label: "departed", value: "departed" },
180
+ ]);
181
+
182
+ const machine = getFieldMachine(schema, "status")!;
183
+ expect(machine.states).toContain("member");
184
+ expect(runFieldMachine(machine, "boarding").state).toBe("valid");
185
+ expect(runFieldMachine(machine, "teleported").state).toBe("invalid");
186
+ });
187
+
188
+ it("humanises transition names into button labels", () => {
189
+ expect(humanizeName("board")).toBe("Board");
190
+ expect(humanizeName("close_out")).toBe("Close out");
191
+ expect(humanizeName("markShipped")).toBe("Mark shipped");
192
+ });
193
+ });
194
+
195
+ describe("TransitionNamesOf — transition names are literal types", () => {
196
+ it("reads names from a resource()'s options and a class's static block", () => {
197
+ const Flight = resource(
198
+ "airports",
199
+ "flight",
200
+ { status: s.string({ maxLength: 20 }) },
201
+ {
202
+ transitions: {
203
+ field: "status",
204
+ states: ["scheduled", "boarding", "departed"],
205
+ initial: "scheduled",
206
+ on: {
207
+ board: { from: "scheduled", to: "boarding" },
208
+ depart: { from: "boarding", to: "departed" },
209
+ },
210
+ },
211
+ },
212
+ );
213
+ type FlightNames = TransitionNamesOf<typeof Flight>;
214
+ const boardName: FlightNames = "board";
215
+ const departName: FlightNames = "depart";
216
+ // @ts-expect-error — "explode" is not a declared transition
217
+ const bad: FlightNames = "explode";
218
+ expect([boardName, departName, bad]).toBeDefined();
219
+
220
+ class Order {
221
+ status = "draft";
222
+ static transitions = {
223
+ field: "status",
224
+ states: ["draft", "placed"],
225
+ initial: "draft",
226
+ on: { place: { from: "draft", to: "placed" } },
227
+ };
228
+ }
229
+ type OrderNames = TransitionNamesOf<typeof Order>;
230
+ const placeName: OrderNames = "place";
231
+ expect(placeName).toBe("place");
232
+
233
+ class Plain {
234
+ name!: string;
235
+ }
236
+ type None = TransitionNamesOf<typeof Plain>; // resolves to never
237
+ const nothing: None[] = [];
238
+ expect(nothing).toEqual([]);
239
+ });
240
+ });
241
+
242
+ // ---------------------------------------------------------------------------
243
+ // Row inference — no type argument needed
244
+ // ---------------------------------------------------------------------------
245
+
246
+ // What `rastack compile` emits as rastack-env.d.ts (declaration merging into
247
+ // the registry) — hand-rolled here so the inference is exercised in-test.
248
+ declare module "../hooks/registry" {
249
+ interface EntityTypes {
250
+ "airports.terminal": { id: number; label: string; airport: number };
251
+ }
252
+ }
253
+
254
+ describe("row inference — the type argument is not needed", () => {
255
+ it("a registered \"app.model\" string resolves its row type", () => {
256
+ type TerminalRow = RowForKey<"airports.terminal">;
257
+ const row: TerminalRow = { id: 1, label: "T1", airport: 2 };
258
+ // @ts-expect-error — `label` is required on the registered row type
259
+ const missing: TerminalRow = { id: 1, airport: 2 };
260
+ expect([row, missing]).toBeDefined();
261
+ });
262
+
263
+ it("a resource() value derives its row type from its fields", () => {
264
+ const Flight = resource("airports", "flight", {
265
+ number: s.string({ maxLength: 8 }),
266
+ seats: s.int(),
267
+ gate: s.string({ maxLength: 5, null: true }),
268
+ active: s.bool(),
269
+ });
270
+ type FlightRow = import("../src/define").RowOf<typeof Flight>;
271
+ const row: FlightRow = {
272
+ id: 1,
273
+ number: "BA1",
274
+ seats: 180,
275
+ gate: null, // `null: true` → `string | null`
276
+ active: true,
277
+ };
278
+ // @ts-expect-error — `seats` is a number
279
+ const wrong: FlightRow = { ...row, seats: "lots" };
280
+ // @ts-expect-error — `number` is not nullable
281
+ const wrongNull: FlightRow = { ...row, number: null };
282
+ expect([row, wrong, wrongNull]).toBeDefined();
283
+ });
284
+
285
+ // Type-level only — never executed (useData is a React hook).
286
+ function _typeOnly() {
287
+ const terminals = useData("airports.terminal");
288
+ const rows: Array<{ id: number; label: string; airport: number }> | undefined =
289
+ terminals.data;
290
+ // @ts-expect-error — rows are Terminal-shaped, not Airport-shaped
291
+ const wrong: Array<{ code: string }> | undefined = terminals.data;
292
+ return { rows, wrong };
293
+ }
294
+
295
+ it("keeps the explicit generic and unregistered strings working", () => {
296
+ expect(typeof _typeOnly).toBe("function");
297
+ function _fallbacks() {
298
+ interface IGate {
299
+ id: number;
300
+ number: string;
301
+ }
302
+ const explicit = useData<IGate>("airports.gate"); // unregistered → explicit
303
+ const gates: IGate[] | undefined = explicit.data;
304
+ const loose = useData("airports.gate"); // unregistered, no generic → any
305
+ const anything: undefined | Array<{ whatever: boolean }> = loose.data;
306
+ return { gates, anything };
307
+ }
308
+ expect(typeof _fallbacks).toBe("function");
309
+ });
310
+ });
311
+
312
+ describe("rastack compile — the emitted rastack-env.d.ts registry", () => {
313
+ const workRoot = join(__dirname, "registry-fixtures");
314
+ const resourcesDir = join(workRoot, "resources");
315
+ const outDir = join(workRoot, ".rastack");
316
+
317
+ beforeAll(() => {
318
+ mkdirSync(resourcesDir, { recursive: true });
319
+ writeFileSync(
320
+ join(resourcesDir, "entities.ts"),
321
+ `
322
+ export class Airport {
323
+ code!: string;
324
+ name!: string;
325
+ }
326
+
327
+ // Not exported — cannot be imported by the registry, so it is skipped.
328
+ class Secret {
329
+ token!: string;
330
+ }
331
+ export const _root = [Airport, Secret];
332
+
333
+ export interface Terminal {
334
+ id: number;
335
+ label: string;
336
+ }
337
+ `,
338
+ );
339
+ writeFileSync(
340
+ join(resourcesDir, "flights.ts"),
341
+ `
342
+ import { resource, s } from "rastack/define";
343
+
344
+ export const Flight = resource("airports", "flight", {
345
+ number: s.string({ maxLength: 8 }),
346
+ });
347
+ `,
348
+ );
349
+ // The dotted string argument roots the Terminal interface — no type
350
+ // argument, no JSDoc tag.
351
+ writeFileSync(
352
+ join(workRoot, "screen.tsx"),
353
+ `
354
+ export function Screen() {
355
+ const terminals = useData("entities.terminal");
356
+ return terminals;
357
+ }
358
+ declare function useData(key: string): unknown;
359
+ `,
360
+ );
361
+ });
362
+
363
+ afterAll(() => {
364
+ rmSync(workRoot, { recursive: true, force: true });
365
+ });
366
+
367
+ it("roots interfaces from dotted string hook arguments and registers every exported entity", () => {
368
+ const result = compile(workRoot, outDir);
369
+
370
+ // The string argument rooted the interface into the manifest…
371
+ expect(
372
+ result.manifest.resources.some(
373
+ (r) => r.app === "entities" && r.model === "terminal",
374
+ ),
375
+ ).toBe(true);
376
+
377
+ // …and the registry maps keys to the caller's own types.
378
+ const envPath = join(outDir, "rastack-env.d.ts");
379
+ expect(result.files).toContain(envPath);
380
+ const dts = readFileSync(envPath, "utf8");
381
+ expect(dts).toContain('declare module "rastack/hooks/registry"');
382
+ expect(dts).toContain(
383
+ '"entities.airport": import("../resources/entities").Airport;',
384
+ );
385
+ expect(dts).toContain(
386
+ '"entities.terminal": import("../resources/entities").Terminal;',
387
+ );
388
+ // DSL consts register through RowOf so string refs infer for them too.
389
+ expect(dts).toContain(
390
+ '"airports.flight": import("rastack/define").RowOf<typeof import("../resources/flights").Flight>;',
391
+ );
392
+ // Unexported declarations are skipped.
393
+ expect(dts).not.toContain("Secret");
394
+ });
395
+
396
+ it("emits no registry when nothing is exported", () => {
397
+ const emptyDir = join(workRoot, "empty");
398
+ mkdirSync(join(emptyDir, "src"), { recursive: true });
399
+ writeFileSync(
400
+ join(emptyDir, "src", "none.ts"),
401
+ `export const nothing = 1;\n`,
402
+ );
403
+ const out = join(emptyDir, "out");
404
+ compile(emptyDir, out);
405
+ expect(existsSync(join(out, "rastack-env.d.ts"))).toBe(false);
406
+ });
407
+ });
@@ -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