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.
- package/CHANGELOG.md +9 -0
- package/cache/cache.ts +1 -1
- package/cache/revocation.ts +1 -1
- package/cache/storage.ts +1 -1
- package/cache/types.ts +1 -1
- package/define.ts +2 -0
- package/dist/compile/analyze.js +3 -3
- package/dist/compile/entities.d.ts +68 -0
- package/dist/compile/entities.js +496 -0
- package/dist/compile/index.d.ts +24 -7
- package/dist/compile/index.js +48 -16
- package/dist/compile/model.d.ts +2 -2
- package/dist/compile/model.js +1 -1
- package/dist/compile/program.d.ts +2 -1
- package/dist/compile/program.js +8 -3
- package/dist/csv-schema.js +1 -1
- package/dist/define/db.d.ts +73 -0
- package/dist/define/db.js +35 -0
- package/dist/define/index.d.ts +8 -8
- package/dist/define/index.js +10 -10
- package/dist/index.js +1 -1
- package/dist/rastack-compile.d.ts +11 -0
- package/dist/{rad-compile.js → rastack-compile.js} +22 -12
- package/dist/{rad-wasm-build.d.ts → rastack-wasm-build.d.ts} +2 -2
- package/dist/{rad-wasm-build.js → rastack-wasm-build.js} +6 -6
- package/dist/rastack.d.ts +17 -0
- package/dist/{rad.js → rastack.js} +24 -24
- package/dist/scan.d.ts +3 -3
- package/dist/scan.js +4 -4
- package/dist/seed.d.ts +2 -2
- package/dist/seed.js +9 -9
- package/hooks/real-time/pusher.ts +2 -2
- package/package.json +2 -3
- package/provider/provider.tsx +12 -12
- package/provider/types.ts +16 -16
- package/provider/warehouse.ts +1 -1
- package/provider/wasm.ts +10 -10
- package/src/compile/analyze.ts +3 -3
- package/src/compile/entities.ts +602 -0
- package/src/compile/index.ts +57 -15
- package/src/compile/model.ts +2 -2
- package/src/compile/program.ts +8 -3
- package/src/csv-schema.ts +1 -1
- package/src/define/db.ts +64 -0
- package/src/define/index.ts +16 -16
- package/src/index.ts +1 -1
- package/src/{rad-compile.ts → rastack-compile.ts} +30 -13
- package/src/{rad-wasm-build.ts → rastack-wasm-build.ts} +6 -6
- package/src/{rad.ts → rastack.ts} +24 -24
- package/src/scan.ts +4 -4
- package/src/seed.ts +9 -9
- package/sync/engine.ts +1 -1
- package/sync/types.ts +1 -1
- package/test/cache.spec.ts +1 -1
- package/test/compile.spec.ts +6 -6
- package/test/csv-schema.spec.ts +2 -2
- package/test/entities.spec.ts +381 -0
- package/types.ts +3 -0
- package/dist/rad-compile.d.ts +0 -11
- 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
|
-
*
|
|
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
|
-
*
|
|
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, ".
|
|
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
|
-
// `
|
|
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 "
|
|
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, ".
|
|
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, ".
|
|
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 .
|
|
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, ".
|
|
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 || "
|
|
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 "
|
|
92
|
+
databaseName?: string; // default "rastack-sync"
|
|
93
93
|
onError?: (error: unknown) => void;
|
|
94
94
|
}
|
package/test/cache.spec.ts
CHANGED
|
@@ -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: "
|
|
44
|
+
format: "rastack-iceberg-v1",
|
|
45
45
|
data_files: Object.keys(dataFiles),
|
|
46
46
|
next_int_id: 99,
|
|
47
47
|
}),
|
package/test/compile.spec.ts
CHANGED
|
@@ -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 (`
|
|
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 "
|
|
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("
|
|
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("
|
|
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.
|
|
154
|
+
it("writes schema.rastack.json and openapi.json", () => {
|
|
155
155
|
const result = compile(resourcesDir, outDir);
|
|
156
|
-
expect(existsSync(join(outDir, "schema.
|
|
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
|
});
|
package/test/csv-schema.spec.ts
CHANGED
|
@@ -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(), "
|
|
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(), "
|
|
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
package/dist/rad-compile.d.ts
DELETED
|
@@ -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 {};
|