rastack 0.0.22 → 0.0.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/CHANGELOG.md +2 -0
  2. package/cache/cache.ts +1 -1
  3. package/cache/revocation.ts +1 -1
  4. package/cache/storage.ts +1 -1
  5. package/cache/types.ts +1 -1
  6. package/define.ts +2 -0
  7. package/dist/compile/analyze.js +3 -3
  8. package/dist/compile/entities.d.ts +68 -0
  9. package/dist/compile/entities.js +496 -0
  10. package/dist/compile/index.d.ts +24 -7
  11. package/dist/compile/index.js +48 -16
  12. package/dist/compile/model.d.ts +2 -2
  13. package/dist/compile/model.js +1 -1
  14. package/dist/compile/program.d.ts +2 -1
  15. package/dist/compile/program.js +8 -3
  16. package/dist/csv-schema.js +1 -1
  17. package/dist/define/db.d.ts +73 -0
  18. package/dist/define/db.js +35 -0
  19. package/dist/define/index.d.ts +8 -8
  20. package/dist/define/index.js +10 -10
  21. package/dist/index.js +1 -1
  22. package/dist/rastack-compile.d.ts +11 -0
  23. package/dist/{rad-compile.js → rastack-compile.js} +22 -12
  24. package/dist/{rad-wasm-build.d.ts → rastack-wasm-build.d.ts} +2 -2
  25. package/dist/{rad-wasm-build.js → rastack-wasm-build.js} +6 -6
  26. package/dist/rastack.d.ts +17 -0
  27. package/dist/{rad.js → rastack.js} +24 -24
  28. package/dist/scan.d.ts +3 -3
  29. package/dist/scan.js +4 -4
  30. package/dist/seed.d.ts +2 -2
  31. package/dist/seed.js +9 -9
  32. package/hooks/real-time/pusher.ts +2 -2
  33. package/package.json +2 -3
  34. package/provider/provider.tsx +12 -12
  35. package/provider/types.ts +16 -16
  36. package/provider/warehouse.ts +1 -1
  37. package/provider/wasm.ts +10 -10
  38. package/src/compile/analyze.ts +3 -3
  39. package/src/compile/entities.ts +602 -0
  40. package/src/compile/index.ts +57 -15
  41. package/src/compile/model.ts +2 -2
  42. package/src/compile/program.ts +8 -3
  43. package/src/csv-schema.ts +1 -1
  44. package/src/define/db.ts +64 -0
  45. package/src/define/index.ts +16 -16
  46. package/src/index.ts +1 -1
  47. package/src/{rad-compile.ts → rastack-compile.ts} +30 -13
  48. package/src/{rad-wasm-build.ts → rastack-wasm-build.ts} +6 -6
  49. package/src/{rad.ts → rastack.ts} +24 -24
  50. package/src/scan.ts +4 -4
  51. package/src/seed.ts +9 -9
  52. package/sync/engine.ts +1 -1
  53. package/sync/types.ts +1 -1
  54. package/test/cache.spec.ts +1 -1
  55. package/test/compile.spec.ts +6 -6
  56. package/test/csv-schema.spec.ts +2 -2
  57. package/test/entities.spec.ts +381 -0
  58. package/types.ts +3 -0
  59. package/dist/rad-compile.d.ts +0 -11
  60. package/dist/rad.d.ts +0 -17
package/CHANGELOG.md CHANGED
@@ -2,6 +2,8 @@
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.23](https://github.com/theserverkid/reactapistack/compare/v0.0.22...v0.0.23) (2026-07-09)
6
+
5
7
  ### [0.0.22](https://github.com/theserverkid/reactapistack/compare/v0.0.21...v0.0.22) (2026-07-09)
6
8
 
7
9
  ### [0.0.21](https://github.com/theserverkid/reactapistack/compare/v0.0.20...v0.0.21) (2026-07-08)
package/cache/cache.ts CHANGED
@@ -11,7 +11,7 @@
11
11
  * written they never change — so a cached copy is correct forever
12
12
  * (cache-first; the network is only touched for files never seen before).
13
13
  *
14
- * Seeding hands the cached bytes to the WASM engine (`RadApi.seedFile`), which
14
+ * Seeding hands the cached bytes to the WASM engine (`RastackApi.seedFile`), which
15
15
  * reads the *actual* Parquet/metadata files — the cache is a transparent layer
16
16
  * between object storage and the engine, not a new store format.
17
17
  *
@@ -31,7 +31,7 @@ export interface PurgeLocalDataOptions {
31
31
  cacheStorage?: CacheStorage;
32
32
  /**
33
33
  * IndexedDB databases to delete outright — the persisted warehouse snapshot
34
- * (`rad-warehouse…`) and the sync-engine store, which hold row data derived
34
+ * (`rastack-warehouse…`) and the sync-engine store, which hold row data derived
35
35
  * from the revoked access.
36
36
  */
37
37
  databases?: string[];
package/cache/storage.ts CHANGED
@@ -33,7 +33,7 @@ function openDb(name: string): Promise<any> {
33
33
  export class IndexedDbCacheStorage implements CacheStorage {
34
34
  private db: Promise<any>;
35
35
 
36
- constructor(databaseName = "rad-iceberg-cache") {
36
+ constructor(databaseName = "rastack-iceberg-cache") {
37
37
  this.db = openDb(databaseName);
38
38
  }
39
39
 
package/cache/types.ts CHANGED
@@ -36,7 +36,7 @@ export interface WarehouseFileSource {
36
36
  fetchFile(key: string): Promise<Uint8Array | null>;
37
37
  }
38
38
 
39
- /** The engine surface seeding needs (the WASM `RadApi`). */
39
+ /** The engine surface seeding needs (the WASM `RastackApi`). */
40
40
  export interface SeedableEngine {
41
41
  seedFile(key: string, bytes: Uint8Array): Promise<void>;
42
42
  }
package/define.ts ADDED
@@ -0,0 +1,2 @@
1
+ /** `rastack/define` — the resource() authoring DSL. */
2
+ export * from "./src/define";
@@ -117,7 +117,7 @@ function analyzeField(name, initializer, checker) {
117
117
  const fieldType = checker.getTypeAtLocation(initializer);
118
118
  // Foreign key: the field type is Ref<App, Model>. Recover the target from
119
119
  // the string-literal type arguments — this is the "infer FK from types" step.
120
- if (fieldType.getProperty("__radRef")) {
120
+ if (fieldType.getProperty("__rastackRef")) {
121
121
  const app = literalTypeProp(fieldType, "__app", checker, initializer);
122
122
  const model = literalTypeProp(fieldType, "__model", checker, initializer);
123
123
  if (app && model) {
@@ -125,8 +125,8 @@ function analyzeField(name, initializer, checker) {
125
125
  }
126
126
  return undefined;
127
127
  }
128
- // Scalar: the field type carries a `__radScalar` string-literal discriminant.
129
- const scalar = literalTypeProp(fieldType, "__radScalar", checker, initializer);
128
+ // Scalar: the field type carries a `__rastackScalar` string-literal discriminant.
129
+ const scalar = literalTypeProp(fieldType, "__rastackScalar", checker, initializer);
130
130
  if (!scalar)
131
131
  return undefined;
132
132
  const field = { name, type: scalar };
@@ -0,0 +1,68 @@
1
+ import * as ts from "typescript";
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
+ export interface EntityDiagnostic {
53
+ severity: "error" | "warning";
54
+ message: string;
55
+ }
56
+ export interface EntityAnalysis {
57
+ resources: ResourceModel[];
58
+ diagnostics: EntityDiagnostic[];
59
+ }
60
+ export declare function analyzeEntities(program: ts.Program, fileNames: string[]): EntityAnalysis;
61
+ /**
62
+ * Detect cycles in the FK graph. Works on the *merged* resource set, so cycles
63
+ * authored through the `resource()` DSL are caught too. A cycle every edge of
64
+ * which is required is an error — no row of any resource in the cycle could
65
+ * ever be inserted. A cycle containing at least one nullable FK (an optional
66
+ * interface property) is breakable and reported as a warning.
67
+ */
68
+ export declare function detectCycles(resources: ResourceModel[]): EntityDiagnostic[];
@@ -0,0 +1,496 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.analyzeEntities = analyzeEntities;
37
+ exports.detectCycles = detectCycles;
38
+ const path = __importStar(require("path"));
39
+ const ts = __importStar(require("typescript"));
40
+ const program_1 = require("./program");
41
+ function analyzeEntities(program, fileNames) {
42
+ const checker = program.getTypeChecker();
43
+ const diagnostics = [];
44
+ const sourceFiles = (0, program_1.resourceSourceFiles)(program, fileNames);
45
+ const scanned = new Set(sourceFiles.map((sf) => path.resolve(sf.fileName)));
46
+ // Every interface declared in the scanned files is a *candidate* entity.
47
+ const candidates = new Set();
48
+ for (const sf of sourceFiles) {
49
+ ts.forEachChild(sf, function walk(node) {
50
+ if (ts.isInterfaceDeclaration(node))
51
+ candidates.add(node);
52
+ ts.forEachChild(node, walk);
53
+ });
54
+ }
55
+ // 1. Roots: interfaces used as type arguments of hook calls, plus explicit
56
+ // `@rastackResource` opt-ins.
57
+ const roots = [];
58
+ const rooted = new Set();
59
+ const addRoot = (decl) => {
60
+ if (!rooted.has(decl)) {
61
+ rooted.add(decl);
62
+ roots.push(decl);
63
+ }
64
+ };
65
+ for (const sf of sourceFiles) {
66
+ ts.forEachChild(sf, function walk(node) {
67
+ if (ts.isCallExpression(node) && isHookCall(node)) {
68
+ for (const typeArg of node.typeArguments ?? []) {
69
+ const decl = interfaceBehindTypeNode(typeArg, checker, scanned);
70
+ if (decl && candidates.has(decl))
71
+ addRoot(decl);
72
+ }
73
+ }
74
+ ts.forEachChild(node, walk);
75
+ });
76
+ }
77
+ for (const decl of candidates) {
78
+ if (jsdocTags(decl).has("rastackresource"))
79
+ addRoot(decl);
80
+ }
81
+ // 2. Transitive closure over FK references: an entity's interface-typed
82
+ // property pulls the referenced interface in as an entity too.
83
+ const propsByDecl = new Map();
84
+ const analyzed = (decl) => {
85
+ let props = propsByDecl.get(decl);
86
+ if (!props) {
87
+ props = analyzeProperties(decl, checker, scanned);
88
+ propsByDecl.set(decl, props);
89
+ }
90
+ return props;
91
+ };
92
+ const included = [];
93
+ const seen = new Set();
94
+ const queue = [...roots];
95
+ while (queue.length) {
96
+ const decl = queue.shift();
97
+ if (seen.has(decl))
98
+ continue;
99
+ seen.add(decl);
100
+ included.push(decl);
101
+ for (const prop of analyzed(decl)) {
102
+ if (prop.target && candidates.has(prop.target) && !seen.has(prop.target)) {
103
+ queue.push(prop.target);
104
+ }
105
+ }
106
+ }
107
+ // 3. Build the canonical models.
108
+ const namingByDecl = new Map(included.map((decl) => [decl, entityNaming(decl)]));
109
+ const resources = [];
110
+ for (const decl of included) {
111
+ const naming = namingByDecl.get(decl);
112
+ const key = `${naming.app}.${naming.model}`;
113
+ const fields = [];
114
+ const relations = [];
115
+ for (const prop of analyzed(decl)) {
116
+ if (prop.skip) {
117
+ diagnostics.push({ severity: "warning", message: `${key}.${prop.name}: ${prop.skip}` });
118
+ continue;
119
+ }
120
+ if (prop.inverse)
121
+ continue; // has-many arrays are the reverse side of a FK
122
+ if (prop.target) {
123
+ const target = namingByDecl.get(prop.target);
124
+ if (!target)
125
+ continue; // unreachable: closure included every target
126
+ const field = {
127
+ name: prop.name,
128
+ type: "fk",
129
+ relation: { app: target.app, model: target.model },
130
+ };
131
+ if (prop.nullable)
132
+ field.null = true;
133
+ fields.push(field);
134
+ relations.push({
135
+ field: prop.name,
136
+ target: `${target.app}.${target.model}`,
137
+ kind: "fk",
138
+ });
139
+ }
140
+ else if (prop.field) {
141
+ fields.push(prop.field);
142
+ }
143
+ }
144
+ resources.push({
145
+ app: naming.app,
146
+ model: naming.model,
147
+ fields,
148
+ relations,
149
+ search: naming.search,
150
+ permission: naming.permission,
151
+ });
152
+ }
153
+ resources.sort((a, b) => a.app === b.app ? a.model.localeCompare(b.model) : a.app.localeCompare(b.app));
154
+ return { resources, diagnostics };
155
+ }
156
+ // -- hooks -------------------------------------------------------------------
157
+ /** A React-style hook call: `useX(...)` or `obj.useX(...)` with type args. */
158
+ function isHookCall(call) {
159
+ if (!call.typeArguments?.length)
160
+ return false;
161
+ const callee = call.expression;
162
+ const name = ts.isIdentifier(callee)
163
+ ? callee.text
164
+ : ts.isPropertyAccessExpression(callee)
165
+ ? callee.name.text
166
+ : undefined;
167
+ return !!name && /^use[A-Z0-9_]/.test(name);
168
+ }
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);
172
+ }
173
+ function interfaceBehindType(type, scanned) {
174
+ const symbol = type.aliasSymbol ?? type.getSymbol();
175
+ for (const decl of symbol?.declarations ?? []) {
176
+ if (ts.isInterfaceDeclaration(decl) &&
177
+ scanned.has(path.resolve(decl.getSourceFile().fileName))) {
178
+ return decl;
179
+ }
180
+ }
181
+ return undefined;
182
+ }
183
+ function analyzeProperties(decl, checker, scanned) {
184
+ const props = [];
185
+ for (const member of decl.members) {
186
+ if (!ts.isPropertySignature(member) || !member.type)
187
+ continue;
188
+ const name = ts.isIdentifier(member.name) || ts.isStringLiteralLike(member.name)
189
+ ? member.name.text
190
+ : undefined;
191
+ if (!name)
192
+ continue;
193
+ props.push(analyzeProperty(name, member, checker, scanned));
194
+ }
195
+ return props;
196
+ }
197
+ function analyzeProperty(name, member, checker, scanned) {
198
+ let nullable = !!member.questionToken;
199
+ // Unwrap parens and unions: strip null/undefined (→ nullable), and require a
200
+ // single remaining arm (unions of string literals collapse to `string`).
201
+ let typeNode = unwrapParens(member.type);
202
+ if (ts.isUnionTypeNode(typeNode)) {
203
+ const arms = typeNode.types.filter((t) => {
204
+ const isNullish = (ts.isLiteralTypeNode(t) && t.literal.kind === ts.SyntaxKind.NullKeyword) ||
205
+ t.kind === ts.SyntaxKind.UndefinedKeyword;
206
+ if (isNullish)
207
+ nullable = true;
208
+ return !isNullish;
209
+ });
210
+ if (arms.length > 1 && arms.every(isStringish)) {
211
+ return scalarProp(name, "string", configFromTags(jsdocTags(member)), nullable);
212
+ }
213
+ if (arms.length !== 1) {
214
+ return { name, nullable, skip: "unsupported union type — skipped" };
215
+ }
216
+ typeNode = unwrapParens(arms[0]);
217
+ }
218
+ // Arrays: `Entity[]` is the reverse side of a FK — it contributes no column
219
+ // but is intentionally not an error. Arrays of scalars have no manifest shape.
220
+ const element = arrayElement(typeNode);
221
+ if (element) {
222
+ if (interfaceBehindTypeNode(element, checker, scanned)) {
223
+ return { name, nullable, inverse: true };
224
+ }
225
+ return { name, nullable, skip: "array fields are not supported — skipped" };
226
+ }
227
+ const type = checker.getTypeFromTypeNode(typeNode);
228
+ const base = classifyType(type, scanned);
229
+ if (base === undefined) {
230
+ return { name, nullable, skip: "unsupported type — skipped" };
231
+ }
232
+ if (typeof base !== "string") {
233
+ return { name, nullable, target: base }; // entity interface → FK
234
+ }
235
+ const config = {
236
+ ...configFromTags(jsdocTags(member)),
237
+ ...configFromType(type, checker, member),
238
+ };
239
+ return scalarProp(name, base, config, nullable);
240
+ }
241
+ /**
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.
246
+ */
247
+ function classifyType(type, scanned) {
248
+ if (type.flags & ts.TypeFlags.StringLike)
249
+ return "string";
250
+ if (type.flags & ts.TypeFlags.NumberLike)
251
+ return "float";
252
+ if (type.flags & ts.TypeFlags.BooleanLike)
253
+ return "bool";
254
+ if (type.isIntersection()) {
255
+ for (const part of type.types) {
256
+ const classified = classifyType(part, scanned);
257
+ if (classified)
258
+ return classified;
259
+ }
260
+ return undefined;
261
+ }
262
+ if (type.getProperty("__rastackDb"))
263
+ return undefined; // a bare config brand
264
+ const decl = interfaceBehindType(type, scanned);
265
+ if (decl)
266
+ return decl;
267
+ if (type.getSymbol()?.getName() === "Date")
268
+ return "datetime";
269
+ return undefined;
270
+ }
271
+ /** Build a scalar field, letting the column config refine type + constraints. */
272
+ function scalarProp(name, base, config, nullable) {
273
+ let type = base;
274
+ if (base === "float" && config.int)
275
+ type = "int";
276
+ if (base === "string" && config.uuid)
277
+ type = "uuid";
278
+ if (base === "string" && config.datetime)
279
+ type = "datetime";
280
+ const field = { name, type };
281
+ if (config.maxLength !== undefined)
282
+ field.maxLength = config.maxLength;
283
+ if (config.unique)
284
+ field.unique = true;
285
+ if (config.primaryKey)
286
+ field.primaryKey = true;
287
+ if (nullable)
288
+ field.null = true;
289
+ if (config.default !== undefined)
290
+ field.default = config.default;
291
+ return { name, nullable, field };
292
+ }
293
+ /**
294
+ * Config carried in the type system: intersecting a property type with
295
+ * `DbConfig` brands (`string & Unique & MaxLength<3>`, or a custom
296
+ * `interface Registration extends DbConfig<{…}>`) surfaces a phantom
297
+ * `__rastackDb` property whose literal type arguments are the configuration.
298
+ */
299
+ function configFromType(type, checker, location) {
300
+ const config = {};
301
+ const symbol = type.getProperty("__rastackDb");
302
+ if (!symbol)
303
+ return config;
304
+ const carrier = checker
305
+ .getTypeOfSymbolAtLocation(symbol, location)
306
+ .getNonNullableType();
307
+ const prop = (name) => {
308
+ const p = carrier.getProperty(name);
309
+ return p && checker.getTypeOfSymbolAtLocation(p, location);
310
+ };
311
+ if (prop("int"))
312
+ config.int = true;
313
+ if (prop("uuid"))
314
+ config.uuid = true;
315
+ if (prop("datetime"))
316
+ config.datetime = true;
317
+ if (prop("unique"))
318
+ config.unique = true;
319
+ if (prop("primaryKey"))
320
+ config.primaryKey = true;
321
+ const maxLength = prop("maxLength");
322
+ if (maxLength?.isNumberLiteral())
323
+ config.maxLength = maxLength.value;
324
+ const dflt = prop("default");
325
+ const value = dflt ? literalValue(dflt, checker) : undefined;
326
+ if (value !== undefined)
327
+ config.default = value;
328
+ return config;
329
+ }
330
+ /** Config carried as JSDoc tags (`@unique`, `@maxLength 3`, `@default x`). */
331
+ function configFromTags(tags) {
332
+ const config = {};
333
+ if (tags.has("int"))
334
+ config.int = true;
335
+ if (tags.has("uuid"))
336
+ config.uuid = true;
337
+ if (tags.has("datetime"))
338
+ config.datetime = true;
339
+ if (tags.has("unique"))
340
+ config.unique = true;
341
+ if (tags.has("primarykey"))
342
+ config.primaryKey = true;
343
+ const maxLength = Number(tags.get("maxlength"));
344
+ if (Number.isFinite(maxLength) && maxLength > 0)
345
+ config.maxLength = maxLength;
346
+ const dflt = tags.get("default");
347
+ if (dflt !== undefined) {
348
+ try {
349
+ config.default = JSON.parse(dflt);
350
+ }
351
+ catch {
352
+ config.default = dflt;
353
+ }
354
+ }
355
+ return config;
356
+ }
357
+ /** The JS value of a literal type (`"active"`, `0`, `true`), else undefined. */
358
+ function literalValue(type, checker) {
359
+ if (type.isStringLiteral() || type.isNumberLiteral())
360
+ return type.value;
361
+ if (type.flags & ts.TypeFlags.BooleanLiteral) {
362
+ return checker.typeToString(type) === "true";
363
+ }
364
+ return undefined;
365
+ }
366
+ function unwrapParens(node) {
367
+ while (ts.isParenthesizedTypeNode(node))
368
+ node = node.type;
369
+ return node;
370
+ }
371
+ /** `string`, a string-literal type, or a template literal type. */
372
+ function isStringish(node) {
373
+ if (node.kind === ts.SyntaxKind.StringKeyword)
374
+ return true;
375
+ if (ts.isLiteralTypeNode(node) && ts.isStringLiteralLike(node.literal))
376
+ return true;
377
+ return ts.isTemplateLiteralTypeNode(node);
378
+ }
379
+ /** Element type of `T[]` / `Array<T>` / `ReadonlyArray<T>`, else undefined. */
380
+ function arrayElement(node) {
381
+ if (ts.isArrayTypeNode(node))
382
+ return node.elementType;
383
+ if (ts.isTypeReferenceNode(node) &&
384
+ ts.isIdentifier(node.typeName) &&
385
+ (node.typeName.text === "Array" || node.typeName.text === "ReadonlyArray") &&
386
+ node.typeArguments?.length === 1) {
387
+ return node.typeArguments[0];
388
+ }
389
+ return undefined;
390
+ }
391
+ function entityNaming(decl) {
392
+ const tags = jsdocTags(decl);
393
+ const raw = decl.name.text;
394
+ // Generated entity interfaces are `I`-prefixed (`IFlight`); strip the prefix
395
+ // so hook usage of generated types resolves to the same resource.
396
+ const stripped = /^I[A-Z]/.test(raw) ? raw.slice(1) : raw;
397
+ const model = tags.get("rastackmodel") || stripped.charAt(0).toLowerCase() + stripped.slice(1);
398
+ const app = tags.get("rastackapp") ||
399
+ path
400
+ .basename(decl.getSourceFile().fileName)
401
+ .replace(/\.(ts|tsx)$/, "")
402
+ .toLowerCase();
403
+ const naming = { app, model };
404
+ const search = tags.get("rastacksearch");
405
+ if (search)
406
+ naming.search = search.split(",").map((s) => s.trim()).filter(Boolean);
407
+ const permission = tags.get("rastackpermission");
408
+ if (permission === "authenticatedOrReadOnly" ||
409
+ permission === "authenticated" ||
410
+ permission === "public") {
411
+ naming.permission = permission;
412
+ }
413
+ return naming;
414
+ }
415
+ /** All JSDoc tags on a node, keyed by lower-cased tag name → comment text. */
416
+ function jsdocTags(node) {
417
+ const tags = new Map();
418
+ for (const tag of ts.getJSDocTags(node)) {
419
+ const text = ts.getTextOfJSDocComment(tag.comment) ?? "";
420
+ tags.set(tag.tagName.text.toLowerCase(), text.trim());
421
+ }
422
+ return tags;
423
+ }
424
+ // -- circular-dependency detection ---------------------------------------------
425
+ /**
426
+ * Detect cycles in the FK graph. Works on the *merged* resource set, so cycles
427
+ * authored through the `resource()` DSL are caught too. A cycle every edge of
428
+ * which is required is an error — no row of any resource in the cycle could
429
+ * ever be inserted. A cycle containing at least one nullable FK (an optional
430
+ * interface property) is breakable and reported as a warning.
431
+ */
432
+ function detectCycles(resources) {
433
+ const adjacency = new Map();
434
+ for (const r of resources) {
435
+ const from = `${r.app}.${r.model}`;
436
+ const edges = [];
437
+ for (const rel of r.relations) {
438
+ const field = r.fields.find((f) => f.name === rel.field);
439
+ edges.push({
440
+ from,
441
+ to: rel.target,
442
+ field: rel.field,
443
+ nullable: field?.null === true,
444
+ });
445
+ }
446
+ adjacency.set(from, edges);
447
+ }
448
+ const diagnostics = [];
449
+ const reported = new Set();
450
+ const state = new Map(); // 1 = on stack, 2 = done
451
+ const stack = [];
452
+ const reportCycle = (closingEdge) => {
453
+ const start = stack.findIndex((frame) => frame.node === closingEdge.to);
454
+ const nodes = stack.slice(start).map((frame) => frame.node);
455
+ const edges = [
456
+ ...stack.slice(start + 1).map((frame) => frame.edgeIn),
457
+ closingEdge,
458
+ ];
459
+ const key = [...nodes].sort().join("|");
460
+ if (reported.has(key))
461
+ return;
462
+ reported.add(key);
463
+ const chain = [...nodes, closingEdge.to].join(" → ");
464
+ const breakable = edges.find((e) => e.nullable);
465
+ diagnostics.push(breakable
466
+ ? {
467
+ severity: "warning",
468
+ message: `Circular dependency: ${chain} — breakable via nullable ` +
469
+ `field ${breakable.from}.${breakable.field}.`,
470
+ }
471
+ : {
472
+ severity: "error",
473
+ message: `Circular dependency: ${chain} — every foreign key in the ` +
474
+ `cycle is required, so no row can ever be inserted. Make ` +
475
+ `one of the fields optional to break the cycle.`,
476
+ });
477
+ };
478
+ const visit = (node, edgeIn) => {
479
+ state.set(node, 1);
480
+ stack.push({ node, edgeIn });
481
+ for (const edge of adjacency.get(node) ?? []) {
482
+ const targetState = state.get(edge.to);
483
+ if (targetState === 1)
484
+ reportCycle(edge);
485
+ else if (targetState !== 2)
486
+ visit(edge.to, edge);
487
+ }
488
+ stack.pop();
489
+ state.set(node, 2);
490
+ };
491
+ for (const node of adjacency.keys()) {
492
+ if (!state.has(node))
493
+ visit(node);
494
+ }
495
+ return diagnostics;
496
+ }
@@ -1,5 +1,8 @@
1
+ import { EntityDiagnostic } from "./entities";
1
2
  import { Manifest, ResourceModel } from "./model";
2
3
  export { analyze } from "./analyze";
4
+ export { analyzeEntities, detectCycles } from "./entities";
5
+ export type { EntityAnalysis, EntityDiagnostic } from "./entities";
3
6
  export { buildManifest } from "./manifest";
4
7
  export { buildOpenApi } from "./openapi";
5
8
  export * from "./model";
@@ -7,17 +10,31 @@ export interface CompileResult {
7
10
  manifest: Manifest;
8
11
  openapi: any;
9
12
  files: string[];
13
+ diagnostics: EntityDiagnostic[];
10
14
  }
11
15
  /**
12
- * Every `.ts` resource file under a directory (recursively), or an explicit
13
- * file. Recursion is what lets resource definitions live **co-located with the
14
- * pages that consume them** — e.g. `app/airports/_schema.rad.ts` beside
15
- * `app/airports/index.tsx` — instead of in one top-level `resources/` folder.
16
- * `.tsx` route components and `.d.ts` files are skipped; only files with a
17
- * `resource(...)` call contribute to the manifest, so co-located helpers are
18
- * harmless.
16
+ * Every `.ts`/`.tsx` source file under a directory (recursively), or an
17
+ * explicit file. Recursion is what lets resource definitions live **co-located
18
+ * with the pages that consume them** — e.g. `app/airports/_schema.rastack.ts`
19
+ * beside `app/airports/index.tsx` — instead of in one top-level `resources/`
20
+ * folder. `.tsx` files carry no `resource(...)` definitions but are scanned
21
+ * for the typed hook calls (`useEntityList<Airport>(…)`) that root the
22
+ * interface-driven entity graph; co-located helpers are harmless.
19
23
  */
20
24
  export declare function resolveResourceFiles(input: string): string[];
25
+ export interface ProjectAnalysis {
26
+ resources: ResourceModel[];
27
+ diagnostics: EntityDiagnostic[];
28
+ }
29
+ /**
30
+ * Analyse a project into the canonical model: explicit `resource()` DSL
31
+ * definitions plus resources inferred from hook-rooted TypeScript interfaces.
32
+ * When both surfaces define the same `app.model`, the explicit DSL definition
33
+ * wins — hook usage of a generated `IAirport` interface resolves back onto the
34
+ * authored resource instead of creating a duplicate. Cycle detection runs over
35
+ * the merged FK graph.
36
+ */
37
+ export declare function analyzeProject(input: string): ProjectAnalysis;
21
38
  /** Analyse resource definitions into the canonical model (no file output). */
22
39
  export declare function analyzeResources(input: string): ResourceModel[];
23
40
  /** Compile resource definitions to a manifest + OpenAPI, writing both to disk. */