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 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;
@@ -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: interfaces used as type arguments of hook calls, plus explicit
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 = interfaceBehindTypeNode(typeArg, checker, scanned);
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 interface-typed
82
- // property pulls the referenced interface in as an entity too.
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 an interface declared in one of the scanned files. */
170
- function interfaceBehindTypeNode(node, checker, scanned) {
171
- return interfaceBehindType(checker.getTypeFromTypeNode(node), scanned);
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 interfaceBehindType(type, scanned) {
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
- if (!ts.isPropertySignature(member) || !member.type)
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(member.name) || ts.isStringLiteralLike(member.name)
189
- ? member.name.text
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, member, checker, scanned));
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 (interfaceBehindTypeNode(element, checker, scanned)) {
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 (→ FK), or
243
- * unsupported. Intersections classify by their first classifiable constituent,
244
- * which is what lets `DbConfig` brands ride along — `string & Unique` is a
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 = interfaceBehindType(type, scanned);
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.
@@ -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-driven entity graph; co-located helpers are harmless.
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 {
@@ -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-driven entity graph; co-located helpers are harmless.
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;
@@ -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-driven entity graph.
58
+ // class/interface entity graph.
59
59
  jsx: ts.JsxEmit.ReactJSX,
60
60
  baseUrl: process.cwd(),
61
61
  paths: {
@@ -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
@@ -1,14 +1,21 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * `rastack admin` — the full, Django-admin-style console, served by the native
4
- * `rastack-server` against the real Iceberg warehouse.
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
- * Unlike the old server-rendered `/admin`, the console is React API Stack's own
9
- * React admin (`tools/admin`, built from `rastack/components` + `rastack/theme`)
10
- * — this command compiles the resources, points the server at the prebuilt
11
- * bundle (`RASTACK_ADMIN_ASSETS`), and boots it. The bundle talks back to the
12
- * same server's `/api` over HTTP, so edits persist to the real warehouse.
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 {};
@@ -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 by the native
5
- * `rastack-server` against the real Iceberg warehouse.
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
- * Unlike the old server-rendered `/admin`, the console is React API Stack's own
10
- * React admin (`tools/admin`, built from `rastack/components` + `rastack/theme`)
11
- * — this command compiles the resources, points the server at the prebuilt
12
- * bundle (`RASTACK_ADMIN_ASSETS`), and boots it. The bundle talks back to the
13
- * same server's `/api` over HTTP, so edits persist to the real warehouse.
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 bundle = path.join(HERE, "admin.js");
85
- if (!fs.existsSync(bundle)) {
86
- console.error(" ✗ admin bundle (dist/admin.js) not found — reinstall rastack.");
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 repoRoot = path.resolve(HERE, "..", "..");
90
- const binary = process.env.RASTACK_SERVER_BIN ||
91
- path.join(repoRoot, "rust", "target", "release", "rastack-server");
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
- child.on("exit", (code) => process.exit(code ?? 0));
118
- // Give the server a moment to bind, then print + open.
119
- setTimeout(() => {
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
- }, 700);
169
+ });
125
170
  }
126
171
  if (require.main === module) {
127
172
  main(process.argv.slice(2));
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rastack",
3
- "version": "0.0.47",
3
+ "version": "0.0.49",
4
4
  "description": "",
5
5
  "main": "runtime.ts",
6
6
  "types": "runtime.ts",
@@ -4,55 +4,67 @@ import { FieldModel, RelationModel, ResourceModel, ScalarType } from "./model";
4
4
  import { resourceSourceFiles } from "./program";
5
5
 
6
6
  /**
7
- * Interface-driven resource inference — "the entities in the app are a list of
8
- * interfaces".
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 hooks. The
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. An interface can
17
- * also opt in explicitly with a `@rastackResource` JSDoc tag.
18
- * 2. **Datatypes come from the interfaces.** Each property's TypeScript type
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 interface
27
- * is pulled into the resource graph transitively — the closure of the
28
- * hook-rooted interfaces is the app's resource set.
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 interface Airport {
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 interface Terminal {
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` prefix as
51
- * in generated `IFlight` interfaces is stripped, so hook usage of generated
52
- * types round-trips onto the same resource); `app` defaults to the defining
53
- * file's basename. Both are overridable with `@rastackApp` / `@rastackModel` tags.
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<ts.InterfaceDeclaration>();
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: interfaces used as type arguments of hook calls, plus explicit
85
- // `@rastackResource` opt-ins.
86
- const roots: ts.InterfaceDeclaration[] = [];
87
- const rooted = new Set<ts.InterfaceDeclaration>();
88
- const addRoot = (decl: ts.InterfaceDeclaration) => {
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 = interfaceBehindTypeNode(typeArg, checker, scanned);
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 interface-typed
110
- // property pulls the referenced interface in as an entity too.
111
- const propsByDecl = new Map<ts.InterfaceDeclaration, PropInfo[]>();
112
- const analyzed = (decl: ts.InterfaceDeclaration): PropInfo[] => {
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: ts.InterfaceDeclaration[] = [];
122
- const seen = new Set<ts.InterfaceDeclaration>();
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 an interface declared in one of the scanned files. */
204
- function interfaceBehindTypeNode(
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
- ): ts.InterfaceDeclaration | undefined {
209
- return interfaceBehindType(checker.getTypeFromTypeNode(node), scanned);
223
+ ): EntityDecl | undefined {
224
+ return entityBehindType(checker.getTypeFromTypeNode(node), scanned);
210
225
  }
211
226
 
212
- function interfaceBehindType(
227
+ function entityBehindType(
213
228
  type: ts.Type,
214
229
  scanned: Set<string>,
215
- ): ts.InterfaceDeclaration | undefined {
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 interface (relation resolved after naming). */
236
- target?: ts.InterfaceDeclaration;
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: ts.InterfaceDeclaration,
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
- if (!ts.isPropertySignature(member) || !member.type) continue;
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(member.name) || ts.isStringLiteralLike(member.name)
253
- ? member.name.text
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, member, checker, scanned));
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: ts.PropertySignature,
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 (interfaceBehindTypeNode(element, checker, scanned)) {
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 (→ FK), or
317
- * unsupported. Intersections classify by their first classifiable constituent,
318
- * which is what lets `DbConfig` brands ride along — `string & Unique` is a
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 | ts.InterfaceDeclaration | undefined {
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 = interfaceBehindType(type, scanned);
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: ts.InterfaceDeclaration): EntityNaming {
580
+ function entityNaming(decl: EntityDecl): EntityNaming {
480
581
  const tags = jsdocTags(decl);
481
- const raw = decl.name.text;
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;
@@ -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-driven entity graph; co-located helpers are harmless.
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;
@@ -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-driven entity graph.
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
@@ -1,22 +1,36 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  /**
4
- * `rastack admin` — the full, Django-admin-style console, served by the native
5
- * `rastack-server` against the real Iceberg warehouse.
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
- * Unlike the old server-rendered `/admin`, the console is React API Stack's own
10
- * React admin (`tools/admin`, built from `rastack/components` + `rastack/theme`)
11
- * — this command compiles the resources, points the server at the prebuilt
12
- * bundle (`RASTACK_ADMIN_ASSETS`), and boots it. The bundle talks back to the
13
- * same server's `/api` over HTTP, so edits persist to the real warehouse.
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, spawn, spawnSync } from "child_process";
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 { parseAdminArgs } from "./dev/harness";
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 bundle = path.join(HERE, "admin.js");
61
- if (!fs.existsSync(bundle)) {
62
- console.error(" ✗ admin bundle (dist/admin.js) not found — reinstall rastack.");
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 repoRoot = path.resolve(HERE, "..", "..");
67
- const binary =
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
- ` ✗ Could not run the Rust server (${binary}). Build it first:\n` +
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
- // Give the server a moment to bind, then print + open.
103
- setTimeout(() => {
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
- }, 700);
166
+ });
108
167
  }
109
168
 
110
169
  if (require.main === module) {
@@ -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