rastack 0.0.49 → 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.
- 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 +20 -0
- package/dist/compile/analyze.js +60 -49
- 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 +162 -21
- package/dist/define/index.js +28 -22
- package/dist/define/manifest.d.ts +64 -0
- package/dist/define/manifest.js +250 -0
- package/dist/import/tabular.d.ts +8 -2
- package/dist/import/tabular.js +1 -1
- package/dist/plugin/core.d.ts +108 -0
- package/dist/plugin/core.js +198 -0
- package/dist/plugin/index.d.ts +112 -0
- package/dist/plugin/index.js +203 -0
- 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 +221 -0
- package/hooks/entity.ts +228 -0
- package/hooks/form/entity-form.ts +358 -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 +77 -0
- package/hooks/registry.ts +56 -0
- package/package.json +1 -1
- package/plugin.ts +8 -0
- package/provider/provider.tsx +26 -5
- package/provider/types.ts +15 -3
- package/src/compile/analyze.ts +74 -45
- 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 +233 -29
- package/src/define/manifest.ts +278 -0
- package/src/import/tabular.ts +9 -2
- package/src/plugin/core.ts +236 -0
- package/src/plugin/index.ts +243 -0
- 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/plugin.spec.ts +315 -0
- package/test/runtime-manifest.spec.ts +309 -0
- package/test/transitions.spec.ts +372 -0
- package/test/typed-hooks.spec.ts +412 -0
- package/wasm/rastack_wasm.js +1 -1
- package/wasm/rastack_wasm_bg.wasm +0 -0
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The dev-server schema compiler (`rastack/plugin`) — the way around
|
|
3
|
+
* "classes need a compile step". The plugin runs the same
|
|
4
|
+
* `analyzeProject`/`compile` pass as the CLI *inside* the bundler: entities
|
|
5
|
+
* authored as plain classes/interfaces become the `virtual:rastack-manifest`
|
|
6
|
+
* module, recompiled on save, with `rastack-env.d.ts` kept fresh and the
|
|
7
|
+
* last good manifest served through broken in-progress edits.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "fs";
|
|
11
|
+
import { join } from "path";
|
|
12
|
+
import {
|
|
13
|
+
GLOBAL_MANIFEST_KEY,
|
|
14
|
+
isEntitySource,
|
|
15
|
+
manifestModuleCode,
|
|
16
|
+
rastackPlugin,
|
|
17
|
+
registerModuleCode,
|
|
18
|
+
resolveOptions,
|
|
19
|
+
RESOLVED_MANIFEST_ID,
|
|
20
|
+
startRastackWatcher,
|
|
21
|
+
VIRTUAL_MANIFEST_ID,
|
|
22
|
+
VIRTUAL_REGISTER_ID,
|
|
23
|
+
virtualModuleDts,
|
|
24
|
+
} from "../src/plugin";
|
|
25
|
+
import { getGlobalManifest } from "../hooks/manifest";
|
|
26
|
+
import { resolveEntityRef } from "../hooks/entity";
|
|
27
|
+
|
|
28
|
+
const workRoot = join(__dirname, "plugin-fixtures");
|
|
29
|
+
const resourcesDir = join(workRoot, "entities");
|
|
30
|
+
const outDir = join(workRoot, ".rastack");
|
|
31
|
+
|
|
32
|
+
// Plain classes + a hook-rooted interface — no resource() DSL anywhere.
|
|
33
|
+
const GOOD_SOURCE = `
|
|
34
|
+
export class Airport {
|
|
35
|
+
code!: string;
|
|
36
|
+
name!: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export class Terminal {
|
|
40
|
+
airport!: Airport; // FK — inferred from the type
|
|
41
|
+
label!: string;
|
|
42
|
+
}
|
|
43
|
+
`;
|
|
44
|
+
|
|
45
|
+
// An all-required FK cycle — a compile *error* (the mid-edit breakage case).
|
|
46
|
+
const BROKEN_SOURCE = `
|
|
47
|
+
export class Airport {
|
|
48
|
+
code!: string;
|
|
49
|
+
home!: Terminal;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export class Terminal {
|
|
53
|
+
airport!: Airport;
|
|
54
|
+
label!: string;
|
|
55
|
+
}
|
|
56
|
+
`;
|
|
57
|
+
|
|
58
|
+
function writeEntities(source: string): void {
|
|
59
|
+
writeFileSync(join(resourcesDir, "airports.ts"), source);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
beforeEach(() => {
|
|
63
|
+
rmSync(workRoot, { recursive: true, force: true });
|
|
64
|
+
mkdirSync(resourcesDir, { recursive: true });
|
|
65
|
+
writeEntities(GOOD_SOURCE);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
afterAll(() => {
|
|
69
|
+
rmSync(workRoot, { recursive: true, force: true });
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
function makePlugin(overrides: Record<string, unknown> = {}) {
|
|
73
|
+
return rastackPlugin({
|
|
74
|
+
resourcesDir,
|
|
75
|
+
outDir,
|
|
76
|
+
onError: () => {},
|
|
77
|
+
...overrides,
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
describe("rastackPlugin — the bundler hooks", () => {
|
|
82
|
+
it("serves class entities as the virtual manifest module", () => {
|
|
83
|
+
const plugin = makePlugin();
|
|
84
|
+
plugin.buildStart();
|
|
85
|
+
|
|
86
|
+
expect(plugin.resolveId(VIRTUAL_MANIFEST_ID)).toBe(RESOLVED_MANIFEST_ID);
|
|
87
|
+
expect(plugin.resolveId("some/other/module")).toBeUndefined();
|
|
88
|
+
|
|
89
|
+
const code = plugin.load(RESOLVED_MANIFEST_ID)!;
|
|
90
|
+
expect(code).toContain("export default manifest");
|
|
91
|
+
const manifest = plugin.api.manifest();
|
|
92
|
+
expect(code).toContain(JSON.stringify(manifest));
|
|
93
|
+
|
|
94
|
+
// The classes were compiled: fields read off the type system, the
|
|
95
|
+
// Airport-typed property inferred as a foreign key.
|
|
96
|
+
// The app name derives from the defining file (airports.ts → "airports").
|
|
97
|
+
const terminal = manifest.resources.find((r) => r.model === "terminal")!;
|
|
98
|
+
expect(terminal.relations).toEqual([
|
|
99
|
+
{ field: "airport", target: "airports.airport", kind: "fk" },
|
|
100
|
+
]);
|
|
101
|
+
expect(manifestModuleCode(manifest)).toBe(code);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("writes the artifacts — schema, openapi, env.d.ts, virtual-module types", () => {
|
|
105
|
+
makePlugin().buildStart();
|
|
106
|
+
expect(existsSync(join(outDir, "schema.rastack.json"))).toBe(true);
|
|
107
|
+
expect(existsSync(join(outDir, "openapi.json"))).toBe(true);
|
|
108
|
+
|
|
109
|
+
const env = readFileSync(join(outDir, "rastack-env.d.ts"), "utf8");
|
|
110
|
+
expect(env).toContain('"airports.airport": import("../entities/airports").Airport;');
|
|
111
|
+
|
|
112
|
+
const dts = readFileSync(join(outDir, "rastack-plugin.d.ts"), "utf8");
|
|
113
|
+
expect(dts).toBe(virtualModuleDts());
|
|
114
|
+
expect(dts).toContain(`declare module "${VIRTUAL_MANIFEST_ID}"`);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("recompiles when a watched entity changes", () => {
|
|
118
|
+
const plugin = makePlugin();
|
|
119
|
+
plugin.buildStart();
|
|
120
|
+
expect(
|
|
121
|
+
plugin.api.manifest().resources.map((r) => r.model),
|
|
122
|
+
).toEqual(["airport", "terminal"]);
|
|
123
|
+
|
|
124
|
+
writeEntities(GOOD_SOURCE + "\nexport class Gate { number!: string }\n");
|
|
125
|
+
plugin.watchChange(join(resourcesDir, "airports.ts"));
|
|
126
|
+
|
|
127
|
+
expect(plugin.api.manifest().resources.map((r) => r.model)).toEqual([
|
|
128
|
+
"airport",
|
|
129
|
+
"gate",
|
|
130
|
+
"terminal",
|
|
131
|
+
]);
|
|
132
|
+
expect(plugin.load(RESOLVED_MANIFEST_ID)).toContain('"gate"');
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("keeps serving the last good manifest through a broken edit", () => {
|
|
136
|
+
const errors: unknown[] = [];
|
|
137
|
+
const plugin = makePlugin({ onError: (e: unknown) => errors.push(e) });
|
|
138
|
+
plugin.buildStart();
|
|
139
|
+
const goodCode = plugin.load(RESOLVED_MANIFEST_ID);
|
|
140
|
+
|
|
141
|
+
writeEntities(BROKEN_SOURCE);
|
|
142
|
+
plugin.watchChange(join(resourcesDir, "airports.ts"));
|
|
143
|
+
|
|
144
|
+
// The compile failed (reported), but the app keeps its manifest.
|
|
145
|
+
expect(plugin.load(RESOLVED_MANIFEST_ID)).toBe(goodCode);
|
|
146
|
+
expect(errors).toHaveLength(1);
|
|
147
|
+
expect(String((errors[0] as Error).message)).toMatch(/circular/i);
|
|
148
|
+
expect(plugin.api.error()).toBe(errors[0]);
|
|
149
|
+
|
|
150
|
+
// Fixing the file recovers on the next change (load() recompiles).
|
|
151
|
+
writeEntities(GOOD_SOURCE);
|
|
152
|
+
plugin.watchChange(join(resourcesDir, "airports.ts"));
|
|
153
|
+
expect(plugin.load(RESOLVED_MANIFEST_ID)).toBe(goodCode);
|
|
154
|
+
expect(plugin.api.error()).toBeUndefined();
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("throws at startup when there is no good manifest to fall back to", () => {
|
|
158
|
+
writeEntities(BROKEN_SOURCE);
|
|
159
|
+
const plugin = makePlugin();
|
|
160
|
+
expect(() => plugin.buildStart()).toThrow(/circular/i);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("hot-invalidates the virtual module in a Vite-shaped server", () => {
|
|
164
|
+
const plugin = makePlugin();
|
|
165
|
+
plugin.buildStart();
|
|
166
|
+
|
|
167
|
+
const virtualModule = { id: RESOLVED_MANIFEST_ID };
|
|
168
|
+
const invalidated: unknown[] = [];
|
|
169
|
+
const server = {
|
|
170
|
+
moduleGraph: {
|
|
171
|
+
getModuleById: (id: string) =>
|
|
172
|
+
id === RESOLVED_MANIFEST_ID ? virtualModule : undefined,
|
|
173
|
+
invalidateModule: (mod: unknown) => invalidated.push(mod),
|
|
174
|
+
},
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
const changed = { id: "changed" };
|
|
178
|
+
const result = plugin.handleHotUpdate({
|
|
179
|
+
file: join(resourcesDir, "airports.ts"),
|
|
180
|
+
server,
|
|
181
|
+
modules: [changed],
|
|
182
|
+
});
|
|
183
|
+
expect(invalidated).toEqual([virtualModule]);
|
|
184
|
+
expect(result).toEqual([changed, virtualModule]);
|
|
185
|
+
|
|
186
|
+
// Unrelated files don't touch the schema.
|
|
187
|
+
expect(
|
|
188
|
+
plugin.handleHotUpdate({
|
|
189
|
+
file: join(workRoot, "App.tsx"),
|
|
190
|
+
server,
|
|
191
|
+
modules: [changed],
|
|
192
|
+
}),
|
|
193
|
+
).toBeUndefined();
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
describe("auto-registration — no manifest prop anywhere", () => {
|
|
198
|
+
afterEach(() => {
|
|
199
|
+
delete (globalThis as Record<string, unknown>)[GLOBAL_MANIFEST_KEY];
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it("serves a register module that publishes the manifest globally", () => {
|
|
203
|
+
const plugin = makePlugin();
|
|
204
|
+
plugin.buildStart();
|
|
205
|
+
|
|
206
|
+
expect(plugin.resolveId(VIRTUAL_REGISTER_ID)).toBe(VIRTUAL_REGISTER_ID);
|
|
207
|
+
const code = plugin.load(VIRTUAL_REGISTER_ID)!;
|
|
208
|
+
expect(code).toBe(registerModuleCode());
|
|
209
|
+
expect(code).toContain(`import manifest from "${VIRTUAL_MANIFEST_ID}"`);
|
|
210
|
+
expect(code).toContain(`globalThis.${GLOBAL_MANIFEST_KEY} = manifest`);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it("injects the register script into index.html (dev src, build inline)", () => {
|
|
214
|
+
const plugin = makePlugin();
|
|
215
|
+
expect(plugin.transformIndexHtml.order).toBe("pre");
|
|
216
|
+
|
|
217
|
+
const dev = plugin.transformIndexHtml.handler("<html/>", { server: {} });
|
|
218
|
+
expect(dev).toEqual([
|
|
219
|
+
{
|
|
220
|
+
tag: "script",
|
|
221
|
+
attrs: { type: "module", src: `/@id/${VIRTUAL_REGISTER_ID}` },
|
|
222
|
+
injectTo: "head-prepend",
|
|
223
|
+
},
|
|
224
|
+
]);
|
|
225
|
+
|
|
226
|
+
const build = plugin.transformIndexHtml.handler("<html/>", {});
|
|
227
|
+
expect(build).toEqual([
|
|
228
|
+
{
|
|
229
|
+
tag: "script",
|
|
230
|
+
attrs: { type: "module" },
|
|
231
|
+
children: `import "${VIRTUAL_REGISTER_ID}";`,
|
|
232
|
+
injectTo: "head-prepend",
|
|
233
|
+
},
|
|
234
|
+
]);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it("the runtime falls back to the registered global manifest", () => {
|
|
238
|
+
class Airport {
|
|
239
|
+
code!: string;
|
|
240
|
+
}
|
|
241
|
+
// Nothing registered → the class cannot resolve without a manifest.
|
|
242
|
+
expect(getGlobalManifest()).toBeUndefined();
|
|
243
|
+
expect(() => resolveEntityRef(Airport, getGlobalManifest())).toThrow(
|
|
244
|
+
/manifest/,
|
|
245
|
+
);
|
|
246
|
+
|
|
247
|
+
// What the injected register module does at boot:
|
|
248
|
+
(globalThis as Record<string, unknown>)[GLOBAL_MANIFEST_KEY] = {
|
|
249
|
+
version: 1,
|
|
250
|
+
resources: [
|
|
251
|
+
{ app: "airports", model: "airport", fields: [], relations: [] },
|
|
252
|
+
],
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
const manifest = getGlobalManifest()!;
|
|
256
|
+
expect(manifest.resources).toHaveLength(1);
|
|
257
|
+
expect(getGlobalManifest()).toBe(manifest); // cached per assignment
|
|
258
|
+
// useRastackManifest (which useData/useForm read outside any provider)
|
|
259
|
+
// falls back to this global, so a bare useData(Airport) resolves with
|
|
260
|
+
// zero schema wiring in app code.
|
|
261
|
+
expect(resolveEntityRef(Airport, getGlobalManifest()).listUrl).toBe(
|
|
262
|
+
"/api/airports/v1/airport/",
|
|
263
|
+
);
|
|
264
|
+
});
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
describe("isEntitySource — what triggers a recompile", () => {
|
|
268
|
+
const options = resolveOptions({ resourcesDir, outDir }, workRoot);
|
|
269
|
+
|
|
270
|
+
it("accepts .ts/.tsx under the resources dir and nothing else", () => {
|
|
271
|
+
expect(isEntitySource(join(resourcesDir, "a.ts"), options)).toBe(true);
|
|
272
|
+
expect(isEntitySource(join(resourcesDir, "deep", "b.tsx"), options)).toBe(true);
|
|
273
|
+
expect(isEntitySource(join(resourcesDir, "a.d.ts"), options)).toBe(false);
|
|
274
|
+
expect(isEntitySource(join(resourcesDir, "notes.md"), options)).toBe(false);
|
|
275
|
+
expect(isEntitySource(join(workRoot, "elsewhere.ts"), options)).toBe(false);
|
|
276
|
+
// The emitted artifacts must never re-trigger the compiler.
|
|
277
|
+
expect(isEntitySource(join(outDir, "rastack-env.d.ts"), options)).toBe(false);
|
|
278
|
+
});
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
describe("startRastackWatcher — the non-Rollup dev servers", () => {
|
|
282
|
+
it("compiles at start and rewrites artifacts on save", async () => {
|
|
283
|
+
const watcher = startRastackWatcher({
|
|
284
|
+
resourcesDir,
|
|
285
|
+
outDir,
|
|
286
|
+
onError: () => {},
|
|
287
|
+
});
|
|
288
|
+
try {
|
|
289
|
+
expect(existsSync(join(outDir, "schema.rastack.json"))).toBe(true);
|
|
290
|
+
expect(
|
|
291
|
+
watcher.state.manifest().resources.map((r) => r.model),
|
|
292
|
+
).toEqual(["airport", "terminal"]);
|
|
293
|
+
|
|
294
|
+
writeEntities(GOOD_SOURCE + "\nexport class Gate { number!: string }\n");
|
|
295
|
+
await waitFor(() =>
|
|
296
|
+
readFileSync(join(outDir, "schema.rastack.json"), "utf8").includes(
|
|
297
|
+
'"gate"',
|
|
298
|
+
),
|
|
299
|
+
);
|
|
300
|
+
} finally {
|
|
301
|
+
watcher.close();
|
|
302
|
+
}
|
|
303
|
+
});
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
async function waitFor(check: () => boolean, timeoutMs = 5000): Promise<void> {
|
|
307
|
+
const start = Date.now();
|
|
308
|
+
for (;;) {
|
|
309
|
+
if (check()) return;
|
|
310
|
+
if (Date.now() - start > timeoutMs) {
|
|
311
|
+
throw new Error("waitFor: condition not met in time");
|
|
312
|
+
}
|
|
313
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
314
|
+
}
|
|
315
|
+
}
|
|
@@ -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
|
+
});
|