rastack 0.0.58 → 0.0.59

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 (83) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/README.md +190 -0
  3. package/dist/deploy/ci.js +2 -2
  4. package/dist/deploy/io.js +2 -2
  5. package/dist/dev/harness.d.ts +1 -1
  6. package/dist/dev/harness.js +1 -2
  7. package/dist/external/cache.d.ts +28 -0
  8. package/dist/external/cache.js +35 -0
  9. package/dist/external/envelope.d.ts +35 -0
  10. package/dist/external/envelope.js +48 -0
  11. package/dist/external/graphql.d.ts +25 -0
  12. package/dist/external/graphql.js +78 -0
  13. package/dist/external/handler.d.ts +52 -0
  14. package/dist/external/handler.js +139 -0
  15. package/dist/external/index.d.ts +19 -0
  16. package/dist/external/index.js +38 -0
  17. package/dist/external/lambda.d.ts +49 -0
  18. package/dist/external/lambda.js +86 -0
  19. package/dist/external/rest.d.ts +17 -0
  20. package/dist/external/rest.js +46 -0
  21. package/dist/external/route.d.ts +12 -0
  22. package/dist/external/route.js +33 -0
  23. package/dist/external/service.d.ts +50 -0
  24. package/dist/external/service.js +59 -0
  25. package/dist/external/types.d.ts +107 -0
  26. package/dist/external/types.js +22 -0
  27. package/dist/external/util.d.ts +16 -0
  28. package/dist/external/util.js +58 -0
  29. package/dist/rastack-dev.js +10 -0
  30. package/dist/rastack-wasm-build.d.ts +1 -1
  31. package/dist/rastack-wasm-build.js +6 -6
  32. package/dist/wasm/rastack_wasm_bg.wasm +0 -0
  33. package/external.ts +10 -0
  34. package/hooks/query/delete.ts +12 -11
  35. package/hooks/query/fetch.ts +6 -8
  36. package/hooks/query/interfaces.ts +1 -4
  37. package/hooks/query/list.ts +16 -9
  38. package/hooks/query/update.ts +8 -6
  39. package/package.json +24 -2
  40. package/scripts/copy-templates.js +1 -1
  41. package/scripts/copy-wasm.js +1 -1
  42. package/scripts/dev-install.mjs +63 -0
  43. package/src/deploy/ci.ts +2 -2
  44. package/src/deploy/io.ts +2 -2
  45. package/src/dev/harness.ts +1 -2
  46. package/src/external/cache.ts +53 -0
  47. package/src/external/envelope.ts +78 -0
  48. package/src/external/graphql.ts +98 -0
  49. package/src/external/handler.ts +207 -0
  50. package/src/external/index.ts +25 -0
  51. package/src/external/lambda.ts +108 -0
  52. package/src/external/rest.ts +60 -0
  53. package/src/external/route.ts +33 -0
  54. package/src/external/service.ts +86 -0
  55. package/src/external/types.ts +121 -0
  56. package/src/external/util.ts +57 -0
  57. package/src/rastack-dev.ts +11 -0
  58. package/src/rastack-wasm-build.ts +6 -6
  59. package/jest.config.cjs +0 -12
  60. package/test/auth.spec.ts +0 -207
  61. package/test/cache.spec.ts +0 -263
  62. package/test/cognito.spec.ts +0 -281
  63. package/test/compile.spec.ts +0 -240
  64. package/test/components.spec.ts +0 -524
  65. package/test/csv-schema.spec.ts +0 -143
  66. package/test/deploy.spec.ts +0 -571
  67. package/test/dev.spec.ts +0 -414
  68. package/test/entities.spec.ts +0 -597
  69. package/test/import.spec.ts +0 -241
  70. package/test/plugin.spec.ts +0 -315
  71. package/test/runtime-manifest.spec.ts +0 -309
  72. package/test/schema-entities.spec.ts +0 -688
  73. package/test/tokens.spec.ts +0 -302
  74. package/test/transitions.spec.ts +0 -372
  75. package/test/typed-hooks.spec.ts +0 -412
  76. package/test/update.spec.ts +0 -152
  77. package/test/validate.spec.ts +0 -319
  78. package/tsconfig.json +0 -27
  79. package/wasm/package.json +0 -4
  80. package/wasm/rastack_wasm.d.ts +0 -100
  81. package/wasm/rastack_wasm.js +0 -745
  82. package/wasm/rastack_wasm_bg.wasm +0 -0
  83. package/wasm/rastack_wasm_bg.wasm.d.ts +0 -28
@@ -1,597 +0,0 @@
1
- import { mkdirSync, rmSync, writeFileSync } from "fs";
2
- import { join } from "path";
3
- import {
4
- analyzeProject,
5
- buildOpenApi,
6
- compile,
7
- detectCycles,
8
- ResourceModel,
9
- } from "../src/compile";
10
-
11
- /**
12
- * Interface-driven inference: the app's entities are plain TypeScript
13
- * interfaces, discovered from the typed hook calls that consume them. The
14
- * fixtures are real `.ts`/`.tsx` files so the TypeChecker resolves the
15
- * interfaces exactly as it would in a consumer project.
16
- */
17
- const workRoot = join(__dirname, "entities-fixtures");
18
- const appDir = join(workRoot, "app");
19
- const classesDir = join(workRoot, "classes");
20
- const authDir = join(workRoot, "auth");
21
- const cycleDir = join(workRoot, "cycles");
22
- const hardCycleDir = join(workRoot, "hard-cycles");
23
- const mixedDir = join(workRoot, "mixed");
24
- const outDir = join(workRoot, "out");
25
-
26
- const AIRPORTS_TS = `
27
- /** @rastackSearch code,name */
28
- export interface Airport {
29
- /**
30
- * @maxLength 3
31
- * @unique
32
- */
33
- code: string;
34
- /** @maxLength 120 */
35
- name: string;
36
- terminals?: Terminal[]; // reverse side of Terminal.airport — no column
37
- }
38
-
39
- export interface Terminal {
40
- airport: Airport; // FK — inferred from the property type
41
- /** @maxLength 20 */
42
- label: string;
43
- }
44
-
45
- /** Never consumed by a hook and never referenced — not a resource. */
46
- export interface Runway {
47
- heading: number;
48
- }
49
- `;
50
-
51
- const FLIGHTS_TS = `
52
- import { Terminal } from "./airports";
53
-
54
- export interface Flight {
55
- /** @maxLength 8 */
56
- number: string;
57
- /** @int */
58
- seats: number;
59
- onTime: boolean;
60
- departure: Date;
61
- notes?: string;
62
- /** @default scheduled */
63
- status: string;
64
- terminal: Terminal | null; // nullable FK across files
65
- tags: string[]; // unsupported — skipped with a warning
66
- }
67
-
68
- /** @rastackResource
69
- * @rastackApp bookings
70
- * @rastackModel booking
71
- */
72
- export interface IBookingRecord {
73
- reference: string;
74
- }
75
- `;
76
-
77
- const PASSENGERS_TS = `
78
- import {
79
- DbConfig,
80
- Default,
81
- Int,
82
- MaxLength,
83
- PrimaryKey,
84
- Unique,
85
- UUID,
86
- } from "rastack/db";
87
- import { Terminal } from "./airports";
88
-
89
- /** A reusable column config — "extend DbConfig once, use it everywhere". */
90
- interface SeatCode extends DbConfig<{ maxLength: 4; unique: true }> {}
91
-
92
- export interface Passenger {
93
- id: UUID & PrimaryKey;
94
- seat: string & SeatCode;
95
- fullName: string & MaxLength<80>;
96
- age: Int;
97
- loyaltyPoints: number & Default<0>;
98
- status: string & Default<"active">;
99
- terminal?: Terminal;
100
- }
101
-
102
- declare function useEntityList<T>(name: string): T[];
103
- export const p = useEntityList<Passenger>("passengers");
104
- `;
105
-
106
- const SCREENS_TSX = `
107
- import { Flight } from "./flights";
108
-
109
- declare function useEntityList<T>(name: string): T[];
110
-
111
- export function FlightsScreen() {
112
- // The hook call is what roots the entity graph.
113
- return useEntityList<Flight>("flights");
114
- }
115
- `;
116
-
117
- const SOFT_CYCLE_TS = `
118
- export interface Employee {
119
- name: string;
120
- manager?: Employee; // optional self-FK — breakable cycle
121
- }
122
-
123
- declare function useEntity<T>(name: string): T;
124
- export const e = useEntity<Employee>("employee");
125
- `;
126
-
127
- const HARD_CYCLE_TS = `
128
- export interface Chicken {
129
- egg: Egg;
130
- }
131
- export interface Egg {
132
- chicken: Chicken;
133
- }
134
-
135
- declare function useEntity<T>(name: string): T;
136
- export const c = useEntity<Chicken>("chicken");
137
- `;
138
-
139
- const MIXED_TS = `
140
- import { resource, s } from "rastack/define";
141
-
142
- export const AirportResource = resource("mixed", "airport", {
143
- code: s.string({ maxLength: 3, unique: true }),
144
- }, { admin: { listDisplay: ["code"] } });
145
-
146
- /** @rastackApp mixed */
147
- export interface Airport {
148
- code: string;
149
- }
150
-
151
- declare function useEntity<T>(name: string): T;
152
- export const a = useEntity<Airport>("airport");
153
- `;
154
-
155
- const CLASSES_TS = `
156
- import { MaxLength, Unique } from "rastack/types";
157
-
158
- /** @rastackSearch code,name */
159
- export class Airport {
160
- code!: string & Unique & MaxLength<3>;
161
- /** @maxLength 120 */
162
- name!: string;
163
- region = "EU"; // initializer → type inferred, value is the default
164
- active = true;
165
- /** @int */
166
- gates = 0;
167
- }
168
-
169
- export class Terminal {
170
- airport!: Airport; // FK — inferred from the property type
171
- /** @maxLength 20 */
172
- label!: string;
173
- opened?: Date;
174
- static kind = "terminal"; // not a column
175
- private secret!: string; // not a column
176
- }
177
-
178
- /** Not a plain data class — a method means it must opt in explicitly. */
179
- export class BoardingService {
180
- queue: string[] = [];
181
- board(): void {}
182
- }
183
-
184
- /** Heritage means app code, not a table. */
185
- export class SplashScreen extends BoardingService {}
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
-
242
- function findResource(
243
- resources: ResourceModel[],
244
- app: string,
245
- model: string,
246
- ): ResourceModel {
247
- const found = resources.find((r) => r.app === app && r.model === model);
248
- if (!found) throw new Error(`missing ${app}.${model}`);
249
- return found;
250
- }
251
-
252
- beforeAll(() => {
253
- mkdirSync(appDir, { recursive: true });
254
- writeFileSync(join(appDir, "airports.ts"), AIRPORTS_TS);
255
- writeFileSync(join(appDir, "flights.ts"), FLIGHTS_TS);
256
- writeFileSync(join(appDir, "passengers.ts"), PASSENGERS_TS);
257
- writeFileSync(join(appDir, "screens.tsx"), SCREENS_TSX);
258
- mkdirSync(classesDir, { recursive: true });
259
- writeFileSync(join(classesDir, "airports.ts"), CLASSES_TS);
260
- mkdirSync(authDir, { recursive: true });
261
- writeFileSync(join(authDir, "workspace.ts"), AUTH_TS);
262
- mkdirSync(cycleDir, { recursive: true });
263
- writeFileSync(join(cycleDir, "org.ts"), SOFT_CYCLE_TS);
264
- mkdirSync(hardCycleDir, { recursive: true });
265
- writeFileSync(join(hardCycleDir, "paradox.ts"), HARD_CYCLE_TS);
266
- mkdirSync(mixedDir, { recursive: true });
267
- writeFileSync(join(mixedDir, "airport.ts"), MIXED_TS);
268
- });
269
-
270
- afterAll(() => {
271
- rmSync(workRoot, { recursive: true, force: true });
272
- });
273
-
274
- describe("interface entities — discovery from hooks", () => {
275
- it("roots the graph at hook type arguments and closes over FK references", () => {
276
- const { resources } = analyzeProject(appDir);
277
- const keys = resources.map((r) => `${r.app}.${r.model}`);
278
-
279
- // Flight is the hook root; Terminal and Airport are pulled in through the
280
- // FK chain flight → terminal → airport.
281
- expect(keys).toContain("flights.flight");
282
- expect(keys).toContain("airports.terminal");
283
- expect(keys).toContain("airports.airport");
284
-
285
- // Declared but neither hooked nor referenced — not a resource.
286
- expect(keys).not.toContain("airports.runway");
287
- });
288
-
289
- it("includes @rastackResource interfaces and honours @rastackApp/@rastackModel + I-prefix stripping", () => {
290
- const { resources } = analyzeProject(appDir);
291
- const booking = findResource(resources, "bookings", "booking");
292
- expect(booking.fields).toEqual([{ name: "reference", type: "string" }]);
293
- });
294
-
295
- it("names entities from the interface and the defining file", () => {
296
- const { resources } = analyzeProject(appDir);
297
- // airports.ts → app "airports"; interface Airport → model "airport".
298
- const airport = findResource(resources, "airports", "airport");
299
- expect(airport.search).toEqual(["code", "name"]);
300
- });
301
- });
302
-
303
- describe("interface entities — datatypes from the type system", () => {
304
- it("maps property types to manifest scalars with JSDoc refinements", () => {
305
- const { resources } = analyzeProject(appDir);
306
- const flight = findResource(resources, "flights", "flight");
307
- const field = (name: string) => flight.fields.find((f) => f.name === name);
308
-
309
- expect(field("number")).toMatchObject({ type: "string", maxLength: 8 });
310
- expect(field("seats")).toMatchObject({ type: "int" }); // number + @int
311
- expect(field("onTime")).toMatchObject({ type: "bool" });
312
- expect(field("departure")).toMatchObject({ type: "datetime" });
313
- expect(field("notes")).toMatchObject({ type: "string", null: true });
314
- expect(field("status")).toMatchObject({
315
- type: "string",
316
- default: "scheduled",
317
- });
318
- });
319
-
320
- it("reads constraint tags (@maxLength/@unique) off interface properties", () => {
321
- const { resources } = analyzeProject(appDir);
322
- const airport = findResource(resources, "airports", "airport");
323
- expect(airport.fields.find((f) => f.name === "code")).toMatchObject({
324
- type: "string",
325
- maxLength: 3,
326
- unique: true,
327
- });
328
- });
329
-
330
- it("reads column config from DbConfig brand types", () => {
331
- const { resources } = analyzeProject(appDir);
332
- const passenger = findResource(resources, "passengers", "passenger");
333
- const field = (name: string) =>
334
- passenger.fields.find((f) => f.name === name);
335
-
336
- expect(field("id")).toMatchObject({ type: "uuid", primaryKey: true });
337
- expect(field("fullName")).toMatchObject({ type: "string", maxLength: 80 });
338
- expect(field("age")).toMatchObject({ type: "int" });
339
- expect(field("loyaltyPoints")).toMatchObject({ type: "float", default: 0 });
340
- expect(field("status")).toMatchObject({
341
- type: "string",
342
- default: "active",
343
- });
344
- expect(field("terminal")).toMatchObject({
345
- type: "fk",
346
- null: true,
347
- relation: { app: "airports", model: "terminal" },
348
- });
349
- });
350
-
351
- it("supports reusable configs that extend DbConfig", () => {
352
- const { resources } = analyzeProject(appDir);
353
- const passenger = findResource(resources, "passengers", "passenger");
354
- expect(passenger.fields.find((f) => f.name === "seat")).toMatchObject({
355
- type: "string",
356
- maxLength: 4,
357
- unique: true,
358
- });
359
-
360
- // The brand interface itself is configuration, not an entity.
361
- expect(
362
- resources.find((r) => r.model.toLowerCase().includes("seatcode")),
363
- ).toBeUndefined();
364
- });
365
-
366
- it("skips unsupported property types with a warning", () => {
367
- const { resources, diagnostics } = analyzeProject(appDir);
368
- const flight = findResource(resources, "flights", "flight");
369
- expect(flight.fields.find((f) => f.name === "tags")).toBeUndefined();
370
- expect(
371
- diagnostics.some(
372
- (d) =>
373
- d.severity === "warning" && d.message.includes("flights.flight.tags"),
374
- ),
375
- ).toBe(true);
376
- });
377
- });
378
-
379
- describe("interface entities — FK inference", () => {
380
- it("treats an interface-typed property as a foreign key", () => {
381
- const { resources } = analyzeProject(appDir);
382
- const terminal = findResource(resources, "airports", "terminal");
383
-
384
- expect(terminal.fields.find((f) => f.name === "airport")).toMatchObject({
385
- type: "fk",
386
- relation: { app: "airports", model: "airport" },
387
- });
388
- expect(terminal.relations).toEqual([
389
- { field: "airport", target: "airports.airport", kind: "fk" },
390
- ]);
391
- });
392
-
393
- it("maps `Entity | null` to a nullable foreign key", () => {
394
- const { resources } = analyzeProject(appDir);
395
- const flight = findResource(resources, "flights", "flight");
396
- expect(flight.fields.find((f) => f.name === "terminal")).toMatchObject({
397
- type: "fk",
398
- null: true,
399
- relation: { app: "airports", model: "terminal" },
400
- });
401
- });
402
-
403
- it("ignores `Entity[]` arrays — the reverse side of the FK", () => {
404
- const { resources } = analyzeProject(appDir);
405
- const airport = findResource(resources, "airports", "airport");
406
- expect(airport.fields.find((f) => f.name === "terminals")).toBeUndefined();
407
- expect(airport.relations).toEqual([]);
408
- });
409
- });
410
-
411
- describe("class entities — plain data classes", () => {
412
- it("roots a plain data class by declaration — no hook, tag, or DSL needed", () => {
413
- const { resources } = analyzeProject(classesDir);
414
- const keys = resources.map((r) => `${r.app}.${r.model}`);
415
- expect(keys).toContain("airports.airport");
416
- expect(keys).toContain("airports.terminal");
417
- });
418
-
419
- it("does not root classes with methods or heritage clauses", () => {
420
- const { resources } = analyzeProject(classesDir);
421
- const keys = resources.map((r) => `${r.app}.${r.model}`);
422
- expect(keys).not.toContain("airports.boardingService");
423
- expect(keys).not.toContain("airports.splashScreen");
424
- });
425
-
426
- it("reads DbConfig brands and JSDoc tags off class fields", () => {
427
- const { resources } = analyzeProject(classesDir);
428
- const airport = findResource(resources, "airports", "airport");
429
- expect(airport.search).toEqual(["code", "name"]);
430
- expect(airport.fields.find((f) => f.name === "code")).toMatchObject({
431
- type: "string",
432
- maxLength: 3,
433
- unique: true,
434
- });
435
- expect(airport.fields.find((f) => f.name === "name")).toMatchObject({
436
- type: "string",
437
- maxLength: 120,
438
- });
439
- });
440
-
441
- it("infers types and defaults from field initializers", () => {
442
- const { resources } = analyzeProject(classesDir);
443
- const airport = findResource(resources, "airports", "airport");
444
- const field = (name: string) => airport.fields.find((f) => f.name === name);
445
- expect(field("region")).toMatchObject({ type: "string", default: "EU" });
446
- expect(field("active")).toMatchObject({ type: "bool", default: true });
447
- expect(field("gates")).toMatchObject({ type: "int", default: 0 });
448
- });
449
-
450
- it("treats a class-typed field as a foreign key", () => {
451
- const { resources } = analyzeProject(classesDir);
452
- const terminal = findResource(resources, "airports", "terminal");
453
- expect(terminal.fields.find((f) => f.name === "airport")).toMatchObject({
454
- type: "fk",
455
- relation: { app: "airports", model: "airport" },
456
- });
457
- expect(terminal.relations).toEqual([
458
- { field: "airport", target: "airports.airport", kind: "fk" },
459
- ]);
460
- expect(terminal.fields.find((f) => f.name === "opened")).toMatchObject({
461
- type: "datetime",
462
- null: true,
463
- });
464
- });
465
-
466
- it("ignores static and private class members", () => {
467
- const { resources } = analyzeProject(classesDir);
468
- const terminal = findResource(resources, "airports", "terminal");
469
- expect(terminal.fields.find((f) => f.name === "kind")).toBeUndefined();
470
- expect(terminal.fields.find((f) => f.name === "secret")).toBeUndefined();
471
- });
472
- });
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
-
524
- describe("interface entities — circular dependencies", () => {
525
- it("reports a breakable cycle (optional self-FK) as a warning", () => {
526
- const { diagnostics } = analyzeProject(cycleDir);
527
- const cycle = diagnostics.find((d) =>
528
- d.message.includes("Circular dependency"),
529
- );
530
- expect(cycle).toBeDefined();
531
- expect(cycle!.severity).toBe("warning");
532
- expect(cycle!.message).toContain("org.employee → org.employee");
533
- expect(cycle!.message).toContain("org.employee.manager");
534
- });
535
-
536
- it("reports an unbreakable cycle (all FKs required) as an error", () => {
537
- const { diagnostics } = analyzeProject(hardCycleDir);
538
- const cycle = diagnostics.find((d) => d.severity === "error");
539
- expect(cycle).toBeDefined();
540
- expect(cycle!.message).toContain("Circular dependency");
541
- expect(cycle!.message).toMatch(/paradox\.(chicken|egg)/);
542
- });
543
-
544
- it("compile() refuses to emit a manifest containing an unbreakable cycle", () => {
545
- expect(() => compile(hardCycleDir, outDir)).toThrow(/Circular dependency/);
546
- });
547
-
548
- it("detectCycles also covers DSL-authored relations", () => {
549
- const resources: ResourceModel[] = [
550
- {
551
- app: "a",
552
- model: "x",
553
- fields: [{ name: "y", type: "fk", relation: { app: "a", model: "y" } }],
554
- relations: [{ field: "y", target: "a.y", kind: "fk" }],
555
- },
556
- {
557
- app: "a",
558
- model: "y",
559
- fields: [{ name: "x", type: "fk", relation: { app: "a", model: "x" } }],
560
- relations: [{ field: "x", target: "a.x", kind: "fk" }],
561
- },
562
- ];
563
- const diagnostics = detectCycles(resources);
564
- expect(diagnostics).toHaveLength(1);
565
- expect(diagnostics[0].severity).toBe("error");
566
- });
567
- });
568
-
569
- describe("interface entities — merging with the resource() DSL", () => {
570
- it("prefers the explicit DSL definition when both define the same app.model", () => {
571
- const { resources } = analyzeProject(mixedDir);
572
- const airports = resources.filter(
573
- (r) => r.app === "mixed" && r.model === "airport",
574
- );
575
- expect(airports).toHaveLength(1);
576
- // The DSL definition (with admin config) won over the inferred interface.
577
- expect(airports[0].admin).toEqual({ listDisplay: ["code"] });
578
- });
579
- });
580
-
581
- describe("interface entities — OpenAPI emission", () => {
582
- it("inferred resources reproduce the /api/{app}/v1/{model}/ contract", () => {
583
- const { resources } = analyzeProject(appDir);
584
- const doc = buildOpenApi(resources);
585
-
586
- expect(doc.paths["/api/flights/v1/flight/"]).toBeDefined();
587
- expect(doc.paths["/api/flights/v1/flight/{id}/"]).toBeDefined();
588
- expect(doc.paths["/api/airports/v1/terminal/"]).toBeDefined();
589
-
590
- // The inferred FK carries the relation extension the admin/codegen follow.
591
- const flightSchema = doc.components.schemas.Flight;
592
- expect(flightSchema.properties.terminal["x-rastack-relation"]).toEqual({
593
- app: "airports",
594
- model: "terminal",
595
- });
596
- });
597
- });