rastack 0.0.50 → 0.0.51

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,309 @@
1
+ /**
2
+ * The runtime manifest builder — `resource()` definitions are their own
3
+ * manifest, no `rastack compile` step required.
4
+ *
5
+ * The headline test is **parity**: the same definitions, authored once as
6
+ * source (compiled by the real compiler) and once as runtime values
7
+ * (assembled by `manifestFromResources`), must produce the identical
8
+ * manifest. That equivalence is what makes the compile step optional for the
9
+ * DSL path. Around it: the provider-input normalisation (`toManifest`),
10
+ * manifest-free entity resolution in the hooks, and the hand-written
11
+ * `EntityTypesOf` registry types.
12
+ */
13
+
14
+ import { mkdirSync, rmSync, writeFileSync } from "fs";
15
+ import { join } from "path";
16
+ import { compile } from "../src/compile";
17
+ import {
18
+ isResourceValue,
19
+ manifestFromResources,
20
+ resource,
21
+ resourceModel,
22
+ s,
23
+ toManifest,
24
+ type EntityKeyOf,
25
+ type EntityTypesOf,
26
+ } from "../src/define";
27
+ import { resolveEntityRef } from "../hooks/entity";
28
+ import { initialValuesFromResource } from "../hooks/form/entity-form";
29
+ import { writeSchema } from "../src/compile/openapi";
30
+ import type { Manifest } from "../src/compile/model";
31
+
32
+ // ---------------------------------------------------------------------------
33
+ // The same definitions twice — as source for the compiler, and as runtime
34
+ // values for manifestFromResources. Keep the two in sync: the parity test
35
+ // exists to prove the two paths agree for identical input.
36
+ // ---------------------------------------------------------------------------
37
+
38
+ const FIXTURE_SOURCE = `
39
+ import { resource, s } from "rastack/define";
40
+
41
+ export const Airport = resource(
42
+ "airports",
43
+ "airport",
44
+ {
45
+ code: s.string({ maxLength: 3, unique: true }),
46
+ name: s.string({ maxLength: 120 }),
47
+ open: s.bool({ default: true }),
48
+ elevation: s.int({ null: true }),
49
+ },
50
+ {
51
+ search: ["code", "name"],
52
+ ordering: ["code"],
53
+ admin: { listDisplay: ["code", "name"] },
54
+ permission: "authenticatedOrReadOnly",
55
+ },
56
+ );
57
+
58
+ export const Terminal = resource("airports", "terminal", {
59
+ airport: s.ref(() => Airport),
60
+ label: s.string({ maxLength: 20 }),
61
+ });
62
+
63
+ export const Flight = resource(
64
+ "airports",
65
+ "flight",
66
+ {
67
+ number: s.string({ maxLength: 8 }),
68
+ status: s.string({ maxLength: 20 }),
69
+ owner_id: s.int(),
70
+ },
71
+ {
72
+ search: ["number"],
73
+ sync: { mode: "local", scope: "owner_id={user.id}", confirmed: ["update"] },
74
+ access: { ownerField: "owner_id", scope: "owner", adminGroups: ["staff"] },
75
+ transitions: {
76
+ field: "status",
77
+ states: ["scheduled", "boarding", "departed", "cancelled"],
78
+ initial: "scheduled",
79
+ on: {
80
+ board: { from: "scheduled", to: "boarding" },
81
+ depart: { from: "boarding", to: "departed" },
82
+ cancel: { from: ["scheduled", "boarding"], to: "cancelled" },
83
+ },
84
+ },
85
+ },
86
+ );
87
+ `;
88
+
89
+ const Airport = resource(
90
+ "airports",
91
+ "airport",
92
+ {
93
+ code: s.string({ maxLength: 3, unique: true }),
94
+ name: s.string({ maxLength: 120 }),
95
+ open: s.bool({ default: true }),
96
+ elevation: s.int({ null: true }),
97
+ },
98
+ {
99
+ search: ["code", "name"],
100
+ ordering: ["code"],
101
+ admin: { listDisplay: ["code", "name"] },
102
+ permission: "authenticatedOrReadOnly",
103
+ },
104
+ );
105
+
106
+ const Terminal = resource("airports", "terminal", {
107
+ airport: s.ref(() => Airport),
108
+ label: s.string({ maxLength: 20 }),
109
+ });
110
+
111
+ const Flight = resource(
112
+ "airports",
113
+ "flight",
114
+ {
115
+ number: s.string({ maxLength: 8 }),
116
+ status: s.string({ maxLength: 20 }),
117
+ owner_id: s.int(),
118
+ },
119
+ {
120
+ search: ["number"],
121
+ sync: { mode: "local", scope: "owner_id={user.id}", confirmed: ["update"] },
122
+ access: { ownerField: "owner_id", scope: "owner", adminGroups: ["staff"] },
123
+ transitions: {
124
+ field: "status",
125
+ states: ["scheduled", "boarding", "departed", "cancelled"],
126
+ initial: "scheduled",
127
+ on: {
128
+ board: { from: "scheduled", to: "boarding" },
129
+ depart: { from: "boarding", to: "departed" },
130
+ cancel: { from: ["scheduled", "boarding"], to: "cancelled" },
131
+ },
132
+ },
133
+ },
134
+ );
135
+
136
+ /** The manifest as it would be serialised — what every consumer reads. */
137
+ function asJson(manifest: Manifest): unknown {
138
+ return JSON.parse(JSON.stringify(manifest));
139
+ }
140
+
141
+ describe("manifestFromResources — parity with rastack compile", () => {
142
+ const workRoot = join(__dirname, "runtime-manifest-fixtures");
143
+ const resourcesDir = join(workRoot, "resources");
144
+ const outDir = join(workRoot, ".rastack");
145
+
146
+ beforeAll(() => {
147
+ mkdirSync(resourcesDir, { recursive: true });
148
+ writeFileSync(join(resourcesDir, "airline.ts"), FIXTURE_SOURCE);
149
+ });
150
+
151
+ afterAll(() => {
152
+ rmSync(workRoot, { recursive: true, force: true });
153
+ });
154
+
155
+ it("produces the exact manifest the compiler emits for the same definitions", () => {
156
+ const compiled = compile(workRoot, outDir).manifest;
157
+ const runtime = manifestFromResources(Airport, Terminal, Flight);
158
+ expect(asJson(runtime)).toEqual(asJson(compiled));
159
+
160
+ // Belt and braces: the interesting derived bits are actually present.
161
+ const flight = runtime.resources.find((r) => r.model === "flight")!;
162
+ expect(flight.transitions?.initial).toBe("scheduled");
163
+ const status = flight.fields.find((f) => f.name === "status")!;
164
+ expect(status.options).toEqual([
165
+ "scheduled",
166
+ "boarding",
167
+ "departed",
168
+ "cancelled",
169
+ ]);
170
+ expect(status.default).toBe("scheduled"); // machine initial → field default
171
+ const terminal = runtime.resources.find((r) => r.model === "terminal")!;
172
+ expect(terminal.relations).toEqual([
173
+ { field: "airport", target: "airports.airport", kind: "fk" },
174
+ ]);
175
+ });
176
+
177
+ it("orders resources like the compiler (app, then model)", () => {
178
+ const runtime = manifestFromResources(Flight, Terminal, Airport);
179
+ expect(runtime.resources.map((r) => r.model)).toEqual([
180
+ "airport",
181
+ "flight",
182
+ "terminal",
183
+ ]);
184
+ });
185
+
186
+ it("rejects an s.ref whose runtime target is missing", () => {
187
+ const broken = {
188
+ __rastack: "resource",
189
+ app: "airports",
190
+ model: "gate",
191
+ fields: { terminal: { __rastackRef: true } },
192
+ options: {},
193
+ } as any;
194
+ expect(() => resourceModel(broken)).toThrow(/target thunk/);
195
+ });
196
+ });
197
+
198
+ describe("toManifest — everything a provider may be handed", () => {
199
+ const expected = asJson(manifestFromResources(Airport, Terminal));
200
+
201
+ it("passes a compiled manifest through (object and JSON string)", () => {
202
+ const compiledShape = manifestFromResources(Airport, Terminal);
203
+ expect(toManifest(compiledShape)).toBe(compiledShape);
204
+ expect(asJson(toManifest(JSON.stringify(compiledShape))!)).toEqual(expected);
205
+ });
206
+
207
+ it("builds from an array of resource() values", () => {
208
+ expect(asJson(toManifest([Airport, Terminal])!)).toEqual(expected);
209
+ });
210
+
211
+ it("builds from a module namespace of resource() exports", () => {
212
+ const moduleNamespace = { Airport, Terminal, unrelated: 42, helper: () => 0 };
213
+ expect(asJson(toManifest(moduleNamespace)!)).toEqual(expected);
214
+ });
215
+
216
+ it("builds from a single resource() value and handles empties", () => {
217
+ const single = toManifest(Airport)!;
218
+ expect(single.resources.map((r) => r.model)).toEqual(["airport"]);
219
+ expect(toManifest(undefined)).toBeUndefined();
220
+ expect(toManifest(null)).toBeUndefined();
221
+ });
222
+
223
+ it("classifies resource() values", () => {
224
+ expect(isResourceValue(Airport)).toBe(true);
225
+ expect(isResourceValue({ app: "a", model: "b" })).toBe(false);
226
+ expect(isResourceValue("airports.airport")).toBe(false);
227
+ });
228
+ });
229
+
230
+ describe("hooks without a manifest — the definition is its own manifest", () => {
231
+ it("resolveEntityRef synthesises the resource from a resource() value", () => {
232
+ const resolved = resolveEntityRef(Flight); // no manifest anywhere
233
+ expect(resolved.listUrl).toBe("/api/airports/v1/flight/");
234
+ expect(resolved.resource).toBeDefined();
235
+ expect(resolved.resource!.relations).toEqual([]);
236
+ expect(resolved.transitions?.on.board).toEqual({
237
+ from: ["scheduled"],
238
+ to: "boarding",
239
+ });
240
+ // A supplied manifest still wins — the synthesised model is the fallback.
241
+ const manifest = manifestFromResources(Flight);
242
+ expect(resolveEntityRef(Flight, manifest).resource).toBe(
243
+ manifest.resources[0],
244
+ );
245
+ });
246
+
247
+ it("feeds the same write schema and form defaults the compiled path does", () => {
248
+ const resolved = resolveEntityRef(Flight);
249
+ const schema = writeSchema(resolved.resource!, false);
250
+ expect(schema.properties.status.enum).toEqual([
251
+ "scheduled",
252
+ "boarding",
253
+ "departed",
254
+ "cancelled",
255
+ ]);
256
+ expect(schema["x-rastack-transitions"]).toBe(resolved.resource!.transitions);
257
+ expect(initialValuesFromResource(resolved.resource!)).toEqual({
258
+ number: "",
259
+ status: "scheduled",
260
+ owner_id: "",
261
+ });
262
+ });
263
+
264
+ it("synthesises FK relations through the runtime target thunk", () => {
265
+ const resolved = resolveEntityRef(Terminal);
266
+ expect(resolved.resource!.relations).toEqual([
267
+ { field: "airport", target: "airports.airport", kind: "fk" },
268
+ ]);
269
+ const schema = writeSchema(resolved.resource!, false);
270
+ expect(schema.properties.airport["x-rastack-relation"]).toEqual({
271
+ app: "airports",
272
+ model: "airport",
273
+ });
274
+ });
275
+ });
276
+
277
+ // ---------------------------------------------------------------------------
278
+ // EntityTypesOf — the hand-written registry, typed from the module itself
279
+ // ---------------------------------------------------------------------------
280
+
281
+ describe("EntityTypesOf — string-key row types with no codegen", () => {
282
+ it("derives keys and rows from a resources module", () => {
283
+ const moduleShape = { Airport, Terminal, Flight, notAResource: 42 };
284
+ type Registry = EntityTypesOf<typeof moduleShape>;
285
+
286
+ // The declaration-merging pattern the docs recommend must compile:
287
+ // an interface extending the mapped type.
288
+ interface Merged extends EntityTypesOf<typeof moduleShape> {}
289
+
290
+ const key: EntityKeyOf<typeof Airport> = "airports.airport";
291
+ const row: Registry["airports.airport"] = {
292
+ id: 1,
293
+ code: "LHR",
294
+ name: "Heathrow",
295
+ open: true,
296
+ elevation: null, // `null: true` → number | null
297
+ };
298
+ const merged: Merged["airports.terminal"] = {
299
+ id: 2,
300
+ airport: 1,
301
+ label: "T5",
302
+ };
303
+ // @ts-expect-error — `open` is a boolean
304
+ const wrong: Registry["airports.airport"] = { ...row, open: "yes" };
305
+ // @ts-expect-error — non-resource exports contribute no registry keys
306
+ const nope: Registry["notAResource"] = 42;
307
+ expect([key, row, merged, wrong, nope]).toBeDefined();
308
+ });
309
+ });
@@ -90,10 +90,15 @@ describe("resolveEntityRef — a type names its resource", () => {
90
90
  number: s.string({ maxLength: 8 }),
91
91
  });
92
92
  expect(resolveEntityRef(Flight, manifest).resource).toBe(flight);
93
- // Without a manifest the URLs still resolve (the DSL value carries app/model)…
93
+ // Without a manifest the value is *its own* manifest: URLs resolve and
94
+ // the resource model is synthesised from the definition's runtime shape.
94
95
  const bare = resolveEntityRef(Flight);
95
96
  expect(bare.listUrl).toBe("/api/airports/v1/flight/");
96
- expect(bare.resource).toBeUndefined();
97
+ expect(bare.resource).toMatchObject({
98
+ app: "airports",
99
+ model: "flight",
100
+ fields: [{ name: "number", type: "string", maxLength: 8 }],
101
+ });
97
102
  });
98
103
 
99
104
  it("carries a resource()'s transitions even without a manifest", () => {
Binary file