rastack 0.0.50 → 0.0.52

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 (72) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/auth/context.ts +73 -0
  3. package/auth/core.ts +293 -0
  4. package/auth/index.ts +23 -0
  5. package/components/auto-form/AutoForm.tsx +32 -12
  6. package/components/auto-form/use-auto-form.ts +75 -6
  7. package/components/data-table/DataTable.tsx +72 -45
  8. package/components/data-table/use-data-table.ts +57 -3
  9. package/components/index.ts +2 -0
  10. package/db.ts +9 -0
  11. package/dist/admin.js +13 -13
  12. package/dist/compile/analyze.d.ts +1 -7
  13. package/dist/compile/analyze.js +19 -70
  14. package/dist/compile/entities.js +163 -16
  15. package/dist/compile/model.d.ts +7 -3
  16. package/dist/compile/openapi.js +13 -3
  17. package/dist/compile/program.d.ts +2 -1
  18. package/dist/compile/program.js +7 -2
  19. package/dist/define/auth.d.ts +27 -0
  20. package/dist/define/auth.js +42 -0
  21. package/dist/define/db.d.ts +24 -1
  22. package/dist/define/db.js +1 -1
  23. package/dist/define/index.d.ts +82 -18
  24. package/dist/define/index.js +27 -21
  25. package/dist/define/manifest.d.ts +64 -0
  26. package/dist/define/manifest.js +259 -0
  27. package/dist/define/markers.d.ts +80 -0
  28. package/dist/define/markers.js +37 -0
  29. package/dist/define/types.d.ts +10 -0
  30. package/dist/define/types.js +26 -0
  31. package/dist/plugin/core.d.ts +108 -0
  32. package/dist/plugin/core.js +198 -0
  33. package/dist/plugin/index.d.ts +112 -0
  34. package/dist/plugin/index.js +203 -0
  35. package/dist/wasm/rastack_wasm.js +1 -1
  36. package/dist/wasm/rastack_wasm_bg.wasm +0 -0
  37. package/hooks/data.ts +18 -6
  38. package/hooks/entity.ts +11 -5
  39. package/hooks/form/entity-form.ts +12 -8
  40. package/hooks/manifest.ts +50 -14
  41. package/hooks/registry.ts +25 -8
  42. package/package.json +1 -1
  43. package/plugin.ts +8 -0
  44. package/provider/index.ts +1 -5
  45. package/provider/provider.tsx +61 -16
  46. package/provider/types.ts +26 -7
  47. package/provider/warehouse.ts +4 -3
  48. package/provider/wasm.ts +3 -1
  49. package/runtime.ts +1 -0
  50. package/src/compile/analyze.ts +27 -65
  51. package/src/compile/entities.ts +193 -19
  52. package/src/compile/model.ts +8 -9
  53. package/src/compile/openapi.ts +11 -3
  54. package/src/compile/program.ts +10 -3
  55. package/src/define/auth.ts +28 -0
  56. package/src/define/db.ts +26 -4
  57. package/src/define/index.ts +118 -24
  58. package/src/define/manifest.ts +287 -0
  59. package/src/define/markers.ts +83 -0
  60. package/src/define/types.ts +11 -0
  61. package/src/plugin/core.ts +236 -0
  62. package/src/plugin/index.ts +243 -0
  63. package/test/auth.spec.ts +207 -0
  64. package/test/compile.spec.ts +18 -7
  65. package/test/components.spec.ts +116 -0
  66. package/test/entities.spec.ts +123 -6
  67. package/test/plugin.spec.ts +315 -0
  68. package/test/runtime-manifest.spec.ts +309 -0
  69. package/test/typed-hooks.spec.ts +7 -2
  70. package/types.ts +7 -2
  71. package/wasm/rastack_wasm.js +1 -1
  72. package/wasm/rastack_wasm_bg.wasm +0 -0
@@ -81,7 +81,10 @@ describe("rastack compile — resource analysis & FK inference", () => {
81
81
 
82
82
  const airportField = terminal.fields.find((f) => f.name === "airport");
83
83
  expect(airportField?.type).toBe("fk");
84
- expect(airportField?.relation).toEqual({ app: "airports", model: "airport" });
84
+ expect(airportField?.relation).toEqual({
85
+ app: "airports",
86
+ model: "airport",
87
+ });
85
88
 
86
89
  expect(terminal.relations).toEqual([
87
90
  { field: "airport", target: "airports.airport", kind: "fk" },
@@ -132,7 +135,9 @@ describe("rastack compile — resource analysis & FK inference", () => {
132
135
  });
133
136
 
134
137
  // Resources without an access block stay unstamped.
135
- expect(findResource(resources, "airports", "airport").access).toBeUndefined();
138
+ expect(
139
+ findResource(resources, "airports", "airport").access,
140
+ ).toBeUndefined();
136
141
  });
137
142
 
138
143
  it("reports no collisions for a valid graph", () => {
@@ -176,9 +181,9 @@ describe("rastack compile — OpenAPI emission", () => {
176
181
  // Local-mode sync extensions, confirmed on update.
177
182
  const flightList = doc.paths["/api/airports/v1/flight/"];
178
183
  expect(flightList.get["x-rastack-sync"]).toBe("local");
179
- expect(doc.paths["/api/airports/v1/flight/{id}/"].put["x-rastack-sync"]).toBe(
180
- "confirmed",
181
- );
184
+ expect(
185
+ doc.paths["/api/airports/v1/flight/{id}/"].put["x-rastack-sync"],
186
+ ).toBe("confirmed");
182
187
  expect(doc.paths["/api/airports/v1/gate/"].get["x-rastack-sync-load"]).toBe(
183
188
  "lazy",
184
189
  );
@@ -186,19 +191,25 @@ describe("rastack compile — OpenAPI emission", () => {
186
191
  doc.paths["/api/airports/v1/booking/"].post["x-rastack-sync-client-ids"],
187
192
  ).toBe(true);
188
193
 
189
- // Access-controlled resource carries the access extension on every op.
194
+ // Access-controlled resource carries the full policy on every op — the
195
+ // permission gate plus the access block `rastack/auth` mirrors in the UI.
190
196
  const itineraryList = doc.paths["/api/airports/v1/itinerary/"];
197
+ expect(itineraryList.get["x-rastack-permission"]).toBe("authenticated");
191
198
  expect(itineraryList.get["x-rastack-access"]).toEqual({
192
199
  ownerField: "owner",
193
200
  scope: "owner",
201
+ adminGroups: ["staff"],
194
202
  });
195
203
  expect(
196
204
  doc.paths["/api/airports/v1/itinerary/{id}/"].delete["x-rastack-access"],
197
- ).toEqual({ ownerField: "owner", scope: "owner" });
205
+ ).toEqual({ ownerField: "owner", scope: "owner", adminGroups: ["staff"] });
198
206
  // Unscoped resources stay unstamped.
199
207
  expect(
200
208
  doc.paths["/api/airports/v1/airport/"].get["x-rastack-access"],
201
209
  ).toBeUndefined();
210
+ expect(
211
+ doc.paths["/api/airports/v1/airport/"].get["x-rastack-permission"],
212
+ ).toBeUndefined();
202
213
  });
203
214
 
204
215
  it("round-trips through the existing hooks codegen", async () => {
@@ -16,6 +16,7 @@ import {
16
16
  resolveFieldSpecs,
17
17
  } from "../components/auto-form/fields";
18
18
  import { buildAutoFormModel } from "../components/auto-form/use-auto-form";
19
+ import { anonymousAuth, userAuth, type ResourcePolicy } from "../auth/core";
19
20
  import type {
20
21
  TableQuery,
21
22
  AutoFormLike,
@@ -406,3 +407,118 @@ describe("buildAutoFormModel — wiring a form hook into field descriptors", ()
406
407
  expect(transition).toHaveBeenCalledWith("board");
407
408
  });
408
409
  });
410
+
411
+ // Auth handling built into the components: the models mirror the server's
412
+ // `permission` gate + row-level security (rastack/auth) so the UI never
413
+ // offers a write the API would reject.
414
+ describe("component models — authorization gating", () => {
415
+ const rls: ResourcePolicy = {
416
+ permission: "authenticated",
417
+ access: { ownerField: "owner", scope: "owner", adminGroups: ["staff"] },
418
+ };
419
+ const readOnly: ResourcePolicy = { permission: "authenticatedOrReadOnly" };
420
+
421
+ const makeField = (): AutoFormField =>
422
+ ({
423
+ value: "",
424
+ error: "",
425
+ type: "text",
426
+ isChecked: false,
427
+ isError: false,
428
+ isDisabled: false,
429
+ isRequired: false,
430
+ setValue: jest.fn(),
431
+ setTouched: jest.fn(),
432
+ }) as unknown as AutoFormField;
433
+
434
+ const form: AutoFormLike = {
435
+ formValues: { owner: "u1", notes: "hi" },
436
+ getFormField: () => makeField(),
437
+ onSubmit: jest.fn(),
438
+ resetForm: jest.fn(),
439
+ isSubmitDisabled: false,
440
+ isSubmitting: false,
441
+ errors: {},
442
+ };
443
+
444
+ it("auto-form: anonymous writes are blocked by the permission gate", () => {
445
+ const model = buildAutoFormModel(form, {
446
+ auth: anonymousAuth(),
447
+ policy: readOnly,
448
+ action: "update",
449
+ });
450
+ expect(model.canWrite).toBe(false);
451
+ expect(model.canSubmit).toBe(false);
452
+
453
+ const asUser = buildAutoFormModel(form, {
454
+ auth: userAuth("u1"),
455
+ policy: readOnly,
456
+ });
457
+ expect(asUser.canWrite).toBe(true);
458
+ });
459
+
460
+ it("auto-form: no identity wired up ⇒ ungated (server stays the enforcer)", () => {
461
+ const model = buildAutoFormModel(form, { policy: rls });
462
+ expect(model.canWrite).toBe(true);
463
+ expect(model.fields.map((f) => f.name)).toEqual(["owner", "notes"]);
464
+ });
465
+
466
+ it("auto-form: hides the server-stamped owner field, except for RLS bypassers", () => {
467
+ const model = buildAutoFormModel(form, {
468
+ auth: userAuth("u1"),
469
+ policy: rls,
470
+ });
471
+ expect(model.fields.map((f) => f.name)).toEqual(["notes"]);
472
+
473
+ const staff = buildAutoFormModel(form, {
474
+ auth: userAuth("u1", ["staff"]),
475
+ policy: rls,
476
+ });
477
+ expect(staff.fields.map((f) => f.name)).toEqual(["owner", "notes"]);
478
+
479
+ // An explicit field list is the author's call — never overridden.
480
+ const explicit = buildAutoFormModel(form, {
481
+ auth: userAuth("u1"),
482
+ policy: rls,
483
+ fields: ["owner", "notes"],
484
+ });
485
+ expect(explicit.fields.map((f) => f.name)).toEqual(["owner", "notes"]);
486
+ });
487
+
488
+ it("data-table: canRead / can / canActOnRow mirror the server's rules", () => {
489
+ const query = {
490
+ data: [
491
+ { id: 1, owner: "u1" },
492
+ { id: 2, owner: "u2" },
493
+ ],
494
+ isLoading: false,
495
+ } as TableQuery<{ id: number; owner: string }>;
496
+
497
+ const anon = buildDataTableModel(query, {
498
+ auth: anonymousAuth(),
499
+ policy: rls,
500
+ });
501
+ expect(anon.canRead).toBe(false);
502
+
503
+ const mine = buildDataTableModel(query, {
504
+ auth: userAuth("u1"),
505
+ policy: rls,
506
+ });
507
+ expect(mine.canRead).toBe(true);
508
+ expect(mine.can("delete")).toBe(true);
509
+ expect(mine.canActOnRow("delete", { id: 1, owner: "u1" })).toBe(true);
510
+ expect(mine.canActOnRow("delete", { id: 2, owner: "u2" })).toBe(false);
511
+
512
+ const anonPublicRead = buildDataTableModel(query, {
513
+ auth: anonymousAuth(),
514
+ policy: readOnly,
515
+ });
516
+ expect(anonPublicRead.canRead).toBe(true);
517
+ expect(anonPublicRead.can("create")).toBe(false);
518
+
519
+ // No identity wired up ⇒ everything stays enabled.
520
+ const ungated = buildDataTableModel(query, { policy: rls });
521
+ expect(ungated.canRead).toBe(true);
522
+ expect(ungated.can("delete")).toBe(true);
523
+ });
524
+ });
@@ -17,6 +17,7 @@ import {
17
17
  const workRoot = join(__dirname, "entities-fixtures");
18
18
  const appDir = join(workRoot, "app");
19
19
  const classesDir = join(workRoot, "classes");
20
+ const authDir = join(workRoot, "auth");
20
21
  const cycleDir = join(workRoot, "cycles");
21
22
  const hardCycleDir = join(workRoot, "hard-cycles");
22
23
  const mixedDir = join(workRoot, "mixed");
@@ -82,7 +83,7 @@ import {
82
83
  PrimaryKey,
83
84
  Unique,
84
85
  UUID,
85
- } from "rastack/types";
86
+ } from "rastack/db";
86
87
  import { Terminal } from "./airports";
87
88
 
88
89
  /** A reusable column config — "extend DbConfig once, use it everywhere". */
@@ -184,6 +185,60 @@ export class BoardingService {
184
185
  export class SplashScreen extends BoardingService {}
185
186
  `;
186
187
 
188
+ const AUTH_TS = `
189
+ import {
190
+ AdminGroups,
191
+ Group,
192
+ IsAuthenticated,
193
+ IsAuthenticatedOrReadOnly,
194
+ Owner,
195
+ OwnerScoped,
196
+ SharedStorage,
197
+ } from "rastack/auth";
198
+
199
+ // Per-user rows: RLS on owner, physically partitioned per identity. The
200
+ // implements clause is policy, not behaviour — still a plain data class.
201
+ export class Itinerary
202
+ implements IsAuthenticated, AdminGroups<"staff">, OwnerScoped
203
+ {
204
+ owner!: string & Owner;
205
+ notes!: string;
206
+ }
207
+
208
+ // A group-owned dataset — physically partitioned per group by default.
209
+ export class Report
210
+ implements IsAuthenticatedOrReadOnly, AdminGroups<"staff" | "ops">
211
+ {
212
+ team!: string & Group;
213
+ title!: string;
214
+ }
215
+
216
+ // Group RLS, but explicitly opted out of per-group storage.
217
+ export class Note implements SharedStorage {
218
+ team!: string & Group;
219
+ body!: string;
220
+ }
221
+
222
+ // Interfaces attach the same markers with extends.
223
+ export interface Memo extends IsAuthenticated, AdminGroups<"staff"> {
224
+ body: string;
225
+ }
226
+
227
+ /** JSDoc equivalents, tag-for-marker.
228
+ * @rastackResource
229
+ * @rastackPermission public
230
+ * @rastackAdminGroups staff, ops
231
+ */
232
+ export interface Bulletin {
233
+ /** @owner */
234
+ author: string;
235
+ text: string;
236
+ }
237
+
238
+ declare function useEntityList<T>(name: string): T[];
239
+ export const m = useEntityList<Memo>("memos");
240
+ `;
241
+
187
242
  function findResource(
188
243
  resources: ResourceModel[],
189
244
  app: string,
@@ -202,6 +257,8 @@ beforeAll(() => {
202
257
  writeFileSync(join(appDir, "screens.tsx"), SCREENS_TSX);
203
258
  mkdirSync(classesDir, { recursive: true });
204
259
  writeFileSync(join(classesDir, "airports.ts"), CLASSES_TS);
260
+ mkdirSync(authDir, { recursive: true });
261
+ writeFileSync(join(authDir, "workspace.ts"), AUTH_TS);
205
262
  mkdirSync(cycleDir, { recursive: true });
206
263
  writeFileSync(join(cycleDir, "org.ts"), SOFT_CYCLE_TS);
207
264
  mkdirSync(hardCycleDir, { recursive: true });
@@ -254,7 +311,10 @@ describe("interface entities — datatypes from the type system", () => {
254
311
  expect(field("onTime")).toMatchObject({ type: "bool" });
255
312
  expect(field("departure")).toMatchObject({ type: "datetime" });
256
313
  expect(field("notes")).toMatchObject({ type: "string", null: true });
257
- expect(field("status")).toMatchObject({ type: "string", default: "scheduled" });
314
+ expect(field("status")).toMatchObject({
315
+ type: "string",
316
+ default: "scheduled",
317
+ });
258
318
  });
259
319
 
260
320
  it("reads constraint tags (@maxLength/@unique) off interface properties", () => {
@@ -270,13 +330,17 @@ describe("interface entities — datatypes from the type system", () => {
270
330
  it("reads column config from DbConfig brand types", () => {
271
331
  const { resources } = analyzeProject(appDir);
272
332
  const passenger = findResource(resources, "passengers", "passenger");
273
- const field = (name: string) => passenger.fields.find((f) => f.name === name);
333
+ const field = (name: string) =>
334
+ passenger.fields.find((f) => f.name === name);
274
335
 
275
336
  expect(field("id")).toMatchObject({ type: "uuid", primaryKey: true });
276
337
  expect(field("fullName")).toMatchObject({ type: "string", maxLength: 80 });
277
338
  expect(field("age")).toMatchObject({ type: "int" });
278
339
  expect(field("loyaltyPoints")).toMatchObject({ type: "float", default: 0 });
279
- expect(field("status")).toMatchObject({ type: "string", default: "active" });
340
+ expect(field("status")).toMatchObject({
341
+ type: "string",
342
+ default: "active",
343
+ });
280
344
  expect(field("terminal")).toMatchObject({
281
345
  type: "fk",
282
346
  null: true,
@@ -305,7 +369,8 @@ describe("interface entities — datatypes from the type system", () => {
305
369
  expect(flight.fields.find((f) => f.name === "tags")).toBeUndefined();
306
370
  expect(
307
371
  diagnostics.some(
308
- (d) => d.severity === "warning" && d.message.includes("flights.flight.tags"),
372
+ (d) =>
373
+ d.severity === "warning" && d.message.includes("flights.flight.tags"),
309
374
  ),
310
375
  ).toBe(true);
311
376
  });
@@ -406,10 +471,62 @@ describe("class entities — plain data classes", () => {
406
471
  });
407
472
  });
408
473
 
474
+ describe("entity access policy — auth as types", () => {
475
+ it("reads permission / adminGroups / scope markers off a class's implements clause", () => {
476
+ const { resources } = analyzeProject(authDir);
477
+ const itinerary = findResource(resources, "workspace", "itinerary");
478
+ expect(itinerary.permission).toBe("authenticated");
479
+ expect(itinerary.access).toEqual({
480
+ ownerField: "owner",
481
+ scope: "owner",
482
+ adminGroups: ["staff"],
483
+ });
484
+ });
485
+
486
+ it("a Group field makes a group-owned dataset — partitioned per group by default", () => {
487
+ const { resources } = analyzeProject(authDir);
488
+ const report = findResource(resources, "workspace", "report");
489
+ expect(report.permission).toBe("authenticatedOrReadOnly");
490
+ expect(report.access).toEqual({
491
+ groupField: "team",
492
+ scope: "group",
493
+ adminGroups: ["ops", "staff"],
494
+ });
495
+ // The group column is an ordinary string field in the schema.
496
+ expect(report.fields.find((f) => f.name === "team")?.type).toBe("string");
497
+ });
498
+
499
+ it("SharedStorage opts a Group dataset out of physical partitioning", () => {
500
+ const { resources } = analyzeProject(authDir);
501
+ const note = findResource(resources, "workspace", "note");
502
+ expect(note.access).toEqual({ groupField: "team", scope: "shared" });
503
+ });
504
+
505
+ it("interfaces attach markers via extends; markers add no columns", () => {
506
+ const { resources } = analyzeProject(authDir);
507
+ const memo = findResource(resources, "workspace", "memo");
508
+ expect(memo.permission).toBe("authenticated");
509
+ expect(memo.access).toEqual({ adminGroups: ["staff"] });
510
+ expect(memo.fields.map((f) => f.name)).toEqual(["body"]);
511
+ });
512
+
513
+ it("JSDoc tags mirror every marker (@rastackPermission/@rastackAdminGroups/@owner)", () => {
514
+ const { resources } = analyzeProject(authDir);
515
+ const bulletin = findResource(resources, "workspace", "bulletin");
516
+ expect(bulletin.permission).toBe("public");
517
+ expect(bulletin.access).toEqual({
518
+ ownerField: "author",
519
+ adminGroups: ["staff", "ops"],
520
+ });
521
+ });
522
+ });
523
+
409
524
  describe("interface entities — circular dependencies", () => {
410
525
  it("reports a breakable cycle (optional self-FK) as a warning", () => {
411
526
  const { diagnostics } = analyzeProject(cycleDir);
412
- const cycle = diagnostics.find((d) => d.message.includes("Circular dependency"));
527
+ const cycle = diagnostics.find((d) =>
528
+ d.message.includes("Circular dependency"),
529
+ );
413
530
  expect(cycle).toBeDefined();
414
531
  expect(cycle!.severity).toBe("warning");
415
532
  expect(cycle!.message).toContain("org.employee → org.employee");
@@ -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
+ }