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.
- package/CHANGELOG.md +2 -0
- package/dist/admin.js +13 -13
- package/dist/compile/analyze.d.ts +1 -7
- package/dist/compile/analyze.js +12 -68
- package/dist/define/index.d.ts +63 -16
- package/dist/define/index.js +27 -21
- package/dist/define/manifest.d.ts +64 -0
- package/dist/define/manifest.js +250 -0
- 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/wasm/rastack_wasm_bg.wasm +0 -0
- package/hooks/data.ts +18 -6
- package/hooks/entity.ts +11 -5
- package/hooks/form/entity-form.ts +12 -8
- package/hooks/manifest.ts +50 -14
- package/hooks/registry.ts +25 -8
- package/package.json +1 -1
- package/plugin.ts +8 -0
- package/provider/provider.tsx +19 -5
- package/provider/types.ts +12 -5
- package/src/compile/analyze.ts +12 -62
- package/src/define/index.ts +98 -22
- package/src/define/manifest.ts +278 -0
- package/src/plugin/core.ts +236 -0
- package/src/plugin/index.ts +243 -0
- package/test/plugin.spec.ts +315 -0
- package/test/runtime-manifest.spec.ts +309 -0
- package/test/typed-hooks.spec.ts +7 -2
- package/wasm/rastack_wasm_bg.wasm +0 -0
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `rastack/plugin` — the schema compiles itself inside your dev server.
|
|
3
|
+
*
|
|
4
|
+
* Class and interface entities are pure types, so the manifest has to be
|
|
5
|
+
* derived by the TypeScript checker — but not by a command you run. Add the
|
|
6
|
+
* plugin to the bundler you already use and the whole `rastack compile`
|
|
7
|
+
* pass disappears into the dev loop:
|
|
8
|
+
*
|
|
9
|
+
* ```ts
|
|
10
|
+
* // vite.config.ts
|
|
11
|
+
* import { rastackPlugin } from "rastack/plugin";
|
|
12
|
+
* export default defineConfig({
|
|
13
|
+
* plugins: [rastackPlugin({ resourcesDir: "src/entities" })],
|
|
14
|
+
* });
|
|
15
|
+
* ```
|
|
16
|
+
*
|
|
17
|
+
* ```tsx
|
|
18
|
+
* // app — the manifest is a live import, recompiled on every save
|
|
19
|
+
* import manifest from "virtual:rastack-manifest";
|
|
20
|
+
* <RAStackProvider mode="local" manifest={manifest}>…</RAStackProvider>
|
|
21
|
+
* ```
|
|
22
|
+
*
|
|
23
|
+
* What it does on your behalf, on every relevant file save:
|
|
24
|
+
* - recompiles the entity graph (classes, interfaces, `resource()` calls);
|
|
25
|
+
* - serves the manifest as the `virtual:rastack-manifest` module and
|
|
26
|
+
* hot-invalidates it;
|
|
27
|
+
* - rewrites `.rastack/rastack-env.d.ts` (string-key row types) and
|
|
28
|
+
* `schema.rastack.json`/`openapi.json`, so the committed artifacts and the
|
|
29
|
+
* registry never go stale;
|
|
30
|
+
* - on a broken in-progress edit, reports the error and keeps serving the
|
|
31
|
+
* last good manifest.
|
|
32
|
+
*
|
|
33
|
+
* The plugin object is Vite/Rollup-compatible (Vite-specific hooks are
|
|
34
|
+
* ignored by plain Rollup). For dev servers with other plugin systems
|
|
35
|
+
* (Next/webpack, Metro/Expo), `startRastackWatcher` does the same
|
|
36
|
+
* recompile-on-save against the filesystem — call it once from your config —
|
|
37
|
+
* and `rastack dev` remains the zero-config path.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
import * as fs from "fs";
|
|
41
|
+
import * as path from "path";
|
|
42
|
+
import {
|
|
43
|
+
createSchemaState,
|
|
44
|
+
isEntitySource,
|
|
45
|
+
registerModuleCode,
|
|
46
|
+
RESOLVED_MANIFEST_ID,
|
|
47
|
+
VIRTUAL_MANIFEST_ID,
|
|
48
|
+
VIRTUAL_REGISTER_ID,
|
|
49
|
+
type RastackPluginOptions,
|
|
50
|
+
type SchemaState,
|
|
51
|
+
} from "./core";
|
|
52
|
+
|
|
53
|
+
export {
|
|
54
|
+
createSchemaState,
|
|
55
|
+
GLOBAL_MANIFEST_KEY,
|
|
56
|
+
isEntitySource,
|
|
57
|
+
manifestModuleCode,
|
|
58
|
+
registerModuleCode,
|
|
59
|
+
resolveOptions,
|
|
60
|
+
RESOLVED_MANIFEST_ID,
|
|
61
|
+
runCompile,
|
|
62
|
+
VIRTUAL_MANIFEST_ID,
|
|
63
|
+
VIRTUAL_REGISTER_ID,
|
|
64
|
+
virtualModuleDts,
|
|
65
|
+
} from "./core";
|
|
66
|
+
export type {
|
|
67
|
+
RastackPluginOptions,
|
|
68
|
+
ResolvedPluginOptions,
|
|
69
|
+
SchemaState,
|
|
70
|
+
} from "./core";
|
|
71
|
+
|
|
72
|
+
/** One HTML tag descriptor in Vite's `transformIndexHtml` shape. */
|
|
73
|
+
export interface HtmlTagDescriptor {
|
|
74
|
+
tag: string;
|
|
75
|
+
attrs?: Record<string, string>;
|
|
76
|
+
children?: string;
|
|
77
|
+
injectTo?: "head" | "head-prepend" | "body" | "body-prepend";
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The minimal bundler-plugin surface we emit (Vite superset of Rollup). */
|
|
81
|
+
export interface RastackBundlerPlugin {
|
|
82
|
+
name: string;
|
|
83
|
+
buildStart(): void;
|
|
84
|
+
resolveId(id: string): string | undefined;
|
|
85
|
+
load(id: string): string | undefined;
|
|
86
|
+
watchChange(id: string): void;
|
|
87
|
+
/**
|
|
88
|
+
* Vite-only: auto-register the schema. Injects a module script that sets
|
|
89
|
+
* `globalThis.__RASTACK_MANIFEST__` before the app boots, so the provider
|
|
90
|
+
* and hooks need no `manifest` wiring at all. Object form with
|
|
91
|
+
* `order: "pre"` — the tag must be injected *before* Vite scans the HTML
|
|
92
|
+
* entry, or the build ships the virtual import verbatim.
|
|
93
|
+
*/
|
|
94
|
+
transformIndexHtml: {
|
|
95
|
+
order: "pre";
|
|
96
|
+
handler(html: string, ctx?: { server?: unknown }): HtmlTagDescriptor[];
|
|
97
|
+
};
|
|
98
|
+
/** Vite-only: recompile + hot-invalidate the virtual manifest module. */
|
|
99
|
+
handleHotUpdate(ctx: {
|
|
100
|
+
file: string;
|
|
101
|
+
server: {
|
|
102
|
+
moduleGraph: {
|
|
103
|
+
getModuleById(id: string): unknown;
|
|
104
|
+
invalidateModule(mod: any): void;
|
|
105
|
+
};
|
|
106
|
+
};
|
|
107
|
+
modules: unknown[];
|
|
108
|
+
}): unknown[] | void;
|
|
109
|
+
/** The live schema state — exposed for tests and advanced callers. */
|
|
110
|
+
api: SchemaState;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The bundler plugin. Works as-is in Vite and Rollup; the returned object is
|
|
115
|
+
* plain data + closures, so any bundler with resolve/load/watch hooks can
|
|
116
|
+
* adapt it.
|
|
117
|
+
*/
|
|
118
|
+
export function rastackPlugin(
|
|
119
|
+
options: RastackPluginOptions = {},
|
|
120
|
+
): RastackBundlerPlugin {
|
|
121
|
+
const state = createSchemaState(options);
|
|
122
|
+
|
|
123
|
+
return {
|
|
124
|
+
name: "rastack",
|
|
125
|
+
|
|
126
|
+
buildStart() {
|
|
127
|
+
// Compile eagerly so authoring errors surface at server start, and the
|
|
128
|
+
// artifacts (env.d.ts, schema.rastack.json) exist before first load.
|
|
129
|
+
state.manifest();
|
|
130
|
+
},
|
|
131
|
+
|
|
132
|
+
resolveId(id: string) {
|
|
133
|
+
if (id === VIRTUAL_MANIFEST_ID) return RESOLVED_MANIFEST_ID;
|
|
134
|
+
if (id === VIRTUAL_REGISTER_ID) return VIRTUAL_REGISTER_ID;
|
|
135
|
+
return undefined;
|
|
136
|
+
},
|
|
137
|
+
|
|
138
|
+
load(id: string) {
|
|
139
|
+
if (id === RESOLVED_MANIFEST_ID) return state.code();
|
|
140
|
+
if (id === VIRTUAL_REGISTER_ID) return registerModuleCode();
|
|
141
|
+
return undefined;
|
|
142
|
+
},
|
|
143
|
+
|
|
144
|
+
watchChange(id: string) {
|
|
145
|
+
if (isEntitySource(id, state.options)) state.invalidate();
|
|
146
|
+
},
|
|
147
|
+
|
|
148
|
+
handleHotUpdate(ctx) {
|
|
149
|
+
if (!isEntitySource(ctx.file, state.options)) return;
|
|
150
|
+
state.invalidate();
|
|
151
|
+
const mod = ctx.server.moduleGraph.getModuleById(RESOLVED_MANIFEST_ID);
|
|
152
|
+
if (mod) {
|
|
153
|
+
ctx.server.moduleGraph.invalidateModule(mod);
|
|
154
|
+
return [...ctx.modules, mod];
|
|
155
|
+
}
|
|
156
|
+
},
|
|
157
|
+
|
|
158
|
+
transformIndexHtml: {
|
|
159
|
+
order: "pre",
|
|
160
|
+
handler(_html: string, ctx?: { server?: unknown }) {
|
|
161
|
+
// Dev serves resolved ids at /@id/<id>; build gets an inline module
|
|
162
|
+
// script that (because this runs `pre`, before Vite scans the HTML
|
|
163
|
+
// entry) is bundled, its import resolving back through
|
|
164
|
+
// resolveId/load above.
|
|
165
|
+
const attrs: Record<string, string> = { type: "module" };
|
|
166
|
+
const tag: HtmlTagDescriptor = ctx?.server
|
|
167
|
+
? {
|
|
168
|
+
tag: "script",
|
|
169
|
+
attrs: { ...attrs, src: `/@id/${VIRTUAL_REGISTER_ID}` },
|
|
170
|
+
}
|
|
171
|
+
: {
|
|
172
|
+
tag: "script",
|
|
173
|
+
attrs,
|
|
174
|
+
children: `import ${JSON.stringify(VIRTUAL_REGISTER_ID)};`,
|
|
175
|
+
};
|
|
176
|
+
return [{ ...tag, injectTo: "head-prepend" }];
|
|
177
|
+
},
|
|
178
|
+
},
|
|
179
|
+
|
|
180
|
+
api: state,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Alias so `import { rastack } from "rastack/plugin"` reads naturally. */
|
|
185
|
+
export const rastack = rastackPlugin;
|
|
186
|
+
|
|
187
|
+
export interface RastackWatcher {
|
|
188
|
+
/** Stop watching. */
|
|
189
|
+
close(): void;
|
|
190
|
+
/** The live schema state (same object the recompiles flow through). */
|
|
191
|
+
state: SchemaState;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* The plugin for everything that isn't Rollup-shaped: watch the resources
|
|
196
|
+
* directory and rewrite the artifacts (`schema.rastack.json`,
|
|
197
|
+
* `openapi.json`, `rastack-env.d.ts`) on every save. Call once from any
|
|
198
|
+
* Node-side config (next.config.js, metro.config.js, a dev script):
|
|
199
|
+
*
|
|
200
|
+
* ```js
|
|
201
|
+
* // next.config.js
|
|
202
|
+
* const { startRastackWatcher } = require("rastack/plugin");
|
|
203
|
+
* if (process.env.NODE_ENV === "development") startRastackWatcher();
|
|
204
|
+
* ```
|
|
205
|
+
*
|
|
206
|
+
* The app then imports `.rastack/schema.rastack.json` directly (a plain JSON
|
|
207
|
+
* import every bundler hot-reloads) instead of the virtual module.
|
|
208
|
+
*/
|
|
209
|
+
export function startRastackWatcher(
|
|
210
|
+
options: RastackPluginOptions = {},
|
|
211
|
+
): RastackWatcher {
|
|
212
|
+
const state = createSchemaState(options);
|
|
213
|
+
state.manifest(); // initial compile (writes the artifacts)
|
|
214
|
+
|
|
215
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
216
|
+
const watcher = fs.watch(
|
|
217
|
+
state.options.resourcesDir,
|
|
218
|
+
{ recursive: true },
|
|
219
|
+
(_event, filename) => {
|
|
220
|
+
// fs.watch reports paths relative to the watched directory.
|
|
221
|
+
const file = filename
|
|
222
|
+
? path.join(state.options.resourcesDir, String(filename))
|
|
223
|
+
: undefined;
|
|
224
|
+
if (!file || !isEntitySource(file, state.options)) {
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
state.invalidate();
|
|
228
|
+
// Debounce bursts (editors write twice, git checkouts touch many files).
|
|
229
|
+
if (timer) clearTimeout(timer);
|
|
230
|
+
timer = setTimeout(() => {
|
|
231
|
+
state.manifest();
|
|
232
|
+
}, 100);
|
|
233
|
+
},
|
|
234
|
+
);
|
|
235
|
+
|
|
236
|
+
return {
|
|
237
|
+
close: () => {
|
|
238
|
+
if (timer) clearTimeout(timer);
|
|
239
|
+
watcher.close();
|
|
240
|
+
},
|
|
241
|
+
state,
|
|
242
|
+
};
|
|
243
|
+
}
|
|
@@ -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
|
+
}
|