rastack 0.0.47 → 0.0.49
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 +4 -0
- package/dist/compile/entities.d.ts +0 -49
- package/dist/compile/entities.js +110 -22
- package/dist/compile/index.d.ts +1 -1
- package/dist/compile/index.js +1 -1
- package/dist/compile/program.js +1 -1
- package/dist/define/db.d.ts +1 -1
- package/dist/define/db.js +1 -1
- package/dist/rastack-admin.d.ts +14 -7
- package/dist/rastack-admin.js +85 -40
- package/dist/wasm/rastack_wasm_bg.wasm +0 -0
- package/package.json +1 -1
- package/src/compile/entities.ts +161 -59
- package/src/compile/index.ts +1 -1
- package/src/compile/program.ts +1 -1
- package/src/define/db.ts +1 -1
- package/src/rastack-admin.ts +105 -46
- package/test/entities.spec.ts +99 -0
- package/wasm/rastack_wasm_bg.wasm +0 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines.
|
|
4
4
|
|
|
5
|
+
### [0.0.49](https://github.com/theserverkid/reactapistack/compare/v0.0.48...v0.0.49) (2026-07-14)
|
|
6
|
+
|
|
7
|
+
### [0.0.48](https://github.com/theserverkid/reactapistack/compare/v0.0.47...v0.0.48) (2026-07-13)
|
|
8
|
+
|
|
5
9
|
### [0.0.47](https://github.com/theserverkid/reactapistack/compare/v0.0.46...v0.0.47) (2026-07-13)
|
|
6
10
|
|
|
7
11
|
### [0.0.46](https://github.com/theserverkid/reactapistack/compare/v0.0.45...v0.0.46) (2026-07-13)
|
|
@@ -1,54 +1,5 @@
|
|
|
1
1
|
import * as ts from "typescript";
|
|
2
2
|
import { ResourceModel } from "./model";
|
|
3
|
-
/**
|
|
4
|
-
* Interface-driven resource inference — "the entities in the app are a list of
|
|
5
|
-
* interfaces".
|
|
6
|
-
*
|
|
7
|
-
* Instead of (or alongside) the `resource()` DSL, an app can describe its data
|
|
8
|
-
* as plain TypeScript interfaces and consume them through typed hooks. The
|
|
9
|
-
* analyser works the resource graph out from the *usage*:
|
|
10
|
-
*
|
|
11
|
-
* 1. **Roots come from the hooks.** Every hook call that carries a type
|
|
12
|
-
* argument — `useEntityList<Airport>(…)`, `useQuery<Terminal>(…)` — marks
|
|
13
|
-
* the interface behind that type argument as an entity. An interface can
|
|
14
|
-
* also opt in explicitly with a `@rastackResource` JSDoc tag.
|
|
15
|
-
* 2. **Datatypes come from the interfaces.** Each property's TypeScript type
|
|
16
|
-
* maps to a manifest scalar (`string` → string, `number` → float,
|
|
17
|
-
* `boolean` → bool, `Date` → datetime). Constraints are carried in the
|
|
18
|
-
* type system with `DbConfig` brands from `rastack/types` —
|
|
19
|
-
* `code: string & Unique & MaxLength<3>`, `age: Int` — or, equivalently,
|
|
20
|
-
* as JSDoc tags (`@unique`, `@maxLength 3`, `@int`, `@uuid`, `@default`).
|
|
21
|
-
* Optional / `| null` properties become nullable fields.
|
|
22
|
-
* 3. **Foreign keys come from the type system.** A property whose type is
|
|
23
|
-
* another entity interface is a foreign key, and the referenced interface
|
|
24
|
-
* is pulled into the resource graph transitively — the closure of the
|
|
25
|
-
* hook-rooted interfaces is the app's resource set.
|
|
26
|
-
* 4. **Circular dependencies are detected.** The FK graph is walked for
|
|
27
|
-
* cycles; a cycle is an *error* when every edge in it is required (no row
|
|
28
|
-
* could ever be inserted) and a *warning* when it is breakable through a
|
|
29
|
-
* nullable field (e.g. an optional self-referencing `manager`).
|
|
30
|
-
*
|
|
31
|
-
* ```ts
|
|
32
|
-
* // entities/airports.ts
|
|
33
|
-
* export interface Airport {
|
|
34
|
-
* code: string; // JSDoc tags refine: @maxLength 3, @unique
|
|
35
|
-
* name: string;
|
|
36
|
-
* }
|
|
37
|
-
* export interface Terminal {
|
|
38
|
-
* airport: Airport; // FK — inferred from the property type
|
|
39
|
-
* label: string;
|
|
40
|
-
* }
|
|
41
|
-
*
|
|
42
|
-
* // app/screen.tsx — the hook call is what roots the graph
|
|
43
|
-
* const terminals = useEntityList<Terminal>("terminals");
|
|
44
|
-
* // → resources: airports.terminal (root) + airports.airport (transitive)
|
|
45
|
-
* ```
|
|
46
|
-
*
|
|
47
|
-
* Naming: `model` is the lower-camel interface name (a leading `I` prefix as
|
|
48
|
-
* in generated `IFlight` interfaces is stripped, so hook usage of generated
|
|
49
|
-
* types round-trips onto the same resource); `app` defaults to the defining
|
|
50
|
-
* file's basename. Both are overridable with `@rastackApp` / `@rastackModel` tags.
|
|
51
|
-
*/
|
|
52
3
|
export interface EntityDiagnostic {
|
|
53
4
|
severity: "error" | "warning";
|
|
54
5
|
message: string;
|
package/dist/compile/entities.js
CHANGED
|
@@ -43,17 +43,20 @@ function analyzeEntities(program, fileNames) {
|
|
|
43
43
|
const diagnostics = [];
|
|
44
44
|
const sourceFiles = (0, program_1.resourceSourceFiles)(program, fileNames);
|
|
45
45
|
const scanned = new Set(sourceFiles.map((sf) => path.resolve(sf.fileName)));
|
|
46
|
-
// Every interface declared in the scanned files is a *candidate* entity.
|
|
46
|
+
// Every class/interface declared in the scanned files is a *candidate* entity.
|
|
47
47
|
const candidates = new Set();
|
|
48
48
|
for (const sf of sourceFiles) {
|
|
49
49
|
ts.forEachChild(sf, function walk(node) {
|
|
50
50
|
if (ts.isInterfaceDeclaration(node))
|
|
51
51
|
candidates.add(node);
|
|
52
|
+
if (ts.isClassDeclaration(node) && node.name)
|
|
53
|
+
candidates.add(node);
|
|
52
54
|
ts.forEachChild(node, walk);
|
|
53
55
|
});
|
|
54
56
|
}
|
|
55
|
-
// 1. Roots:
|
|
56
|
-
// `@rastackResource` opt-ins
|
|
57
|
+
// 1. Roots: declarations used as type arguments of hook calls, explicit
|
|
58
|
+
// `@rastackResource` opt-ins, and plain data classes (declaring one *is*
|
|
59
|
+
// the opt-in).
|
|
57
60
|
const roots = [];
|
|
58
61
|
const rooted = new Set();
|
|
59
62
|
const addRoot = (decl) => {
|
|
@@ -66,7 +69,7 @@ function analyzeEntities(program, fileNames) {
|
|
|
66
69
|
ts.forEachChild(sf, function walk(node) {
|
|
67
70
|
if (ts.isCallExpression(node) && isHookCall(node)) {
|
|
68
71
|
for (const typeArg of node.typeArguments ?? []) {
|
|
69
|
-
const decl =
|
|
72
|
+
const decl = entityBehindTypeNode(typeArg, checker, scanned);
|
|
70
73
|
if (decl && candidates.has(decl))
|
|
71
74
|
addRoot(decl);
|
|
72
75
|
}
|
|
@@ -77,9 +80,11 @@ function analyzeEntities(program, fileNames) {
|
|
|
77
80
|
for (const decl of candidates) {
|
|
78
81
|
if (jsdocTags(decl).has("rastackresource"))
|
|
79
82
|
addRoot(decl);
|
|
83
|
+
else if (ts.isClassDeclaration(decl) && isDataClass(decl))
|
|
84
|
+
addRoot(decl);
|
|
80
85
|
}
|
|
81
|
-
// 2. Transitive closure over FK references: an entity's
|
|
82
|
-
// property pulls the referenced
|
|
86
|
+
// 2. Transitive closure over FK references: an entity's entity-typed
|
|
87
|
+
// property pulls the referenced declaration in as an entity too.
|
|
83
88
|
const propsByDecl = new Map();
|
|
84
89
|
const analyzed = (decl) => {
|
|
85
90
|
let props = propsByDecl.get(decl);
|
|
@@ -166,36 +171,93 @@ function isHookCall(call) {
|
|
|
166
171
|
: undefined;
|
|
167
172
|
return !!name && /^use[A-Z0-9_]/.test(name);
|
|
168
173
|
}
|
|
169
|
-
/** Resolve a type node to
|
|
170
|
-
function
|
|
171
|
-
return
|
|
174
|
+
/** Resolve a type node to a class/interface declared in a scanned file. */
|
|
175
|
+
function entityBehindTypeNode(node, checker, scanned) {
|
|
176
|
+
return entityBehindType(checker.getTypeFromTypeNode(node), scanned);
|
|
172
177
|
}
|
|
173
|
-
function
|
|
178
|
+
function entityBehindType(type, scanned) {
|
|
174
179
|
const symbol = type.aliasSymbol ?? type.getSymbol();
|
|
175
180
|
for (const decl of symbol?.declarations ?? []) {
|
|
176
|
-
if (ts.isInterfaceDeclaration(decl)
|
|
181
|
+
if ((ts.isInterfaceDeclaration(decl) ||
|
|
182
|
+
(ts.isClassDeclaration(decl) && decl.name)) &&
|
|
177
183
|
scanned.has(path.resolve(decl.getSourceFile().fileName))) {
|
|
178
184
|
return decl;
|
|
179
185
|
}
|
|
180
186
|
}
|
|
181
187
|
return undefined;
|
|
182
188
|
}
|
|
189
|
+
/**
|
|
190
|
+
* A *plain data class* — the class-authored equivalent of a `resource()` call,
|
|
191
|
+
* so it roots itself. It must carry no heritage clause (a subclass of
|
|
192
|
+
* `React.Component` or a service base is app code, not a table), not be
|
|
193
|
+
* abstract, contain nothing but property declarations, and have at least one
|
|
194
|
+
* instance property. Anything richer can still opt in via a hook or the
|
|
195
|
+
* `@rastackResource` tag.
|
|
196
|
+
*/
|
|
197
|
+
function isDataClass(decl) {
|
|
198
|
+
if (decl.heritageClauses?.length)
|
|
199
|
+
return false;
|
|
200
|
+
if (ts.getCombinedModifierFlags(decl) &
|
|
201
|
+
(ts.ModifierFlags.Abstract | ts.ModifierFlags.Ambient)) {
|
|
202
|
+
return false;
|
|
203
|
+
}
|
|
204
|
+
let properties = 0;
|
|
205
|
+
for (const member of decl.members) {
|
|
206
|
+
if (!ts.isPropertyDeclaration(member))
|
|
207
|
+
return false; // method/ctor/accessor
|
|
208
|
+
if (ts.getCombinedModifierFlags(member) & ts.ModifierFlags.Static)
|
|
209
|
+
continue;
|
|
210
|
+
properties += 1;
|
|
211
|
+
}
|
|
212
|
+
return properties > 0;
|
|
213
|
+
}
|
|
183
214
|
function analyzeProperties(decl, checker, scanned) {
|
|
184
215
|
const props = [];
|
|
185
216
|
for (const member of decl.members) {
|
|
186
|
-
|
|
217
|
+
let prop;
|
|
218
|
+
if (ts.isPropertySignature(member) && member.type) {
|
|
219
|
+
prop = member;
|
|
220
|
+
}
|
|
221
|
+
else if (ts.isPropertyDeclaration(member) &&
|
|
222
|
+
(member.type || member.initializer) &&
|
|
223
|
+
!ts.isPrivateIdentifier(member.name) &&
|
|
224
|
+
!(ts.getCombinedModifierFlags(member) &
|
|
225
|
+
(ts.ModifierFlags.Static |
|
|
226
|
+
ts.ModifierFlags.Private |
|
|
227
|
+
ts.ModifierFlags.Protected))) {
|
|
228
|
+
prop = member;
|
|
229
|
+
}
|
|
230
|
+
if (!prop)
|
|
187
231
|
continue;
|
|
188
|
-
const name = ts.isIdentifier(
|
|
189
|
-
?
|
|
232
|
+
const name = ts.isIdentifier(prop.name) || ts.isStringLiteralLike(prop.name)
|
|
233
|
+
? prop.name.text
|
|
190
234
|
: undefined;
|
|
191
235
|
if (!name)
|
|
192
236
|
continue;
|
|
193
|
-
props.push(analyzeProperty(name,
|
|
237
|
+
props.push(analyzeProperty(name, prop, checker, scanned));
|
|
194
238
|
}
|
|
195
239
|
return props;
|
|
196
240
|
}
|
|
197
241
|
function analyzeProperty(name, member, checker, scanned) {
|
|
198
242
|
let nullable = !!member.questionToken;
|
|
243
|
+
// A class field with no annotation infers its (widened) type from the
|
|
244
|
+
// initializer — `region = "EU"` is a string column defaulting to "EU".
|
|
245
|
+
if (!member.type) {
|
|
246
|
+
const type = checker.getTypeAtLocation(member);
|
|
247
|
+
const base = classifyType(type, scanned);
|
|
248
|
+
if (base === undefined) {
|
|
249
|
+
return { name, nullable, skip: "unsupported type — skipped" };
|
|
250
|
+
}
|
|
251
|
+
if (typeof base !== "string")
|
|
252
|
+
return { name, nullable, target: base };
|
|
253
|
+
const config = configFromTags(jsdocTags(member));
|
|
254
|
+
if (config.default === undefined) {
|
|
255
|
+
const dflt = initializerDefault(member);
|
|
256
|
+
if (dflt !== undefined)
|
|
257
|
+
config.default = dflt;
|
|
258
|
+
}
|
|
259
|
+
return scalarProp(name, base, config, nullable);
|
|
260
|
+
}
|
|
199
261
|
// Unwrap parens and unions: strip null/undefined (→ nullable), and require a
|
|
200
262
|
// single remaining arm (unions of string literals collapse to `string`).
|
|
201
263
|
let typeNode = unwrapParens(member.type);
|
|
@@ -219,7 +281,7 @@ function analyzeProperty(name, member, checker, scanned) {
|
|
|
219
281
|
// but is intentionally not an error. Arrays of scalars have no manifest shape.
|
|
220
282
|
const element = arrayElement(typeNode);
|
|
221
283
|
if (element) {
|
|
222
|
-
if (
|
|
284
|
+
if (entityBehindTypeNode(element, checker, scanned)) {
|
|
223
285
|
return { name, nullable, inverse: true };
|
|
224
286
|
}
|
|
225
287
|
return { name, nullable, skip: "array fields are not supported — skipped" };
|
|
@@ -230,19 +292,44 @@ function analyzeProperty(name, member, checker, scanned) {
|
|
|
230
292
|
return { name, nullable, skip: "unsupported type — skipped" };
|
|
231
293
|
}
|
|
232
294
|
if (typeof base !== "string") {
|
|
233
|
-
return { name, nullable, target: base }; // entity interface → FK
|
|
295
|
+
return { name, nullable, target: base }; // entity class/interface → FK
|
|
234
296
|
}
|
|
235
297
|
const config = {
|
|
236
298
|
...configFromTags(jsdocTags(member)),
|
|
237
299
|
...configFromType(type, checker, member),
|
|
238
300
|
};
|
|
301
|
+
if (config.default === undefined) {
|
|
302
|
+
const dflt = initializerDefault(member);
|
|
303
|
+
if (dflt !== undefined)
|
|
304
|
+
config.default = dflt;
|
|
305
|
+
}
|
|
239
306
|
return scalarProp(name, base, config, nullable);
|
|
240
307
|
}
|
|
308
|
+
/** The literal value of a class field's initializer, used as the default. */
|
|
309
|
+
function initializerDefault(member) {
|
|
310
|
+
if (!ts.isPropertyDeclaration(member) || !member.initializer)
|
|
311
|
+
return undefined;
|
|
312
|
+
const init = member.initializer;
|
|
313
|
+
if (ts.isStringLiteralLike(init))
|
|
314
|
+
return init.text;
|
|
315
|
+
if (ts.isNumericLiteral(init))
|
|
316
|
+
return Number(init.text);
|
|
317
|
+
if (ts.isPrefixUnaryExpression(init) &&
|
|
318
|
+
init.operator === ts.SyntaxKind.MinusToken &&
|
|
319
|
+
ts.isNumericLiteral(init.operand)) {
|
|
320
|
+
return -Number(init.operand.text);
|
|
321
|
+
}
|
|
322
|
+
if (init.kind === ts.SyntaxKind.TrueKeyword)
|
|
323
|
+
return true;
|
|
324
|
+
if (init.kind === ts.SyntaxKind.FalseKeyword)
|
|
325
|
+
return false;
|
|
326
|
+
return undefined;
|
|
327
|
+
}
|
|
241
328
|
/**
|
|
242
|
-
* What a property type *is*: a manifest scalar, an entity interface
|
|
243
|
-
* unsupported. Intersections classify by their first classifiable
|
|
244
|
-
* which is what lets `DbConfig` brands ride along — `string &
|
|
245
|
-
* string; the brand carries configuration, not a datatype.
|
|
329
|
+
* What a property type *is*: a manifest scalar, an entity class/interface
|
|
330
|
+
* (→ FK), or unsupported. Intersections classify by their first classifiable
|
|
331
|
+
* constituent, which is what lets `DbConfig` brands ride along — `string &
|
|
332
|
+
* Unique` is a string; the brand carries configuration, not a datatype.
|
|
246
333
|
*/
|
|
247
334
|
function classifyType(type, scanned) {
|
|
248
335
|
if (type.flags & ts.TypeFlags.StringLike)
|
|
@@ -261,7 +348,7 @@ function classifyType(type, scanned) {
|
|
|
261
348
|
}
|
|
262
349
|
if (type.getProperty("__rastackDb"))
|
|
263
350
|
return undefined; // a bare config brand
|
|
264
|
-
const decl =
|
|
351
|
+
const decl = entityBehindType(type, scanned);
|
|
265
352
|
if (decl)
|
|
266
353
|
return decl;
|
|
267
354
|
if (type.getSymbol()?.getName() === "Date")
|
|
@@ -390,6 +477,7 @@ function arrayElement(node) {
|
|
|
390
477
|
}
|
|
391
478
|
function entityNaming(decl) {
|
|
392
479
|
const tags = jsdocTags(decl);
|
|
480
|
+
// Class candidates are only ever collected with a name (see analyzeEntities).
|
|
393
481
|
const raw = decl.name.text;
|
|
394
482
|
// Generated entity interfaces are `I`-prefixed (`IFlight`); strip the prefix
|
|
395
483
|
// so hook usage of generated types resolves to the same resource.
|
package/dist/compile/index.d.ts
CHANGED
|
@@ -19,7 +19,7 @@ export interface CompileResult {
|
|
|
19
19
|
* beside `app/airports/index.tsx` — instead of in one top-level `resources/`
|
|
20
20
|
* folder. `.tsx` files carry no `resource(...)` definitions but are scanned
|
|
21
21
|
* for the typed hook calls (`useEntityList<Airport>(…)`) that root the
|
|
22
|
-
* interface
|
|
22
|
+
* class/interface entity graph; co-located helpers are harmless.
|
|
23
23
|
*/
|
|
24
24
|
export declare function resolveResourceFiles(input: string): string[];
|
|
25
25
|
export interface ProjectAnalysis {
|
package/dist/compile/index.js
CHANGED
|
@@ -77,7 +77,7 @@ const IGNORE_DIRS = new Set([
|
|
|
77
77
|
* beside `app/airports/index.tsx` — instead of in one top-level `resources/`
|
|
78
78
|
* folder. `.tsx` files carry no `resource(...)` definitions but are scanned
|
|
79
79
|
* for the typed hook calls (`useEntityList<Airport>(…)`) that root the
|
|
80
|
-
* interface
|
|
80
|
+
* class/interface entity graph; co-located helpers are harmless.
|
|
81
81
|
*/
|
|
82
82
|
function resolveResourceFiles(input) {
|
|
83
83
|
const stat = fs.existsSync(input) ? fs.statSync(input) : undefined;
|
package/dist/compile/program.js
CHANGED
|
@@ -55,7 +55,7 @@ function createProgram(fileNames) {
|
|
|
55
55
|
esModuleInterop: true,
|
|
56
56
|
noEmit: true,
|
|
57
57
|
// `.tsx` app files are scanned for the typed hook calls that root the
|
|
58
|
-
// interface
|
|
58
|
+
// class/interface entity graph.
|
|
59
59
|
jsx: ts.JsxEmit.ReactJSX,
|
|
60
60
|
baseUrl: process.cwd(),
|
|
61
61
|
paths: {
|
package/dist/define/db.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Type-level column configuration for interface-driven entities.
|
|
2
|
+
* Type-level column configuration for class- and interface-driven entities.
|
|
3
3
|
*
|
|
4
4
|
* Interface entities are plain TypeScript, so their column constraints are
|
|
5
5
|
* expressed *in the type system*: intersect a property type with one or more
|
package/dist/define/db.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
/**
|
|
3
|
-
* Type-level column configuration for interface-driven entities.
|
|
3
|
+
* Type-level column configuration for class- and interface-driven entities.
|
|
4
4
|
*
|
|
5
5
|
* Interface entities are plain TypeScript, so their column constraints are
|
|
6
6
|
* expressed *in the type system*: intersect a property type with one or more
|
package/dist/rastack-admin.d.ts
CHANGED
|
@@ -1,14 +1,21 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* `rastack admin` — the full, Django-admin-style console, served
|
|
4
|
-
*
|
|
3
|
+
* `rastack admin` — the full, Django-admin-style console, served **in the
|
|
4
|
+
* browser over the prebuilt WebAssembly engine** (exactly like `rastack dev`).
|
|
5
5
|
*
|
|
6
6
|
* rastack admin [resourcesDir] [--warehouse <dir>] [--port <n>] [--out <dir>] [--no-open]
|
|
7
7
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
8
|
+
* The console is React API Stack's own React admin (`tools/admin`, built from
|
|
9
|
+
* `rastack/components` + `rastack/theme`). This command compiles the resources,
|
|
10
|
+
* then boots a dependency-free static server that hands the browser everything
|
|
11
|
+
* it needs to run the admin locally over WASM — no native server, no cargo:
|
|
12
|
+
* • the bundled React admin (`/dev/admin.js`), which defaults to `mode:"local"`,
|
|
13
|
+
* • the prebuilt WASM engine (`/wasm/…`) shipped in the package,
|
|
14
|
+
* • the compiled `schema.rastack.json` / `openapi.json`,
|
|
15
|
+
* • the committed Iceberg warehouse (`/data/warehouse/…`) to seed from.
|
|
16
|
+
*
|
|
17
|
+
* The engine is the single prebuilt `.wasm` bundle shipped in the npm package;
|
|
18
|
+
* if it is missing the command errors and points at `rastack update` — it never
|
|
19
|
+
* falls back to a from-source cargo build.
|
|
13
20
|
*/
|
|
14
21
|
export {};
|
package/dist/rastack-admin.js
CHANGED
|
@@ -1,16 +1,23 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
"use strict";
|
|
3
3
|
/**
|
|
4
|
-
* `rastack admin` — the full, Django-admin-style console, served
|
|
5
|
-
*
|
|
4
|
+
* `rastack admin` — the full, Django-admin-style console, served **in the
|
|
5
|
+
* browser over the prebuilt WebAssembly engine** (exactly like `rastack dev`).
|
|
6
6
|
*
|
|
7
7
|
* rastack admin [resourcesDir] [--warehouse <dir>] [--port <n>] [--out <dir>] [--no-open]
|
|
8
8
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
9
|
+
* The console is React API Stack's own React admin (`tools/admin`, built from
|
|
10
|
+
* `rastack/components` + `rastack/theme`). This command compiles the resources,
|
|
11
|
+
* then boots a dependency-free static server that hands the browser everything
|
|
12
|
+
* it needs to run the admin locally over WASM — no native server, no cargo:
|
|
13
|
+
* • the bundled React admin (`/dev/admin.js`), which defaults to `mode:"local"`,
|
|
14
|
+
* • the prebuilt WASM engine (`/wasm/…`) shipped in the package,
|
|
15
|
+
* • the compiled `schema.rastack.json` / `openapi.json`,
|
|
16
|
+
* • the committed Iceberg warehouse (`/data/warehouse/…`) to seed from.
|
|
17
|
+
*
|
|
18
|
+
* The engine is the single prebuilt `.wasm` bundle shipped in the npm package;
|
|
19
|
+
* if it is missing the command errors and points at `rastack update` — it never
|
|
20
|
+
* falls back to a from-source cargo build.
|
|
14
21
|
*/
|
|
15
22
|
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
16
23
|
if (k2 === undefined) k2 = k;
|
|
@@ -48,6 +55,7 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
48
55
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
49
56
|
const child_process_1 = require("child_process");
|
|
50
57
|
const fs = __importStar(require("fs"));
|
|
58
|
+
const http = __importStar(require("http"));
|
|
51
59
|
const path = __importStar(require("path"));
|
|
52
60
|
const harness_1 = require("./dev/harness");
|
|
53
61
|
const HERE = __dirname; // dist/ when installed — the bundle lives beside us.
|
|
@@ -59,6 +67,42 @@ function compile(resourcesDir, outDir) {
|
|
|
59
67
|
return false;
|
|
60
68
|
return fs.existsSync(path.join(outDir, "schema.rastack.json"));
|
|
61
69
|
}
|
|
70
|
+
/** Locate the prebuilt wasm bundle dir shipped in the package (no cargo fallback). */
|
|
71
|
+
function resolveWasmDir() {
|
|
72
|
+
for (const dir of (0, harness_1.wasmBundleCandidates)(HERE)) {
|
|
73
|
+
if (fs.existsSync(path.join(dir, harness_1.WASM_ENTRY)))
|
|
74
|
+
return dir;
|
|
75
|
+
}
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
/** Serve one file if it exists and stays within `root`; else 404/403. */
|
|
79
|
+
function serveUnder(res, root, relPath) {
|
|
80
|
+
const safeRoot = path.resolve(root);
|
|
81
|
+
const rel = decodeURIComponent(relPath).replace(/^\/+/, "");
|
|
82
|
+
const target = path.resolve(safeRoot, rel);
|
|
83
|
+
// Path-traversal guard: the resolved target must stay inside the root.
|
|
84
|
+
if (target !== safeRoot && !target.startsWith(safeRoot + path.sep)) {
|
|
85
|
+
res.writeHead(403).end("Forbidden");
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
fs.readFile(target, (err, data) => {
|
|
89
|
+
if (err) {
|
|
90
|
+
res.writeHead(404).end("Not found");
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
res.writeHead(200, { "Content-Type": (0, harness_1.contentType)(target) }).end(data);
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
/** Serve an exact file (no traversal concern — a fixed internal artifact). */
|
|
97
|
+
function serveFile(res, file) {
|
|
98
|
+
fs.readFile(file, (err, data) => {
|
|
99
|
+
if (err) {
|
|
100
|
+
res.writeHead(404).end("Not found");
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
res.writeHead(200, { "Content-Type": (0, harness_1.contentType)(file) }).end(data);
|
|
104
|
+
});
|
|
105
|
+
}
|
|
62
106
|
/** Best-effort open the default browser at `url`. */
|
|
63
107
|
function openBrowser(url) {
|
|
64
108
|
const cmd = process.platform === "darwin"
|
|
@@ -81,47 +125,48 @@ function main(argv) {
|
|
|
81
125
|
console.error(" ✗ compile failed — fix the resource graph and re-run.");
|
|
82
126
|
process.exit(1);
|
|
83
127
|
}
|
|
84
|
-
const
|
|
85
|
-
if (!
|
|
86
|
-
console.error(" ✗
|
|
128
|
+
const wasmDir = resolveWasmDir();
|
|
129
|
+
if (!wasmDir) {
|
|
130
|
+
console.error("\n ✗ No prebuilt WASM engine found in this rastack install.\n" +
|
|
131
|
+
" Update rastack to pull the prebuilt bundle:\n" +
|
|
132
|
+
" rastack update\n");
|
|
87
133
|
process.exit(1);
|
|
88
134
|
}
|
|
89
|
-
const
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
const manifest = path.join(args.outDir, "schema.rastack.json");
|
|
93
|
-
const serverArgs = [
|
|
94
|
-
"admin",
|
|
95
|
-
"--manifest",
|
|
96
|
-
manifest,
|
|
97
|
-
"--warehouse",
|
|
98
|
-
args.warehouseDir,
|
|
99
|
-
"--addr",
|
|
100
|
-
`127.0.0.1:${args.port}`,
|
|
101
|
-
// Local dev context: the admin runs unauthenticated, exactly like the old
|
|
102
|
-
// native admin. Production admins run the server with --jwks/--admin-group.
|
|
103
|
-
"--insecure-dev-auth",
|
|
104
|
-
];
|
|
105
|
-
const url = `http://127.0.0.1:${args.port}/admin/`;
|
|
106
|
-
// The server serves the React bundle from here; hand the path via env so the
|
|
107
|
-
// Rust side stays free of JS-toolchain knowledge.
|
|
108
|
-
const child = (0, child_process_1.spawn)(binary, serverArgs, {
|
|
109
|
-
stdio: "inherit",
|
|
110
|
-
env: { ...process.env, RASTACK_ADMIN_ASSETS: HERE },
|
|
111
|
-
});
|
|
112
|
-
child.on("error", () => {
|
|
113
|
-
console.error(` ✗ Could not run the Rust server (${binary}). Build it first:\n` +
|
|
114
|
-
` cd rust && cargo build --release\n`);
|
|
135
|
+
const adminBundle = path.join(HERE, "admin.js");
|
|
136
|
+
if (!fs.existsSync(adminBundle)) {
|
|
137
|
+
console.error(" ✗ admin bundle (dist/admin.js) not found — reinstall rastack.");
|
|
115
138
|
process.exit(1);
|
|
139
|
+
}
|
|
140
|
+
const server = http.createServer((req, res) => {
|
|
141
|
+
const url = (req.url || "/").split("?")[0];
|
|
142
|
+
if (req.method !== "GET") {
|
|
143
|
+
res.writeHead(405).end("Method not allowed");
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
if (url === "/" || url === "/index.html") {
|
|
147
|
+
res
|
|
148
|
+
.writeHead(200, { "Content-Type": "text/html; charset=utf-8" })
|
|
149
|
+
.end((0, harness_1.renderShell)("React API Stack — admin"));
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
if (url === "/dev/admin.js")
|
|
153
|
+
return serveFile(res, adminBundle);
|
|
154
|
+
if (url.startsWith("/wasm/"))
|
|
155
|
+
return serveUnder(res, wasmDir, url.slice("/wasm/".length));
|
|
156
|
+
if (url === "/schema.rastack.json" || url === "/openapi.json")
|
|
157
|
+
return serveUnder(res, args.outDir, path.basename(url));
|
|
158
|
+
if (url.startsWith("/data/warehouse/"))
|
|
159
|
+
return serveUnder(res, args.warehouseDir, url.slice("/data/warehouse/".length));
|
|
160
|
+
res.writeHead(404).end("Not found");
|
|
116
161
|
});
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
162
|
+
server.listen(args.port, () => {
|
|
163
|
+
const url = `http://localhost:${args.port}`;
|
|
164
|
+
console.log(` ✓ WASM engine: ${path.relative(process.cwd(), wasmDir)}`);
|
|
120
165
|
console.log(`\n ▸ ${url}\n`);
|
|
121
166
|
console.log(" Press Ctrl+C to stop.\n");
|
|
122
167
|
if (args.open)
|
|
123
168
|
openBrowser(url);
|
|
124
|
-
}
|
|
169
|
+
});
|
|
125
170
|
}
|
|
126
171
|
if (require.main === module) {
|
|
127
172
|
main(process.argv.slice(2));
|
|
Binary file
|
package/package.json
CHANGED
package/src/compile/entities.ts
CHANGED
|
@@ -4,55 +4,67 @@ import { FieldModel, RelationModel, ResourceModel, ScalarType } from "./model";
|
|
|
4
4
|
import { resourceSourceFiles } from "./program";
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
|
-
*
|
|
8
|
-
*
|
|
7
|
+
* Type-driven resource inference — "the entities in the app are a list of
|
|
8
|
+
* plain TypeScript types".
|
|
9
9
|
*
|
|
10
10
|
* Instead of (or alongside) the `resource()` DSL, an app can describe its data
|
|
11
|
-
* as plain TypeScript interfaces and consume them through typed
|
|
12
|
-
* analyser works the resource graph out from the *usage*:
|
|
11
|
+
* as plain TypeScript **classes or interfaces** and consume them through typed
|
|
12
|
+
* hooks. The analyser works the resource graph out from the *usage*:
|
|
13
13
|
*
|
|
14
14
|
* 1. **Roots come from the hooks.** Every hook call that carries a type
|
|
15
15
|
* argument — `useEntityList<Airport>(…)`, `useQuery<Terminal>(…)` — marks
|
|
16
|
-
* the interface behind that type argument as an entity.
|
|
17
|
-
* also opt in explicitly with a `@rastackResource` JSDoc tag.
|
|
18
|
-
*
|
|
16
|
+
* the class/interface behind that type argument as an entity. A declaration
|
|
17
|
+
* can also opt in explicitly with a `@rastackResource` JSDoc tag. And a
|
|
18
|
+
* **plain data class** — no heritage clause, no methods or constructor,
|
|
19
|
+
* only property declarations — is an entity by virtue of being declared:
|
|
20
|
+
* writing `export class Airport { code: string }` is the whole authoring
|
|
21
|
+
* step, no DSL and no tag required. (Interfaces are ubiquitous in app code,
|
|
22
|
+
* so they still need a hook or a tag to root; an entity-shaped class is a
|
|
23
|
+
* deliberate declaration.)
|
|
24
|
+
* 2. **Datatypes come from the properties.** Each property's TypeScript type
|
|
19
25
|
* maps to a manifest scalar (`string` → string, `number` → float,
|
|
20
26
|
* `boolean` → bool, `Date` → datetime). Constraints are carried in the
|
|
21
27
|
* type system with `DbConfig` brands from `rastack/types` —
|
|
22
28
|
* `code: string & Unique & MaxLength<3>`, `age: Int` — or, equivalently,
|
|
23
29
|
* as JSDoc tags (`@unique`, `@maxLength 3`, `@int`, `@uuid`, `@default`).
|
|
24
|
-
* Optional / `| null` properties become nullable fields.
|
|
30
|
+
* Optional / `| null` properties become nullable fields. On a class, a
|
|
31
|
+
* literal property initializer (`status = "scheduled"`) doubles as the
|
|
32
|
+
* column default, and the property type may be inferred from it.
|
|
25
33
|
* 3. **Foreign keys come from the type system.** A property whose type is
|
|
26
|
-
* another entity interface is a foreign key, and the referenced
|
|
27
|
-
* is pulled into the resource graph transitively — the closure
|
|
28
|
-
*
|
|
34
|
+
* another entity class/interface is a foreign key, and the referenced
|
|
35
|
+
* declaration is pulled into the resource graph transitively — the closure
|
|
36
|
+
* of the rooted declarations is the app's resource set.
|
|
29
37
|
* 4. **Circular dependencies are detected.** The FK graph is walked for
|
|
30
38
|
* cycles; a cycle is an *error* when every edge in it is required (no row
|
|
31
39
|
* could ever be inserted) and a *warning* when it is breakable through a
|
|
32
40
|
* nullable field (e.g. an optional self-referencing `manager`).
|
|
33
41
|
*
|
|
34
42
|
* ```ts
|
|
35
|
-
* // entities/airports.ts
|
|
36
|
-
* export
|
|
43
|
+
* // entities/airports.ts — plain data classes are entities by declaration
|
|
44
|
+
* export class Airport {
|
|
37
45
|
* code: string; // JSDoc tags refine: @maxLength 3, @unique
|
|
38
46
|
* name: string;
|
|
39
47
|
* }
|
|
40
|
-
* export
|
|
48
|
+
* export class Terminal {
|
|
41
49
|
* airport: Airport; // FK — inferred from the property type
|
|
42
50
|
* label: string;
|
|
43
51
|
* }
|
|
44
52
|
*
|
|
53
|
+
* // interfaces root through usage instead:
|
|
45
54
|
* // app/screen.tsx — the hook call is what roots the graph
|
|
46
55
|
* const terminals = useEntityList<Terminal>("terminals");
|
|
47
|
-
* // → resources: airports.terminal (root) + airports.airport (transitive)
|
|
48
56
|
* ```
|
|
49
57
|
*
|
|
50
|
-
* Naming: `model` is the lower-camel interface name (a leading `I`
|
|
51
|
-
* in generated `IFlight` interfaces is stripped, so hook usage of
|
|
52
|
-
* types round-trips onto the same resource); `app` defaults to the
|
|
53
|
-
* file's basename. Both are overridable with `@rastackApp` /
|
|
58
|
+
* Naming: `model` is the lower-camel class/interface name (a leading `I`
|
|
59
|
+
* prefix as in generated `IFlight` interfaces is stripped, so hook usage of
|
|
60
|
+
* generated types round-trips onto the same resource); `app` defaults to the
|
|
61
|
+
* defining file's basename. Both are overridable with `@rastackApp` /
|
|
62
|
+
* `@rastackModel` tags.
|
|
54
63
|
*/
|
|
55
64
|
|
|
65
|
+
/** An entity can be declared as an interface or as a (named) class. */
|
|
66
|
+
type EntityDecl = ts.InterfaceDeclaration | ts.ClassDeclaration;
|
|
67
|
+
|
|
56
68
|
export interface EntityDiagnostic {
|
|
57
69
|
severity: "error" | "warning";
|
|
58
70
|
message: string;
|
|
@@ -72,20 +84,22 @@ export function analyzeEntities(
|
|
|
72
84
|
const sourceFiles = resourceSourceFiles(program, fileNames);
|
|
73
85
|
const scanned = new Set(sourceFiles.map((sf) => path.resolve(sf.fileName)));
|
|
74
86
|
|
|
75
|
-
// Every interface declared in the scanned files is a *candidate* entity.
|
|
76
|
-
const candidates = new Set<
|
|
87
|
+
// Every class/interface declared in the scanned files is a *candidate* entity.
|
|
88
|
+
const candidates = new Set<EntityDecl>();
|
|
77
89
|
for (const sf of sourceFiles) {
|
|
78
90
|
ts.forEachChild(sf, function walk(node) {
|
|
79
91
|
if (ts.isInterfaceDeclaration(node)) candidates.add(node);
|
|
92
|
+
if (ts.isClassDeclaration(node) && node.name) candidates.add(node);
|
|
80
93
|
ts.forEachChild(node, walk);
|
|
81
94
|
});
|
|
82
95
|
}
|
|
83
96
|
|
|
84
|
-
// 1. Roots:
|
|
85
|
-
// `@rastackResource` opt-ins
|
|
86
|
-
|
|
87
|
-
const
|
|
88
|
-
const
|
|
97
|
+
// 1. Roots: declarations used as type arguments of hook calls, explicit
|
|
98
|
+
// `@rastackResource` opt-ins, and plain data classes (declaring one *is*
|
|
99
|
+
// the opt-in).
|
|
100
|
+
const roots: EntityDecl[] = [];
|
|
101
|
+
const rooted = new Set<EntityDecl>();
|
|
102
|
+
const addRoot = (decl: EntityDecl) => {
|
|
89
103
|
if (!rooted.has(decl)) {
|
|
90
104
|
rooted.add(decl);
|
|
91
105
|
roots.push(decl);
|
|
@@ -95,7 +109,7 @@ export function analyzeEntities(
|
|
|
95
109
|
ts.forEachChild(sf, function walk(node) {
|
|
96
110
|
if (ts.isCallExpression(node) && isHookCall(node)) {
|
|
97
111
|
for (const typeArg of node.typeArguments ?? []) {
|
|
98
|
-
const decl =
|
|
112
|
+
const decl = entityBehindTypeNode(typeArg, checker, scanned);
|
|
99
113
|
if (decl && candidates.has(decl)) addRoot(decl);
|
|
100
114
|
}
|
|
101
115
|
}
|
|
@@ -104,12 +118,13 @@ export function analyzeEntities(
|
|
|
104
118
|
}
|
|
105
119
|
for (const decl of candidates) {
|
|
106
120
|
if (jsdocTags(decl).has("rastackresource")) addRoot(decl);
|
|
121
|
+
else if (ts.isClassDeclaration(decl) && isDataClass(decl)) addRoot(decl);
|
|
107
122
|
}
|
|
108
123
|
|
|
109
|
-
// 2. Transitive closure over FK references: an entity's
|
|
110
|
-
// property pulls the referenced
|
|
111
|
-
const propsByDecl = new Map<
|
|
112
|
-
const analyzed = (decl:
|
|
124
|
+
// 2. Transitive closure over FK references: an entity's entity-typed
|
|
125
|
+
// property pulls the referenced declaration in as an entity too.
|
|
126
|
+
const propsByDecl = new Map<EntityDecl, PropInfo[]>();
|
|
127
|
+
const analyzed = (decl: EntityDecl): PropInfo[] => {
|
|
113
128
|
let props = propsByDecl.get(decl);
|
|
114
129
|
if (!props) {
|
|
115
130
|
props = analyzeProperties(decl, checker, scanned);
|
|
@@ -118,8 +133,8 @@ export function analyzeEntities(
|
|
|
118
133
|
return props;
|
|
119
134
|
};
|
|
120
135
|
|
|
121
|
-
const included:
|
|
122
|
-
const seen = new Set<
|
|
136
|
+
const included: EntityDecl[] = [];
|
|
137
|
+
const seen = new Set<EntityDecl>();
|
|
123
138
|
const queue = [...roots];
|
|
124
139
|
while (queue.length) {
|
|
125
140
|
const decl = queue.shift()!;
|
|
@@ -200,23 +215,24 @@ function isHookCall(call: ts.CallExpression): boolean {
|
|
|
200
215
|
return !!name && /^use[A-Z0-9_]/.test(name);
|
|
201
216
|
}
|
|
202
217
|
|
|
203
|
-
/** Resolve a type node to
|
|
204
|
-
function
|
|
218
|
+
/** Resolve a type node to a class/interface declared in a scanned file. */
|
|
219
|
+
function entityBehindTypeNode(
|
|
205
220
|
node: ts.TypeNode,
|
|
206
221
|
checker: ts.TypeChecker,
|
|
207
222
|
scanned: Set<string>,
|
|
208
|
-
):
|
|
209
|
-
return
|
|
223
|
+
): EntityDecl | undefined {
|
|
224
|
+
return entityBehindType(checker.getTypeFromTypeNode(node), scanned);
|
|
210
225
|
}
|
|
211
226
|
|
|
212
|
-
function
|
|
227
|
+
function entityBehindType(
|
|
213
228
|
type: ts.Type,
|
|
214
229
|
scanned: Set<string>,
|
|
215
|
-
):
|
|
230
|
+
): EntityDecl | undefined {
|
|
216
231
|
const symbol = type.aliasSymbol ?? type.getSymbol();
|
|
217
232
|
for (const decl of symbol?.declarations ?? []) {
|
|
218
233
|
if (
|
|
219
|
-
ts.isInterfaceDeclaration(decl)
|
|
234
|
+
(ts.isInterfaceDeclaration(decl) ||
|
|
235
|
+
(ts.isClassDeclaration(decl) && decl.name)) &&
|
|
220
236
|
scanned.has(path.resolve(decl.getSourceFile().fileName))
|
|
221
237
|
) {
|
|
222
238
|
return decl;
|
|
@@ -225,6 +241,31 @@ function interfaceBehindType(
|
|
|
225
241
|
return undefined;
|
|
226
242
|
}
|
|
227
243
|
|
|
244
|
+
/**
|
|
245
|
+
* A *plain data class* — the class-authored equivalent of a `resource()` call,
|
|
246
|
+
* so it roots itself. It must carry no heritage clause (a subclass of
|
|
247
|
+
* `React.Component` or a service base is app code, not a table), not be
|
|
248
|
+
* abstract, contain nothing but property declarations, and have at least one
|
|
249
|
+
* instance property. Anything richer can still opt in via a hook or the
|
|
250
|
+
* `@rastackResource` tag.
|
|
251
|
+
*/
|
|
252
|
+
function isDataClass(decl: ts.ClassDeclaration): boolean {
|
|
253
|
+
if (decl.heritageClauses?.length) return false;
|
|
254
|
+
if (
|
|
255
|
+
ts.getCombinedModifierFlags(decl) &
|
|
256
|
+
(ts.ModifierFlags.Abstract | ts.ModifierFlags.Ambient)
|
|
257
|
+
) {
|
|
258
|
+
return false;
|
|
259
|
+
}
|
|
260
|
+
let properties = 0;
|
|
261
|
+
for (const member of decl.members) {
|
|
262
|
+
if (!ts.isPropertyDeclaration(member)) return false; // method/ctor/accessor
|
|
263
|
+
if (ts.getCombinedModifierFlags(member) & ts.ModifierFlags.Static) continue;
|
|
264
|
+
properties += 1;
|
|
265
|
+
}
|
|
266
|
+
return properties > 0;
|
|
267
|
+
}
|
|
268
|
+
|
|
228
269
|
// -- property analysis --------------------------------------------------------
|
|
229
270
|
|
|
230
271
|
interface PropInfo {
|
|
@@ -232,43 +273,79 @@ interface PropInfo {
|
|
|
232
273
|
nullable: boolean;
|
|
233
274
|
/** Scalar field (mutually exclusive with `target`). */
|
|
234
275
|
field?: FieldModel;
|
|
235
|
-
/** FK target
|
|
236
|
-
target?:
|
|
276
|
+
/** FK target declaration (relation resolved after naming). */
|
|
277
|
+
target?: EntityDecl;
|
|
237
278
|
/** `Entity[]` — the reverse side of a FK; contributes no column. */
|
|
238
279
|
inverse?: boolean;
|
|
239
280
|
/** Unsupported type — reported as a warning and skipped. */
|
|
240
281
|
skip?: string;
|
|
241
282
|
}
|
|
242
283
|
|
|
284
|
+
/** A column-shaped member: an interface property or a class instance field. */
|
|
285
|
+
type EntityProp = ts.PropertySignature | ts.PropertyDeclaration;
|
|
286
|
+
|
|
243
287
|
function analyzeProperties(
|
|
244
|
-
decl:
|
|
288
|
+
decl: EntityDecl,
|
|
245
289
|
checker: ts.TypeChecker,
|
|
246
290
|
scanned: Set<string>,
|
|
247
291
|
): PropInfo[] {
|
|
248
292
|
const props: PropInfo[] = [];
|
|
249
293
|
for (const member of decl.members) {
|
|
250
|
-
|
|
294
|
+
let prop: EntityProp | undefined;
|
|
295
|
+
if (ts.isPropertySignature(member) && member.type) {
|
|
296
|
+
prop = member;
|
|
297
|
+
} else if (
|
|
298
|
+
ts.isPropertyDeclaration(member) &&
|
|
299
|
+
(member.type || member.initializer) &&
|
|
300
|
+
!ts.isPrivateIdentifier(member.name) &&
|
|
301
|
+
!(
|
|
302
|
+
ts.getCombinedModifierFlags(member) &
|
|
303
|
+
(ts.ModifierFlags.Static |
|
|
304
|
+
ts.ModifierFlags.Private |
|
|
305
|
+
ts.ModifierFlags.Protected)
|
|
306
|
+
)
|
|
307
|
+
) {
|
|
308
|
+
prop = member;
|
|
309
|
+
}
|
|
310
|
+
if (!prop) continue;
|
|
251
311
|
const name =
|
|
252
|
-
ts.isIdentifier(
|
|
253
|
-
?
|
|
312
|
+
ts.isIdentifier(prop.name) || ts.isStringLiteralLike(prop.name)
|
|
313
|
+
? prop.name.text
|
|
254
314
|
: undefined;
|
|
255
315
|
if (!name) continue;
|
|
256
|
-
props.push(analyzeProperty(name,
|
|
316
|
+
props.push(analyzeProperty(name, prop, checker, scanned));
|
|
257
317
|
}
|
|
258
318
|
return props;
|
|
259
319
|
}
|
|
260
320
|
|
|
261
321
|
function analyzeProperty(
|
|
262
322
|
name: string,
|
|
263
|
-
member:
|
|
323
|
+
member: EntityProp,
|
|
264
324
|
checker: ts.TypeChecker,
|
|
265
325
|
scanned: Set<string>,
|
|
266
326
|
): PropInfo {
|
|
267
327
|
let nullable = !!member.questionToken;
|
|
268
328
|
|
|
329
|
+
// A class field with no annotation infers its (widened) type from the
|
|
330
|
+
// initializer — `region = "EU"` is a string column defaulting to "EU".
|
|
331
|
+
if (!member.type) {
|
|
332
|
+
const type = checker.getTypeAtLocation(member);
|
|
333
|
+
const base = classifyType(type, scanned);
|
|
334
|
+
if (base === undefined) {
|
|
335
|
+
return { name, nullable, skip: "unsupported type — skipped" };
|
|
336
|
+
}
|
|
337
|
+
if (typeof base !== "string") return { name, nullable, target: base };
|
|
338
|
+
const config = configFromTags(jsdocTags(member));
|
|
339
|
+
if (config.default === undefined) {
|
|
340
|
+
const dflt = initializerDefault(member);
|
|
341
|
+
if (dflt !== undefined) config.default = dflt;
|
|
342
|
+
}
|
|
343
|
+
return scalarProp(name, base, config, nullable);
|
|
344
|
+
}
|
|
345
|
+
|
|
269
346
|
// Unwrap parens and unions: strip null/undefined (→ nullable), and require a
|
|
270
347
|
// single remaining arm (unions of string literals collapse to `string`).
|
|
271
|
-
let typeNode = unwrapParens(member.type
|
|
348
|
+
let typeNode = unwrapParens(member.type);
|
|
272
349
|
if (ts.isUnionTypeNode(typeNode)) {
|
|
273
350
|
const arms = typeNode.types.filter((t) => {
|
|
274
351
|
const isNullish =
|
|
@@ -290,7 +367,7 @@ function analyzeProperty(
|
|
|
290
367
|
// but is intentionally not an error. Arrays of scalars have no manifest shape.
|
|
291
368
|
const element = arrayElement(typeNode);
|
|
292
369
|
if (element) {
|
|
293
|
-
if (
|
|
370
|
+
if (entityBehindTypeNode(element, checker, scanned)) {
|
|
294
371
|
return { name, nullable, inverse: true };
|
|
295
372
|
}
|
|
296
373
|
return { name, nullable, skip: "array fields are not supported — skipped" };
|
|
@@ -302,26 +379,50 @@ function analyzeProperty(
|
|
|
302
379
|
return { name, nullable, skip: "unsupported type — skipped" };
|
|
303
380
|
}
|
|
304
381
|
if (typeof base !== "string") {
|
|
305
|
-
return { name, nullable, target: base }; // entity interface → FK
|
|
382
|
+
return { name, nullable, target: base }; // entity class/interface → FK
|
|
306
383
|
}
|
|
307
384
|
|
|
308
385
|
const config = {
|
|
309
386
|
...configFromTags(jsdocTags(member)),
|
|
310
387
|
...configFromType(type, checker, member),
|
|
311
388
|
};
|
|
389
|
+
if (config.default === undefined) {
|
|
390
|
+
const dflt = initializerDefault(member);
|
|
391
|
+
if (dflt !== undefined) config.default = dflt;
|
|
392
|
+
}
|
|
312
393
|
return scalarProp(name, base, config, nullable);
|
|
313
394
|
}
|
|
314
395
|
|
|
396
|
+
/** The literal value of a class field's initializer, used as the default. */
|
|
397
|
+
function initializerDefault(
|
|
398
|
+
member: EntityProp,
|
|
399
|
+
): string | number | boolean | undefined {
|
|
400
|
+
if (!ts.isPropertyDeclaration(member) || !member.initializer) return undefined;
|
|
401
|
+
const init = member.initializer;
|
|
402
|
+
if (ts.isStringLiteralLike(init)) return init.text;
|
|
403
|
+
if (ts.isNumericLiteral(init)) return Number(init.text);
|
|
404
|
+
if (
|
|
405
|
+
ts.isPrefixUnaryExpression(init) &&
|
|
406
|
+
init.operator === ts.SyntaxKind.MinusToken &&
|
|
407
|
+
ts.isNumericLiteral(init.operand)
|
|
408
|
+
) {
|
|
409
|
+
return -Number(init.operand.text);
|
|
410
|
+
}
|
|
411
|
+
if (init.kind === ts.SyntaxKind.TrueKeyword) return true;
|
|
412
|
+
if (init.kind === ts.SyntaxKind.FalseKeyword) return false;
|
|
413
|
+
return undefined;
|
|
414
|
+
}
|
|
415
|
+
|
|
315
416
|
/**
|
|
316
|
-
* What a property type *is*: a manifest scalar, an entity interface
|
|
317
|
-
* unsupported. Intersections classify by their first classifiable
|
|
318
|
-
* which is what lets `DbConfig` brands ride along — `string &
|
|
319
|
-
* string; the brand carries configuration, not a datatype.
|
|
417
|
+
* What a property type *is*: a manifest scalar, an entity class/interface
|
|
418
|
+
* (→ FK), or unsupported. Intersections classify by their first classifiable
|
|
419
|
+
* constituent, which is what lets `DbConfig` brands ride along — `string &
|
|
420
|
+
* Unique` is a string; the brand carries configuration, not a datatype.
|
|
320
421
|
*/
|
|
321
422
|
function classifyType(
|
|
322
423
|
type: ts.Type,
|
|
323
424
|
scanned: Set<string>,
|
|
324
|
-
): ScalarType |
|
|
425
|
+
): ScalarType | EntityDecl | undefined {
|
|
325
426
|
if (type.flags & ts.TypeFlags.StringLike) return "string";
|
|
326
427
|
if (type.flags & ts.TypeFlags.NumberLike) return "float";
|
|
327
428
|
if (type.flags & ts.TypeFlags.BooleanLike) return "bool";
|
|
@@ -333,7 +434,7 @@ function classifyType(
|
|
|
333
434
|
return undefined;
|
|
334
435
|
}
|
|
335
436
|
if (type.getProperty("__rastackDb")) return undefined; // a bare config brand
|
|
336
|
-
const decl =
|
|
437
|
+
const decl = entityBehindType(type, scanned);
|
|
337
438
|
if (decl) return decl;
|
|
338
439
|
if (type.getSymbol()?.getName() === "Date") return "datetime";
|
|
339
440
|
return undefined;
|
|
@@ -476,9 +577,10 @@ interface EntityNaming {
|
|
|
476
577
|
permission?: ResourceModel["permission"];
|
|
477
578
|
}
|
|
478
579
|
|
|
479
|
-
function entityNaming(decl:
|
|
580
|
+
function entityNaming(decl: EntityDecl): EntityNaming {
|
|
480
581
|
const tags = jsdocTags(decl);
|
|
481
|
-
|
|
582
|
+
// Class candidates are only ever collected with a name (see analyzeEntities).
|
|
583
|
+
const raw = decl.name!.text;
|
|
482
584
|
// Generated entity interfaces are `I`-prefixed (`IFlight`); strip the prefix
|
|
483
585
|
// so hook usage of generated types resolves to the same resource.
|
|
484
586
|
const stripped = /^I[A-Z]/.test(raw) ? raw.slice(1) : raw;
|
package/src/compile/index.ts
CHANGED
|
@@ -39,7 +39,7 @@ const IGNORE_DIRS = new Set([
|
|
|
39
39
|
* beside `app/airports/index.tsx` — instead of in one top-level `resources/`
|
|
40
40
|
* folder. `.tsx` files carry no `resource(...)` definitions but are scanned
|
|
41
41
|
* for the typed hook calls (`useEntityList<Airport>(…)`) that root the
|
|
42
|
-
* interface
|
|
42
|
+
* class/interface entity graph; co-located helpers are harmless.
|
|
43
43
|
*/
|
|
44
44
|
export function resolveResourceFiles(input: string): string[] {
|
|
45
45
|
const stat = fs.existsSync(input) ? fs.statSync(input) : undefined;
|
package/src/compile/program.ts
CHANGED
|
@@ -20,7 +20,7 @@ export function createProgram(fileNames: string[]): ts.Program {
|
|
|
20
20
|
esModuleInterop: true,
|
|
21
21
|
noEmit: true,
|
|
22
22
|
// `.tsx` app files are scanned for the typed hook calls that root the
|
|
23
|
-
// interface
|
|
23
|
+
// class/interface entity graph.
|
|
24
24
|
jsx: ts.JsxEmit.ReactJSX,
|
|
25
25
|
baseUrl: process.cwd(),
|
|
26
26
|
paths: {
|
package/src/define/db.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Type-level column configuration for interface-driven entities.
|
|
2
|
+
* Type-level column configuration for class- and interface-driven entities.
|
|
3
3
|
*
|
|
4
4
|
* Interface entities are plain TypeScript, so their column constraints are
|
|
5
5
|
* expressed *in the type system*: intersect a property type with one or more
|
package/src/rastack-admin.ts
CHANGED
|
@@ -1,22 +1,36 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* `rastack admin` — the full, Django-admin-style console, served
|
|
5
|
-
*
|
|
4
|
+
* `rastack admin` — the full, Django-admin-style console, served **in the
|
|
5
|
+
* browser over the prebuilt WebAssembly engine** (exactly like `rastack dev`).
|
|
6
6
|
*
|
|
7
7
|
* rastack admin [resourcesDir] [--warehouse <dir>] [--port <n>] [--out <dir>] [--no-open]
|
|
8
8
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
9
|
+
* The console is React API Stack's own React admin (`tools/admin`, built from
|
|
10
|
+
* `rastack/components` + `rastack/theme`). This command compiles the resources,
|
|
11
|
+
* then boots a dependency-free static server that hands the browser everything
|
|
12
|
+
* it needs to run the admin locally over WASM — no native server, no cargo:
|
|
13
|
+
* • the bundled React admin (`/dev/admin.js`), which defaults to `mode:"local"`,
|
|
14
|
+
* • the prebuilt WASM engine (`/wasm/…`) shipped in the package,
|
|
15
|
+
* • the compiled `schema.rastack.json` / `openapi.json`,
|
|
16
|
+
* • the committed Iceberg warehouse (`/data/warehouse/…`) to seed from.
|
|
17
|
+
*
|
|
18
|
+
* The engine is the single prebuilt `.wasm` bundle shipped in the npm package;
|
|
19
|
+
* if it is missing the command errors and points at `rastack update` — it never
|
|
20
|
+
* falls back to a from-source cargo build.
|
|
14
21
|
*/
|
|
15
22
|
|
|
16
|
-
import { execFileSync,
|
|
23
|
+
import { execFileSync, spawnSync } from "child_process";
|
|
17
24
|
import * as fs from "fs";
|
|
25
|
+
import * as http from "http";
|
|
18
26
|
import * as path from "path";
|
|
19
|
-
import {
|
|
27
|
+
import {
|
|
28
|
+
WASM_ENTRY,
|
|
29
|
+
contentType,
|
|
30
|
+
parseAdminArgs,
|
|
31
|
+
renderShell,
|
|
32
|
+
wasmBundleCandidates,
|
|
33
|
+
} from "./dev/harness";
|
|
20
34
|
|
|
21
35
|
const HERE = __dirname; // dist/ when installed — the bundle lives beside us.
|
|
22
36
|
|
|
@@ -32,6 +46,48 @@ function compile(resourcesDir: string, outDir: string): boolean {
|
|
|
32
46
|
return fs.existsSync(path.join(outDir, "schema.rastack.json"));
|
|
33
47
|
}
|
|
34
48
|
|
|
49
|
+
/** Locate the prebuilt wasm bundle dir shipped in the package (no cargo fallback). */
|
|
50
|
+
function resolveWasmDir(): string | null {
|
|
51
|
+
for (const dir of wasmBundleCandidates(HERE)) {
|
|
52
|
+
if (fs.existsSync(path.join(dir, WASM_ENTRY))) return dir;
|
|
53
|
+
}
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Serve one file if it exists and stays within `root`; else 404/403. */
|
|
58
|
+
function serveUnder(
|
|
59
|
+
res: http.ServerResponse,
|
|
60
|
+
root: string,
|
|
61
|
+
relPath: string,
|
|
62
|
+
): void {
|
|
63
|
+
const safeRoot = path.resolve(root);
|
|
64
|
+
const rel = decodeURIComponent(relPath).replace(/^\/+/, "");
|
|
65
|
+
const target = path.resolve(safeRoot, rel);
|
|
66
|
+
// Path-traversal guard: the resolved target must stay inside the root.
|
|
67
|
+
if (target !== safeRoot && !target.startsWith(safeRoot + path.sep)) {
|
|
68
|
+
res.writeHead(403).end("Forbidden");
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
fs.readFile(target, (err, data) => {
|
|
72
|
+
if (err) {
|
|
73
|
+
res.writeHead(404).end("Not found");
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
res.writeHead(200, { "Content-Type": contentType(target) }).end(data);
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Serve an exact file (no traversal concern — a fixed internal artifact). */
|
|
81
|
+
function serveFile(res: http.ServerResponse, file: string): void {
|
|
82
|
+
fs.readFile(file, (err, data) => {
|
|
83
|
+
if (err) {
|
|
84
|
+
res.writeHead(404).end("Not found");
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
res.writeHead(200, { "Content-Type": contentType(file) }).end(data);
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
35
91
|
/** Best-effort open the default browser at `url`. */
|
|
36
92
|
function openBrowser(url: string): void {
|
|
37
93
|
const cmd =
|
|
@@ -57,54 +113,57 @@ function main(argv: string[]): void {
|
|
|
57
113
|
process.exit(1);
|
|
58
114
|
}
|
|
59
115
|
|
|
60
|
-
const
|
|
61
|
-
if (!
|
|
62
|
-
console.error(
|
|
116
|
+
const wasmDir = resolveWasmDir();
|
|
117
|
+
if (!wasmDir) {
|
|
118
|
+
console.error(
|
|
119
|
+
"\n ✗ No prebuilt WASM engine found in this rastack install.\n" +
|
|
120
|
+
" Update rastack to pull the prebuilt bundle:\n" +
|
|
121
|
+
" rastack update\n",
|
|
122
|
+
);
|
|
63
123
|
process.exit(1);
|
|
64
124
|
}
|
|
65
125
|
|
|
66
|
-
const
|
|
67
|
-
|
|
68
|
-
process.env.RASTACK_SERVER_BIN ||
|
|
69
|
-
path.join(repoRoot, "rust", "target", "release", "rastack-server");
|
|
70
|
-
|
|
71
|
-
const manifest = path.join(args.outDir, "schema.rastack.json");
|
|
72
|
-
const serverArgs = [
|
|
73
|
-
"admin",
|
|
74
|
-
"--manifest",
|
|
75
|
-
manifest,
|
|
76
|
-
"--warehouse",
|
|
77
|
-
args.warehouseDir,
|
|
78
|
-
"--addr",
|
|
79
|
-
`127.0.0.1:${args.port}`,
|
|
80
|
-
// Local dev context: the admin runs unauthenticated, exactly like the old
|
|
81
|
-
// native admin. Production admins run the server with --jwks/--admin-group.
|
|
82
|
-
"--insecure-dev-auth",
|
|
83
|
-
];
|
|
84
|
-
|
|
85
|
-
const url = `http://127.0.0.1:${args.port}/admin/`;
|
|
86
|
-
// The server serves the React bundle from here; hand the path via env so the
|
|
87
|
-
// Rust side stays free of JS-toolchain knowledge.
|
|
88
|
-
const child = spawn(binary, serverArgs, {
|
|
89
|
-
stdio: "inherit",
|
|
90
|
-
env: { ...process.env, RASTACK_ADMIN_ASSETS: HERE },
|
|
91
|
-
});
|
|
92
|
-
|
|
93
|
-
child.on("error", () => {
|
|
126
|
+
const adminBundle = path.join(HERE, "admin.js");
|
|
127
|
+
if (!fs.existsSync(adminBundle)) {
|
|
94
128
|
console.error(
|
|
95
|
-
|
|
96
|
-
` cd rust && cargo build --release\n`,
|
|
129
|
+
" ✗ admin bundle (dist/admin.js) not found — reinstall rastack.",
|
|
97
130
|
);
|
|
98
131
|
process.exit(1);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const server = http.createServer((req, res) => {
|
|
135
|
+
const url = (req.url || "/").split("?")[0];
|
|
136
|
+
if (req.method !== "GET") {
|
|
137
|
+
res.writeHead(405).end("Method not allowed");
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
if (url === "/" || url === "/index.html") {
|
|
141
|
+
res
|
|
142
|
+
.writeHead(200, { "Content-Type": "text/html; charset=utf-8" })
|
|
143
|
+
.end(renderShell("React API Stack — admin"));
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
if (url === "/dev/admin.js") return serveFile(res, adminBundle);
|
|
147
|
+
if (url.startsWith("/wasm/"))
|
|
148
|
+
return serveUnder(res, wasmDir, url.slice("/wasm/".length));
|
|
149
|
+
if (url === "/schema.rastack.json" || url === "/openapi.json")
|
|
150
|
+
return serveUnder(res, args.outDir, path.basename(url));
|
|
151
|
+
if (url.startsWith("/data/warehouse/"))
|
|
152
|
+
return serveUnder(
|
|
153
|
+
res,
|
|
154
|
+
args.warehouseDir,
|
|
155
|
+
url.slice("/data/warehouse/".length),
|
|
156
|
+
);
|
|
157
|
+
res.writeHead(404).end("Not found");
|
|
99
158
|
});
|
|
100
|
-
child.on("exit", (code) => process.exit(code ?? 0));
|
|
101
159
|
|
|
102
|
-
|
|
103
|
-
|
|
160
|
+
server.listen(args.port, () => {
|
|
161
|
+
const url = `http://localhost:${args.port}`;
|
|
162
|
+
console.log(` ✓ WASM engine: ${path.relative(process.cwd(), wasmDir)}`);
|
|
104
163
|
console.log(`\n ▸ ${url}\n`);
|
|
105
164
|
console.log(" Press Ctrl+C to stop.\n");
|
|
106
165
|
if (args.open) openBrowser(url);
|
|
107
|
-
}
|
|
166
|
+
});
|
|
108
167
|
}
|
|
109
168
|
|
|
110
169
|
if (require.main === module) {
|
package/test/entities.spec.ts
CHANGED
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
*/
|
|
17
17
|
const workRoot = join(__dirname, "entities-fixtures");
|
|
18
18
|
const appDir = join(workRoot, "app");
|
|
19
|
+
const classesDir = join(workRoot, "classes");
|
|
19
20
|
const cycleDir = join(workRoot, "cycles");
|
|
20
21
|
const hardCycleDir = join(workRoot, "hard-cycles");
|
|
21
22
|
const mixedDir = join(workRoot, "mixed");
|
|
@@ -150,6 +151,39 @@ declare function useEntity<T>(name: string): T;
|
|
|
150
151
|
export const a = useEntity<Airport>("airport");
|
|
151
152
|
`;
|
|
152
153
|
|
|
154
|
+
const CLASSES_TS = `
|
|
155
|
+
import { MaxLength, Unique } from "rastack/types";
|
|
156
|
+
|
|
157
|
+
/** @rastackSearch code,name */
|
|
158
|
+
export class Airport {
|
|
159
|
+
code!: string & Unique & MaxLength<3>;
|
|
160
|
+
/** @maxLength 120 */
|
|
161
|
+
name!: string;
|
|
162
|
+
region = "EU"; // initializer → type inferred, value is the default
|
|
163
|
+
active = true;
|
|
164
|
+
/** @int */
|
|
165
|
+
gates = 0;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export class Terminal {
|
|
169
|
+
airport!: Airport; // FK — inferred from the property type
|
|
170
|
+
/** @maxLength 20 */
|
|
171
|
+
label!: string;
|
|
172
|
+
opened?: Date;
|
|
173
|
+
static kind = "terminal"; // not a column
|
|
174
|
+
private secret!: string; // not a column
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Not a plain data class — a method means it must opt in explicitly. */
|
|
178
|
+
export class BoardingService {
|
|
179
|
+
queue: string[] = [];
|
|
180
|
+
board(): void {}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Heritage means app code, not a table. */
|
|
184
|
+
export class SplashScreen extends BoardingService {}
|
|
185
|
+
`;
|
|
186
|
+
|
|
153
187
|
function findResource(
|
|
154
188
|
resources: ResourceModel[],
|
|
155
189
|
app: string,
|
|
@@ -166,6 +200,8 @@ beforeAll(() => {
|
|
|
166
200
|
writeFileSync(join(appDir, "flights.ts"), FLIGHTS_TS);
|
|
167
201
|
writeFileSync(join(appDir, "passengers.ts"), PASSENGERS_TS);
|
|
168
202
|
writeFileSync(join(appDir, "screens.tsx"), SCREENS_TSX);
|
|
203
|
+
mkdirSync(classesDir, { recursive: true });
|
|
204
|
+
writeFileSync(join(classesDir, "airports.ts"), CLASSES_TS);
|
|
169
205
|
mkdirSync(cycleDir, { recursive: true });
|
|
170
206
|
writeFileSync(join(cycleDir, "org.ts"), SOFT_CYCLE_TS);
|
|
171
207
|
mkdirSync(hardCycleDir, { recursive: true });
|
|
@@ -307,6 +343,69 @@ describe("interface entities — FK inference", () => {
|
|
|
307
343
|
});
|
|
308
344
|
});
|
|
309
345
|
|
|
346
|
+
describe("class entities — plain data classes", () => {
|
|
347
|
+
it("roots a plain data class by declaration — no hook, tag, or DSL needed", () => {
|
|
348
|
+
const { resources } = analyzeProject(classesDir);
|
|
349
|
+
const keys = resources.map((r) => `${r.app}.${r.model}`);
|
|
350
|
+
expect(keys).toContain("airports.airport");
|
|
351
|
+
expect(keys).toContain("airports.terminal");
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
it("does not root classes with methods or heritage clauses", () => {
|
|
355
|
+
const { resources } = analyzeProject(classesDir);
|
|
356
|
+
const keys = resources.map((r) => `${r.app}.${r.model}`);
|
|
357
|
+
expect(keys).not.toContain("airports.boardingService");
|
|
358
|
+
expect(keys).not.toContain("airports.splashScreen");
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
it("reads DbConfig brands and JSDoc tags off class fields", () => {
|
|
362
|
+
const { resources } = analyzeProject(classesDir);
|
|
363
|
+
const airport = findResource(resources, "airports", "airport");
|
|
364
|
+
expect(airport.search).toEqual(["code", "name"]);
|
|
365
|
+
expect(airport.fields.find((f) => f.name === "code")).toMatchObject({
|
|
366
|
+
type: "string",
|
|
367
|
+
maxLength: 3,
|
|
368
|
+
unique: true,
|
|
369
|
+
});
|
|
370
|
+
expect(airport.fields.find((f) => f.name === "name")).toMatchObject({
|
|
371
|
+
type: "string",
|
|
372
|
+
maxLength: 120,
|
|
373
|
+
});
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
it("infers types and defaults from field initializers", () => {
|
|
377
|
+
const { resources } = analyzeProject(classesDir);
|
|
378
|
+
const airport = findResource(resources, "airports", "airport");
|
|
379
|
+
const field = (name: string) => airport.fields.find((f) => f.name === name);
|
|
380
|
+
expect(field("region")).toMatchObject({ type: "string", default: "EU" });
|
|
381
|
+
expect(field("active")).toMatchObject({ type: "bool", default: true });
|
|
382
|
+
expect(field("gates")).toMatchObject({ type: "int", default: 0 });
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
it("treats a class-typed field as a foreign key", () => {
|
|
386
|
+
const { resources } = analyzeProject(classesDir);
|
|
387
|
+
const terminal = findResource(resources, "airports", "terminal");
|
|
388
|
+
expect(terminal.fields.find((f) => f.name === "airport")).toMatchObject({
|
|
389
|
+
type: "fk",
|
|
390
|
+
relation: { app: "airports", model: "airport" },
|
|
391
|
+
});
|
|
392
|
+
expect(terminal.relations).toEqual([
|
|
393
|
+
{ field: "airport", target: "airports.airport", kind: "fk" },
|
|
394
|
+
]);
|
|
395
|
+
expect(terminal.fields.find((f) => f.name === "opened")).toMatchObject({
|
|
396
|
+
type: "datetime",
|
|
397
|
+
null: true,
|
|
398
|
+
});
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
it("ignores static and private class members", () => {
|
|
402
|
+
const { resources } = analyzeProject(classesDir);
|
|
403
|
+
const terminal = findResource(resources, "airports", "terminal");
|
|
404
|
+
expect(terminal.fields.find((f) => f.name === "kind")).toBeUndefined();
|
|
405
|
+
expect(terminal.fields.find((f) => f.name === "secret")).toBeUndefined();
|
|
406
|
+
});
|
|
407
|
+
});
|
|
408
|
+
|
|
310
409
|
describe("interface entities — circular dependencies", () => {
|
|
311
410
|
it("reports a breakable cycle (optional self-FK) as a warning", () => {
|
|
312
411
|
const { diagnostics } = analyzeProject(cycleDir);
|
|
Binary file
|