rastack 0.0.22 → 0.0.24

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 (60) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/cache/cache.ts +1 -1
  3. package/cache/revocation.ts +1 -1
  4. package/cache/storage.ts +1 -1
  5. package/cache/types.ts +1 -1
  6. package/define.ts +2 -0
  7. package/dist/compile/analyze.js +3 -3
  8. package/dist/compile/entities.d.ts +68 -0
  9. package/dist/compile/entities.js +496 -0
  10. package/dist/compile/index.d.ts +24 -7
  11. package/dist/compile/index.js +48 -16
  12. package/dist/compile/model.d.ts +2 -2
  13. package/dist/compile/model.js +1 -1
  14. package/dist/compile/program.d.ts +2 -1
  15. package/dist/compile/program.js +8 -3
  16. package/dist/csv-schema.js +1 -1
  17. package/dist/define/db.d.ts +73 -0
  18. package/dist/define/db.js +35 -0
  19. package/dist/define/index.d.ts +8 -8
  20. package/dist/define/index.js +10 -10
  21. package/dist/index.js +1 -1
  22. package/dist/rastack-compile.d.ts +11 -0
  23. package/dist/{rad-compile.js → rastack-compile.js} +22 -12
  24. package/dist/{rad-wasm-build.d.ts → rastack-wasm-build.d.ts} +2 -2
  25. package/dist/{rad-wasm-build.js → rastack-wasm-build.js} +6 -6
  26. package/dist/rastack.d.ts +17 -0
  27. package/dist/{rad.js → rastack.js} +24 -24
  28. package/dist/scan.d.ts +3 -3
  29. package/dist/scan.js +4 -4
  30. package/dist/seed.d.ts +2 -2
  31. package/dist/seed.js +9 -9
  32. package/hooks/real-time/pusher.ts +2 -2
  33. package/package.json +2 -3
  34. package/provider/provider.tsx +12 -12
  35. package/provider/types.ts +16 -16
  36. package/provider/warehouse.ts +1 -1
  37. package/provider/wasm.ts +10 -10
  38. package/src/compile/analyze.ts +3 -3
  39. package/src/compile/entities.ts +602 -0
  40. package/src/compile/index.ts +57 -15
  41. package/src/compile/model.ts +2 -2
  42. package/src/compile/program.ts +8 -3
  43. package/src/csv-schema.ts +1 -1
  44. package/src/define/db.ts +64 -0
  45. package/src/define/index.ts +16 -16
  46. package/src/index.ts +1 -1
  47. package/src/{rad-compile.ts → rastack-compile.ts} +30 -13
  48. package/src/{rad-wasm-build.ts → rastack-wasm-build.ts} +6 -6
  49. package/src/{rad.ts → rastack.ts} +24 -24
  50. package/src/scan.ts +4 -4
  51. package/src/seed.ts +9 -9
  52. package/sync/engine.ts +1 -1
  53. package/sync/types.ts +1 -1
  54. package/test/cache.spec.ts +1 -1
  55. package/test/compile.spec.ts +6 -6
  56. package/test/csv-schema.spec.ts +2 -2
  57. package/test/entities.spec.ts +381 -0
  58. package/types.ts +3 -0
  59. package/dist/rad-compile.d.ts +0 -11
  60. package/dist/rad.d.ts +0 -17
package/src/seed.ts CHANGED
@@ -1,14 +1,14 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  /**
4
- * rad seed
4
+ * rastack seed
5
5
  *
6
6
  * Loads CSV files from a local directory or GCS bucket into the target
7
7
  * Iceberg namespace. Performs schema inference, PII check, and idempotent
8
8
  * table creation before writing rows.
9
9
  *
10
10
  * Usage:
11
- * rad seed [--env dev|staging] [--source gs://bucket/ | --source ./myapp-dev/]
11
+ * rastack seed [--env dev|staging] [--source gs://bucket/ | --source ./myapp-dev/]
12
12
  *
13
13
  * Environment variables (set via CI secrets or .env):
14
14
  * GCP_PROJECT GCP project ID
@@ -52,11 +52,11 @@ function resolveFiles(): string[] {
52
52
  }
53
53
 
54
54
  function downloadFromGcs(gcsUri: string): string[] {
55
- const tmpDir = path.join(repoRoot, ".rad", "tmp", "seed");
55
+ const tmpDir = path.join(repoRoot, ".rastack", "tmp", "seed");
56
56
  fs.mkdirSync(tmpDir, { recursive: true });
57
57
 
58
58
  console.log(`Downloading CSVs from ${gcsUri} ...`);
59
- // `rad seed` is a local/CI developer tool; `gcsUri` is an operator-supplied
59
+ // `rastack seed` is a local/CI developer tool; `gcsUri` is an operator-supplied
60
60
  // --source argument (a gs:// bucket the operator controls), not untrusted
61
61
  // network input, so there is no command-injection surface here.
62
62
  try {
@@ -87,7 +87,7 @@ function validateSchema(csvPath: string): TableSchema {
87
87
  .join("\n");
88
88
  throw new Error(
89
89
  `PII detected in ${path.basename(csvPath)}:\n${list}\n` +
90
- `Remove PII data before seeding. Run "rad scan" to review.`,
90
+ `Remove PII data before seeding. Run "rastack scan" to review.`,
91
91
  );
92
92
  }
93
93
 
@@ -138,7 +138,7 @@ function buildCreateTableSql(schema: TableSchema, namespace: string): string {
138
138
  // Step 4: write schema fingerprints to registry
139
139
  // ---------------------------------------------------------------------------
140
140
  function writeFingerprint(schema: TableSchema): void {
141
- const registryDir = path.join(repoRoot, ".rad", "registry");
141
+ const registryDir = path.join(repoRoot, ".rastack", "registry");
142
142
  fs.mkdirSync(registryDir, { recursive: true });
143
143
  const registryPath = path.join(registryDir, `${schema.tableName}.json`);
144
144
 
@@ -257,18 +257,18 @@ async function main() {
257
257
  }
258
258
 
259
259
  // Emit DDL summary
260
- const ddlDir = path.join(repoRoot, ".rad", "ddl");
260
+ const ddlDir = path.join(repoRoot, ".rastack", "ddl");
261
261
  fs.mkdirSync(ddlDir, { recursive: true });
262
262
  for (const schema of schemas) {
263
263
  const ddl = buildCreateTableSql(schema, env);
264
264
  fs.writeFileSync(path.join(ddlDir, `${schema.tableName}.sql`), ddl + "\n");
265
265
  }
266
266
 
267
- console.log(`\nDDL written to .rad/ddl/`);
267
+ console.log(`\nDDL written to .rastack/ddl/`);
268
268
 
269
269
  // Run Python seed via pyiceberg if available
270
270
  const seedScript = buildPythonSeedScript(schemas, files);
271
- const scriptPath = path.join(repoRoot, ".rad", "tmp", "seed.py");
271
+ const scriptPath = path.join(repoRoot, ".rastack", "tmp", "seed.py");
272
272
  fs.mkdirSync(path.dirname(scriptPath), { recursive: true });
273
273
  fs.writeFileSync(scriptPath, seedScript);
274
274
 
package/sync/engine.ts CHANGED
@@ -58,7 +58,7 @@ export class SyncEngine {
58
58
  this.store = new SyncStore();
59
59
  this.persistence = createPersistence(
60
60
  config.persist !== false,
61
- config.databaseName || "rad-sync",
61
+ config.databaseName || "rastack-sync",
62
62
  );
63
63
  this.queue = new TransactionQueue({
64
64
  api,
package/sync/types.ts CHANGED
@@ -89,6 +89,6 @@ export interface SyncEngineConfig {
89
89
  /** Persist to IndexedDB (default true; falls back to memory when unavailable). */
90
90
  persist?: boolean;
91
91
  /** Database name for IndexedDB. */
92
- databaseName?: string; // default "rad-sync"
92
+ databaseName?: string; // default "rastack-sync"
93
93
  onError?: (error: unknown) => void;
94
94
  }
@@ -41,7 +41,7 @@ class FakeWarehouse implements WarehouseFileSource {
41
41
  `${prefix}/metadata/v${version}.metadata.json`,
42
42
  encoder.encode(
43
43
  JSON.stringify({
44
- format: "rad-iceberg-v1",
44
+ format: "rastack-iceberg-v1",
45
45
  data_files: Object.keys(dataFiles),
46
46
  next_int_id: 99,
47
47
  }),
@@ -12,7 +12,7 @@ import { removeNonModelPaths } from "../src/schema/remove-non-model-paths";
12
12
 
13
13
  /**
14
14
  * The compiler's fixtures are real `.ts` files that import the DSL by its
15
- * package name (`rad-tools/define`); `createProgram` maps that to the in-tree
15
+ * package name (`rastack/define`); `createProgram` maps that to the in-tree
16
16
  * source, so the type checker resolves the `Ref`/`Resource` brands exactly as it
17
17
  * would in a consumer project.
18
18
  */
@@ -21,7 +21,7 @@ const resourcesDir = join(workRoot, "resources");
21
21
  const outDir = join(workRoot, "out");
22
22
 
23
23
  const AIRPORTS_TS = `
24
- import { resource, s } from "rad-tools/define";
24
+ import { resource, s } from "rastack/define";
25
25
 
26
26
  export const Airport = resource("airports", "airport", {
27
27
  code: s.string({ maxLength: 3, unique: true }),
@@ -65,7 +65,7 @@ function findResource(
65
65
  return found;
66
66
  }
67
67
 
68
- describe("rad compile — resource analysis & FK inference", () => {
68
+ describe("rastack compile — resource analysis & FK inference", () => {
69
69
  beforeAll(() => {
70
70
  mkdirSync(resourcesDir, { recursive: true });
71
71
  writeFileSync(join(resourcesDir, "airports.ts"), AIRPORTS_TS);
@@ -141,7 +141,7 @@ describe("rad compile — resource analysis & FK inference", () => {
141
141
  });
142
142
  });
143
143
 
144
- describe("rad compile — OpenAPI emission", () => {
144
+ describe("rastack compile — OpenAPI emission", () => {
145
145
  beforeAll(() => {
146
146
  mkdirSync(resourcesDir, { recursive: true });
147
147
  writeFileSync(join(resourcesDir, "airports.ts"), AIRPORTS_TS);
@@ -151,9 +151,9 @@ describe("rad compile — OpenAPI emission", () => {
151
151
  rmSync(workRoot, { recursive: true, force: true });
152
152
  });
153
153
 
154
- it("writes schema.rad.json and openapi.json", () => {
154
+ it("writes schema.rastack.json and openapi.json", () => {
155
155
  const result = compile(resourcesDir, outDir);
156
- expect(existsSync(join(outDir, "schema.rad.json"))).toBe(true);
156
+ expect(existsSync(join(outDir, "schema.rastack.json"))).toBe(true);
157
157
  expect(existsSync(join(outDir, "openapi.json"))).toBe(true);
158
158
  expect(result.manifest.resources.length).toBe(6);
159
159
  });
@@ -6,7 +6,7 @@ import { parseCsv, inferSchema, discoverCsvFiles } from "../src/csv-schema";
6
6
  const REPO_ROOT = path.join(__dirname, "..");
7
7
 
8
8
  function tmpCsv(content: string): string {
9
- const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rad-test-"));
9
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rastack-test-"));
10
10
  const file = path.join(dir, "test.csv");
11
11
  fs.writeFileSync(file, content);
12
12
  return file;
@@ -128,7 +128,7 @@ describe("inferSchema — PII detection", () => {
128
128
 
129
129
  describe("discoverCsvFiles", () => {
130
130
  it("finds all csv files in a directory", () => {
131
- const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rad-test-"));
131
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rastack-test-"));
132
132
  fs.writeFileSync(path.join(dir, "a.csv"), "id\n1");
133
133
  fs.writeFileSync(path.join(dir, "b.csv"), "id\n2");
134
134
  fs.writeFileSync(path.join(dir, "readme.txt"), "not a csv");
@@ -0,0 +1,381 @@
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 cycleDir = join(workRoot, "cycles");
20
+ const hardCycleDir = join(workRoot, "hard-cycles");
21
+ const mixedDir = join(workRoot, "mixed");
22
+ const outDir = join(workRoot, "out");
23
+
24
+ const AIRPORTS_TS = `
25
+ /** @rastackSearch code,name */
26
+ export interface Airport {
27
+ /**
28
+ * @maxLength 3
29
+ * @unique
30
+ */
31
+ code: string;
32
+ /** @maxLength 120 */
33
+ name: string;
34
+ terminals?: Terminal[]; // reverse side of Terminal.airport — no column
35
+ }
36
+
37
+ export interface Terminal {
38
+ airport: Airport; // FK — inferred from the property type
39
+ /** @maxLength 20 */
40
+ label: string;
41
+ }
42
+
43
+ /** Never consumed by a hook and never referenced — not a resource. */
44
+ export interface Runway {
45
+ heading: number;
46
+ }
47
+ `;
48
+
49
+ const FLIGHTS_TS = `
50
+ import { Terminal } from "./airports";
51
+
52
+ export interface Flight {
53
+ /** @maxLength 8 */
54
+ number: string;
55
+ /** @int */
56
+ seats: number;
57
+ onTime: boolean;
58
+ departure: Date;
59
+ notes?: string;
60
+ /** @default scheduled */
61
+ status: string;
62
+ terminal: Terminal | null; // nullable FK across files
63
+ tags: string[]; // unsupported — skipped with a warning
64
+ }
65
+
66
+ /** @rastackResource
67
+ * @rastackApp bookings
68
+ * @rastackModel booking
69
+ */
70
+ export interface IBookingRecord {
71
+ reference: string;
72
+ }
73
+ `;
74
+
75
+ const PASSENGERS_TS = `
76
+ import {
77
+ DbConfig,
78
+ Default,
79
+ Int,
80
+ MaxLength,
81
+ PrimaryKey,
82
+ Unique,
83
+ UUID,
84
+ } from "rastack/types";
85
+ import { Terminal } from "./airports";
86
+
87
+ /** A reusable column config — "extend DbConfig once, use it everywhere". */
88
+ interface SeatCode extends DbConfig<{ maxLength: 4; unique: true }> {}
89
+
90
+ export interface Passenger {
91
+ id: UUID & PrimaryKey;
92
+ seat: string & SeatCode;
93
+ fullName: string & MaxLength<80>;
94
+ age: Int;
95
+ loyaltyPoints: number & Default<0>;
96
+ status: string & Default<"active">;
97
+ terminal?: Terminal;
98
+ }
99
+
100
+ declare function useEntityList<T>(name: string): T[];
101
+ export const p = useEntityList<Passenger>("passengers");
102
+ `;
103
+
104
+ const SCREENS_TSX = `
105
+ import { Flight } from "./flights";
106
+
107
+ declare function useEntityList<T>(name: string): T[];
108
+
109
+ export function FlightsScreen() {
110
+ // The hook call is what roots the entity graph.
111
+ return useEntityList<Flight>("flights");
112
+ }
113
+ `;
114
+
115
+ const SOFT_CYCLE_TS = `
116
+ export interface Employee {
117
+ name: string;
118
+ manager?: Employee; // optional self-FK — breakable cycle
119
+ }
120
+
121
+ declare function useEntity<T>(name: string): T;
122
+ export const e = useEntity<Employee>("employee");
123
+ `;
124
+
125
+ const HARD_CYCLE_TS = `
126
+ export interface Chicken {
127
+ egg: Egg;
128
+ }
129
+ export interface Egg {
130
+ chicken: Chicken;
131
+ }
132
+
133
+ declare function useEntity<T>(name: string): T;
134
+ export const c = useEntity<Chicken>("chicken");
135
+ `;
136
+
137
+ const MIXED_TS = `
138
+ import { resource, s } from "rastack/define";
139
+
140
+ export const AirportResource = resource("mixed", "airport", {
141
+ code: s.string({ maxLength: 3, unique: true }),
142
+ }, { admin: { listDisplay: ["code"] } });
143
+
144
+ /** @rastackApp mixed */
145
+ export interface Airport {
146
+ code: string;
147
+ }
148
+
149
+ declare function useEntity<T>(name: string): T;
150
+ export const a = useEntity<Airport>("airport");
151
+ `;
152
+
153
+ function findResource(
154
+ resources: ResourceModel[],
155
+ app: string,
156
+ model: string,
157
+ ): ResourceModel {
158
+ const found = resources.find((r) => r.app === app && r.model === model);
159
+ if (!found) throw new Error(`missing ${app}.${model}`);
160
+ return found;
161
+ }
162
+
163
+ beforeAll(() => {
164
+ mkdirSync(appDir, { recursive: true });
165
+ writeFileSync(join(appDir, "airports.ts"), AIRPORTS_TS);
166
+ writeFileSync(join(appDir, "flights.ts"), FLIGHTS_TS);
167
+ writeFileSync(join(appDir, "passengers.ts"), PASSENGERS_TS);
168
+ writeFileSync(join(appDir, "screens.tsx"), SCREENS_TSX);
169
+ mkdirSync(cycleDir, { recursive: true });
170
+ writeFileSync(join(cycleDir, "org.ts"), SOFT_CYCLE_TS);
171
+ mkdirSync(hardCycleDir, { recursive: true });
172
+ writeFileSync(join(hardCycleDir, "paradox.ts"), HARD_CYCLE_TS);
173
+ mkdirSync(mixedDir, { recursive: true });
174
+ writeFileSync(join(mixedDir, "airport.ts"), MIXED_TS);
175
+ });
176
+
177
+ afterAll(() => {
178
+ rmSync(workRoot, { recursive: true, force: true });
179
+ });
180
+
181
+ describe("interface entities — discovery from hooks", () => {
182
+ it("roots the graph at hook type arguments and closes over FK references", () => {
183
+ const { resources } = analyzeProject(appDir);
184
+ const keys = resources.map((r) => `${r.app}.${r.model}`);
185
+
186
+ // Flight is the hook root; Terminal and Airport are pulled in through the
187
+ // FK chain flight → terminal → airport.
188
+ expect(keys).toContain("flights.flight");
189
+ expect(keys).toContain("airports.terminal");
190
+ expect(keys).toContain("airports.airport");
191
+
192
+ // Declared but neither hooked nor referenced — not a resource.
193
+ expect(keys).not.toContain("airports.runway");
194
+ });
195
+
196
+ it("includes @rastackResource interfaces and honours @rastackApp/@rastackModel + I-prefix stripping", () => {
197
+ const { resources } = analyzeProject(appDir);
198
+ const booking = findResource(resources, "bookings", "booking");
199
+ expect(booking.fields).toEqual([{ name: "reference", type: "string" }]);
200
+ });
201
+
202
+ it("names entities from the interface and the defining file", () => {
203
+ const { resources } = analyzeProject(appDir);
204
+ // airports.ts → app "airports"; interface Airport → model "airport".
205
+ const airport = findResource(resources, "airports", "airport");
206
+ expect(airport.search).toEqual(["code", "name"]);
207
+ });
208
+ });
209
+
210
+ describe("interface entities — datatypes from the type system", () => {
211
+ it("maps property types to manifest scalars with JSDoc refinements", () => {
212
+ const { resources } = analyzeProject(appDir);
213
+ const flight = findResource(resources, "flights", "flight");
214
+ const field = (name: string) => flight.fields.find((f) => f.name === name);
215
+
216
+ expect(field("number")).toMatchObject({ type: "string", maxLength: 8 });
217
+ expect(field("seats")).toMatchObject({ type: "int" }); // number + @int
218
+ expect(field("onTime")).toMatchObject({ type: "bool" });
219
+ expect(field("departure")).toMatchObject({ type: "datetime" });
220
+ expect(field("notes")).toMatchObject({ type: "string", null: true });
221
+ expect(field("status")).toMatchObject({ type: "string", default: "scheduled" });
222
+ });
223
+
224
+ it("reads constraint tags (@maxLength/@unique) off interface properties", () => {
225
+ const { resources } = analyzeProject(appDir);
226
+ const airport = findResource(resources, "airports", "airport");
227
+ expect(airport.fields.find((f) => f.name === "code")).toMatchObject({
228
+ type: "string",
229
+ maxLength: 3,
230
+ unique: true,
231
+ });
232
+ });
233
+
234
+ it("reads column config from DbConfig brand types", () => {
235
+ const { resources } = analyzeProject(appDir);
236
+ const passenger = findResource(resources, "passengers", "passenger");
237
+ const field = (name: string) => passenger.fields.find((f) => f.name === name);
238
+
239
+ expect(field("id")).toMatchObject({ type: "uuid", primaryKey: true });
240
+ expect(field("fullName")).toMatchObject({ type: "string", maxLength: 80 });
241
+ expect(field("age")).toMatchObject({ type: "int" });
242
+ expect(field("loyaltyPoints")).toMatchObject({ type: "float", default: 0 });
243
+ expect(field("status")).toMatchObject({ type: "string", default: "active" });
244
+ expect(field("terminal")).toMatchObject({
245
+ type: "fk",
246
+ null: true,
247
+ relation: { app: "airports", model: "terminal" },
248
+ });
249
+ });
250
+
251
+ it("supports reusable configs that extend DbConfig", () => {
252
+ const { resources } = analyzeProject(appDir);
253
+ const passenger = findResource(resources, "passengers", "passenger");
254
+ expect(passenger.fields.find((f) => f.name === "seat")).toMatchObject({
255
+ type: "string",
256
+ maxLength: 4,
257
+ unique: true,
258
+ });
259
+
260
+ // The brand interface itself is configuration, not an entity.
261
+ expect(
262
+ resources.find((r) => r.model.toLowerCase().includes("seatcode")),
263
+ ).toBeUndefined();
264
+ });
265
+
266
+ it("skips unsupported property types with a warning", () => {
267
+ const { resources, diagnostics } = analyzeProject(appDir);
268
+ const flight = findResource(resources, "flights", "flight");
269
+ expect(flight.fields.find((f) => f.name === "tags")).toBeUndefined();
270
+ expect(
271
+ diagnostics.some(
272
+ (d) => d.severity === "warning" && d.message.includes("flights.flight.tags"),
273
+ ),
274
+ ).toBe(true);
275
+ });
276
+ });
277
+
278
+ describe("interface entities — FK inference", () => {
279
+ it("treats an interface-typed property as a foreign key", () => {
280
+ const { resources } = analyzeProject(appDir);
281
+ const terminal = findResource(resources, "airports", "terminal");
282
+
283
+ expect(terminal.fields.find((f) => f.name === "airport")).toMatchObject({
284
+ type: "fk",
285
+ relation: { app: "airports", model: "airport" },
286
+ });
287
+ expect(terminal.relations).toEqual([
288
+ { field: "airport", target: "airports.airport", kind: "fk" },
289
+ ]);
290
+ });
291
+
292
+ it("maps `Entity | null` to a nullable foreign key", () => {
293
+ const { resources } = analyzeProject(appDir);
294
+ const flight = findResource(resources, "flights", "flight");
295
+ expect(flight.fields.find((f) => f.name === "terminal")).toMatchObject({
296
+ type: "fk",
297
+ null: true,
298
+ relation: { app: "airports", model: "terminal" },
299
+ });
300
+ });
301
+
302
+ it("ignores `Entity[]` arrays — the reverse side of the FK", () => {
303
+ const { resources } = analyzeProject(appDir);
304
+ const airport = findResource(resources, "airports", "airport");
305
+ expect(airport.fields.find((f) => f.name === "terminals")).toBeUndefined();
306
+ expect(airport.relations).toEqual([]);
307
+ });
308
+ });
309
+
310
+ describe("interface entities — circular dependencies", () => {
311
+ it("reports a breakable cycle (optional self-FK) as a warning", () => {
312
+ const { diagnostics } = analyzeProject(cycleDir);
313
+ const cycle = diagnostics.find((d) => d.message.includes("Circular dependency"));
314
+ expect(cycle).toBeDefined();
315
+ expect(cycle!.severity).toBe("warning");
316
+ expect(cycle!.message).toContain("org.employee → org.employee");
317
+ expect(cycle!.message).toContain("org.employee.manager");
318
+ });
319
+
320
+ it("reports an unbreakable cycle (all FKs required) as an error", () => {
321
+ const { diagnostics } = analyzeProject(hardCycleDir);
322
+ const cycle = diagnostics.find((d) => d.severity === "error");
323
+ expect(cycle).toBeDefined();
324
+ expect(cycle!.message).toContain("Circular dependency");
325
+ expect(cycle!.message).toMatch(/paradox\.(chicken|egg)/);
326
+ });
327
+
328
+ it("compile() refuses to emit a manifest containing an unbreakable cycle", () => {
329
+ expect(() => compile(hardCycleDir, outDir)).toThrow(/Circular dependency/);
330
+ });
331
+
332
+ it("detectCycles also covers DSL-authored relations", () => {
333
+ const resources: ResourceModel[] = [
334
+ {
335
+ app: "a",
336
+ model: "x",
337
+ fields: [{ name: "y", type: "fk", relation: { app: "a", model: "y" } }],
338
+ relations: [{ field: "y", target: "a.y", kind: "fk" }],
339
+ },
340
+ {
341
+ app: "a",
342
+ model: "y",
343
+ fields: [{ name: "x", type: "fk", relation: { app: "a", model: "x" } }],
344
+ relations: [{ field: "x", target: "a.x", kind: "fk" }],
345
+ },
346
+ ];
347
+ const diagnostics = detectCycles(resources);
348
+ expect(diagnostics).toHaveLength(1);
349
+ expect(diagnostics[0].severity).toBe("error");
350
+ });
351
+ });
352
+
353
+ describe("interface entities — merging with the resource() DSL", () => {
354
+ it("prefers the explicit DSL definition when both define the same app.model", () => {
355
+ const { resources } = analyzeProject(mixedDir);
356
+ const airports = resources.filter(
357
+ (r) => r.app === "mixed" && r.model === "airport",
358
+ );
359
+ expect(airports).toHaveLength(1);
360
+ // The DSL definition (with admin config) won over the inferred interface.
361
+ expect(airports[0].admin).toEqual({ listDisplay: ["code"] });
362
+ });
363
+ });
364
+
365
+ describe("interface entities — OpenAPI emission", () => {
366
+ it("inferred resources reproduce the /api/{app}/v1/{model}/ contract", () => {
367
+ const { resources } = analyzeProject(appDir);
368
+ const doc = buildOpenApi(resources);
369
+
370
+ expect(doc.paths["/api/flights/v1/flight/"]).toBeDefined();
371
+ expect(doc.paths["/api/flights/v1/flight/{id}/"]).toBeDefined();
372
+ expect(doc.paths["/api/airports/v1/terminal/"]).toBeDefined();
373
+
374
+ // The inferred FK carries the relation extension the admin/codegen follow.
375
+ const flightSchema = doc.components.schemas.Flight;
376
+ expect(flightSchema.properties.terminal["x-rastack-relation"]).toEqual({
377
+ app: "airports",
378
+ model: "terminal",
379
+ });
380
+ });
381
+ });
package/types.ts CHANGED
@@ -1,3 +1,6 @@
1
+ /** `rastack/types` — type-level column config for interface entities. */
2
+ export * from "./src/define/db";
3
+
1
4
  type Primitive = string | number | boolean | null | undefined;
2
5
 
3
6
  type FlattenedKeys<T> = T extends Primitive
@@ -1,11 +0,0 @@
1
- #!/usr/bin/env node
2
- /**
3
- * `rad compile | check | list | urls` — the TypeScript-first replacement for the
4
- * old Django `manage.py rastack` command.
5
- *
6
- * rad compile [resourcesDir] [outDir] defs → schema.rad.json + openapi.json
7
- * rad check [resourcesDir] validate the resource graph (exit != 0 on error)
8
- * rad list [resourcesDir] print every resource + its fields/relations
9
- * rad urls [resourcesDir] print the generated /api/{app}/v1/{model}/ routes
10
- */
11
- export {};
package/dist/rad.d.ts DELETED
@@ -1,17 +0,0 @@
1
- #!/usr/bin/env node
2
- /**
3
- * rad — unified CLI for React API Stack
4
- *
5
- * Commands:
6
- * rad compile [resourcesDir] [outDir] TypeScript resources → schema.rad.json + openapi.json
7
- * rad check [resourcesDir] Validate the resource graph
8
- * rad list [resourcesDir] Print resources, fields and relations
9
- * rad urls [resourcesDir] Print the generated /api/{app}/v1/{model}/ routes
10
- * rad generate [--schema-only] Generate typed hooks from OpenAPI
11
- * rad serve [--warehouse dir] Run the Rust API over an Iceberg warehouse
12
- * rad admin [--warehouse dir] Run the Rust admin (Django-admin-style DB browser)
13
- * rad wasm Compile the API to WebAssembly (in-browser local dev)
14
- * rad scan [files...] [--fail-on-pii] Scan CSVs for PII and schema info
15
- * rad seed [--env dev] [--source gs://] Load CSVs into Iceberg namespace
16
- */
17
- export {};