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
@@ -0,0 +1,602 @@
1
+ import * as path from "path";
2
+ import * as ts from "typescript";
3
+ import { FieldModel, RelationModel, ResourceModel, ScalarType } from "./model";
4
+ import { resourceSourceFiles } from "./program";
5
+
6
+ /**
7
+ * Interface-driven resource inference — "the entities in the app are a list of
8
+ * interfaces".
9
+ *
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*:
13
+ *
14
+ * 1. **Roots come from the hooks.** Every hook call that carries a type
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
19
+ * maps to a manifest scalar (`string` → string, `number` → float,
20
+ * `boolean` → bool, `Date` → datetime). Constraints are carried in the
21
+ * type system with `DbConfig` brands from `rastack/types` —
22
+ * `code: string & Unique & MaxLength<3>`, `age: Int` — or, equivalently,
23
+ * as JSDoc tags (`@unique`, `@maxLength 3`, `@int`, `@uuid`, `@default`).
24
+ * Optional / `| null` properties become nullable fields.
25
+ * 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.
29
+ * 4. **Circular dependencies are detected.** The FK graph is walked for
30
+ * cycles; a cycle is an *error* when every edge in it is required (no row
31
+ * could ever be inserted) and a *warning* when it is breakable through a
32
+ * nullable field (e.g. an optional self-referencing `manager`).
33
+ *
34
+ * ```ts
35
+ * // entities/airports.ts
36
+ * export interface Airport {
37
+ * code: string; // JSDoc tags refine: @maxLength 3, @unique
38
+ * name: string;
39
+ * }
40
+ * export interface Terminal {
41
+ * airport: Airport; // FK — inferred from the property type
42
+ * label: string;
43
+ * }
44
+ *
45
+ * // app/screen.tsx — the hook call is what roots the graph
46
+ * const terminals = useEntityList<Terminal>("terminals");
47
+ * // → resources: airports.terminal (root) + airports.airport (transitive)
48
+ * ```
49
+ *
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.
54
+ */
55
+
56
+ export interface EntityDiagnostic {
57
+ severity: "error" | "warning";
58
+ message: string;
59
+ }
60
+
61
+ export interface EntityAnalysis {
62
+ resources: ResourceModel[];
63
+ diagnostics: EntityDiagnostic[];
64
+ }
65
+
66
+ export function analyzeEntities(
67
+ program: ts.Program,
68
+ fileNames: string[],
69
+ ): EntityAnalysis {
70
+ const checker = program.getTypeChecker();
71
+ const diagnostics: EntityDiagnostic[] = [];
72
+ const sourceFiles = resourceSourceFiles(program, fileNames);
73
+ const scanned = new Set(sourceFiles.map((sf) => path.resolve(sf.fileName)));
74
+
75
+ // Every interface declared in the scanned files is a *candidate* entity.
76
+ const candidates = new Set<ts.InterfaceDeclaration>();
77
+ for (const sf of sourceFiles) {
78
+ ts.forEachChild(sf, function walk(node) {
79
+ if (ts.isInterfaceDeclaration(node)) candidates.add(node);
80
+ ts.forEachChild(node, walk);
81
+ });
82
+ }
83
+
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) => {
89
+ if (!rooted.has(decl)) {
90
+ rooted.add(decl);
91
+ roots.push(decl);
92
+ }
93
+ };
94
+ for (const sf of sourceFiles) {
95
+ ts.forEachChild(sf, function walk(node) {
96
+ if (ts.isCallExpression(node) && isHookCall(node)) {
97
+ for (const typeArg of node.typeArguments ?? []) {
98
+ const decl = interfaceBehindTypeNode(typeArg, checker, scanned);
99
+ if (decl && candidates.has(decl)) addRoot(decl);
100
+ }
101
+ }
102
+ ts.forEachChild(node, walk);
103
+ });
104
+ }
105
+ for (const decl of candidates) {
106
+ if (jsdocTags(decl).has("rastackresource")) addRoot(decl);
107
+ }
108
+
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[] => {
113
+ let props = propsByDecl.get(decl);
114
+ if (!props) {
115
+ props = analyzeProperties(decl, checker, scanned);
116
+ propsByDecl.set(decl, props);
117
+ }
118
+ return props;
119
+ };
120
+
121
+ const included: ts.InterfaceDeclaration[] = [];
122
+ const seen = new Set<ts.InterfaceDeclaration>();
123
+ const queue = [...roots];
124
+ while (queue.length) {
125
+ const decl = queue.shift()!;
126
+ if (seen.has(decl)) continue;
127
+ seen.add(decl);
128
+ included.push(decl);
129
+ for (const prop of analyzed(decl)) {
130
+ if (prop.target && candidates.has(prop.target) && !seen.has(prop.target)) {
131
+ queue.push(prop.target);
132
+ }
133
+ }
134
+ }
135
+
136
+ // 3. Build the canonical models.
137
+ const namingByDecl = new Map(
138
+ included.map((decl) => [decl, entityNaming(decl)] as const),
139
+ );
140
+ const resources: ResourceModel[] = [];
141
+ for (const decl of included) {
142
+ const naming = namingByDecl.get(decl)!;
143
+ const key = `${naming.app}.${naming.model}`;
144
+ const fields: FieldModel[] = [];
145
+ const relations: RelationModel[] = [];
146
+
147
+ for (const prop of analyzed(decl)) {
148
+ if (prop.skip) {
149
+ diagnostics.push({ severity: "warning", message: `${key}.${prop.name}: ${prop.skip}` });
150
+ continue;
151
+ }
152
+ if (prop.inverse) continue; // has-many arrays are the reverse side of a FK
153
+ if (prop.target) {
154
+ const target = namingByDecl.get(prop.target);
155
+ if (!target) continue; // unreachable: closure included every target
156
+ const field: FieldModel = {
157
+ name: prop.name,
158
+ type: "fk",
159
+ relation: { app: target.app, model: target.model },
160
+ };
161
+ if (prop.nullable) field.null = true;
162
+ fields.push(field);
163
+ relations.push({
164
+ field: prop.name,
165
+ target: `${target.app}.${target.model}`,
166
+ kind: "fk",
167
+ });
168
+ } else if (prop.field) {
169
+ fields.push(prop.field);
170
+ }
171
+ }
172
+
173
+ resources.push({
174
+ app: naming.app,
175
+ model: naming.model,
176
+ fields,
177
+ relations,
178
+ search: naming.search,
179
+ permission: naming.permission,
180
+ });
181
+ }
182
+
183
+ resources.sort((a, b) =>
184
+ a.app === b.app ? a.model.localeCompare(b.model) : a.app.localeCompare(b.app),
185
+ );
186
+ return { resources, diagnostics };
187
+ }
188
+
189
+ // -- hooks -------------------------------------------------------------------
190
+
191
+ /** A React-style hook call: `useX(...)` or `obj.useX(...)` with type args. */
192
+ function isHookCall(call: ts.CallExpression): boolean {
193
+ if (!call.typeArguments?.length) return false;
194
+ const callee = call.expression;
195
+ const name = ts.isIdentifier(callee)
196
+ ? callee.text
197
+ : ts.isPropertyAccessExpression(callee)
198
+ ? callee.name.text
199
+ : undefined;
200
+ return !!name && /^use[A-Z0-9_]/.test(name);
201
+ }
202
+
203
+ /** Resolve a type node to an interface declared in one of the scanned files. */
204
+ function interfaceBehindTypeNode(
205
+ node: ts.TypeNode,
206
+ checker: ts.TypeChecker,
207
+ scanned: Set<string>,
208
+ ): ts.InterfaceDeclaration | undefined {
209
+ return interfaceBehindType(checker.getTypeFromTypeNode(node), scanned);
210
+ }
211
+
212
+ function interfaceBehindType(
213
+ type: ts.Type,
214
+ scanned: Set<string>,
215
+ ): ts.InterfaceDeclaration | undefined {
216
+ const symbol = type.aliasSymbol ?? type.getSymbol();
217
+ for (const decl of symbol?.declarations ?? []) {
218
+ if (
219
+ ts.isInterfaceDeclaration(decl) &&
220
+ scanned.has(path.resolve(decl.getSourceFile().fileName))
221
+ ) {
222
+ return decl;
223
+ }
224
+ }
225
+ return undefined;
226
+ }
227
+
228
+ // -- property analysis --------------------------------------------------------
229
+
230
+ interface PropInfo {
231
+ name: string;
232
+ nullable: boolean;
233
+ /** Scalar field (mutually exclusive with `target`). */
234
+ field?: FieldModel;
235
+ /** FK target interface (relation resolved after naming). */
236
+ target?: ts.InterfaceDeclaration;
237
+ /** `Entity[]` — the reverse side of a FK; contributes no column. */
238
+ inverse?: boolean;
239
+ /** Unsupported type — reported as a warning and skipped. */
240
+ skip?: string;
241
+ }
242
+
243
+ function analyzeProperties(
244
+ decl: ts.InterfaceDeclaration,
245
+ checker: ts.TypeChecker,
246
+ scanned: Set<string>,
247
+ ): PropInfo[] {
248
+ const props: PropInfo[] = [];
249
+ for (const member of decl.members) {
250
+ if (!ts.isPropertySignature(member) || !member.type) continue;
251
+ const name =
252
+ ts.isIdentifier(member.name) || ts.isStringLiteralLike(member.name)
253
+ ? member.name.text
254
+ : undefined;
255
+ if (!name) continue;
256
+ props.push(analyzeProperty(name, member, checker, scanned));
257
+ }
258
+ return props;
259
+ }
260
+
261
+ function analyzeProperty(
262
+ name: string,
263
+ member: ts.PropertySignature,
264
+ checker: ts.TypeChecker,
265
+ scanned: Set<string>,
266
+ ): PropInfo {
267
+ let nullable = !!member.questionToken;
268
+
269
+ // Unwrap parens and unions: strip null/undefined (→ nullable), and require a
270
+ // single remaining arm (unions of string literals collapse to `string`).
271
+ let typeNode = unwrapParens(member.type!);
272
+ if (ts.isUnionTypeNode(typeNode)) {
273
+ const arms = typeNode.types.filter((t) => {
274
+ const isNullish =
275
+ (ts.isLiteralTypeNode(t) && t.literal.kind === ts.SyntaxKind.NullKeyword) ||
276
+ t.kind === ts.SyntaxKind.UndefinedKeyword;
277
+ if (isNullish) nullable = true;
278
+ return !isNullish;
279
+ });
280
+ if (arms.length > 1 && arms.every(isStringish)) {
281
+ return scalarProp(name, "string", configFromTags(jsdocTags(member)), nullable);
282
+ }
283
+ if (arms.length !== 1) {
284
+ return { name, nullable, skip: "unsupported union type — skipped" };
285
+ }
286
+ typeNode = unwrapParens(arms[0]);
287
+ }
288
+
289
+ // Arrays: `Entity[]` is the reverse side of a FK — it contributes no column
290
+ // but is intentionally not an error. Arrays of scalars have no manifest shape.
291
+ const element = arrayElement(typeNode);
292
+ if (element) {
293
+ if (interfaceBehindTypeNode(element, checker, scanned)) {
294
+ return { name, nullable, inverse: true };
295
+ }
296
+ return { name, nullable, skip: "array fields are not supported — skipped" };
297
+ }
298
+
299
+ const type = checker.getTypeFromTypeNode(typeNode);
300
+ const base = classifyType(type, scanned);
301
+ if (base === undefined) {
302
+ return { name, nullable, skip: "unsupported type — skipped" };
303
+ }
304
+ if (typeof base !== "string") {
305
+ return { name, nullable, target: base }; // entity interface → FK
306
+ }
307
+
308
+ const config = {
309
+ ...configFromTags(jsdocTags(member)),
310
+ ...configFromType(type, checker, member),
311
+ };
312
+ return scalarProp(name, base, config, nullable);
313
+ }
314
+
315
+ /**
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.
320
+ */
321
+ function classifyType(
322
+ type: ts.Type,
323
+ scanned: Set<string>,
324
+ ): ScalarType | ts.InterfaceDeclaration | undefined {
325
+ if (type.flags & ts.TypeFlags.StringLike) return "string";
326
+ if (type.flags & ts.TypeFlags.NumberLike) return "float";
327
+ if (type.flags & ts.TypeFlags.BooleanLike) return "bool";
328
+ if (type.isIntersection()) {
329
+ for (const part of type.types) {
330
+ const classified = classifyType(part, scanned);
331
+ if (classified) return classified;
332
+ }
333
+ return undefined;
334
+ }
335
+ if (type.getProperty("__rastackDb")) return undefined; // a bare config brand
336
+ const decl = interfaceBehindType(type, scanned);
337
+ if (decl) return decl;
338
+ if (type.getSymbol()?.getName() === "Date") return "datetime";
339
+ return undefined;
340
+ }
341
+
342
+ /** Build a scalar field, letting the column config refine type + constraints. */
343
+ function scalarProp(
344
+ name: string,
345
+ base: ScalarType,
346
+ config: ColumnConfig,
347
+ nullable: boolean,
348
+ ): PropInfo {
349
+ let type = base;
350
+ if (base === "float" && config.int) type = "int";
351
+ if (base === "string" && config.uuid) type = "uuid";
352
+ if (base === "string" && config.datetime) type = "datetime";
353
+
354
+ const field: FieldModel = { name, type };
355
+ if (config.maxLength !== undefined) field.maxLength = config.maxLength;
356
+ if (config.unique) field.unique = true;
357
+ if (config.primaryKey) field.primaryKey = true;
358
+ if (nullable) field.null = true;
359
+ if (config.default !== undefined) field.default = config.default;
360
+ return { name, nullable, field };
361
+ }
362
+
363
+ // -- column configuration -------------------------------------------------------
364
+
365
+ /** Column constraints, sourced from `DbConfig` brand types and/or JSDoc tags. */
366
+ interface ColumnConfig {
367
+ int?: boolean;
368
+ uuid?: boolean;
369
+ datetime?: boolean;
370
+ maxLength?: number;
371
+ unique?: boolean;
372
+ primaryKey?: boolean;
373
+ default?: unknown;
374
+ }
375
+
376
+ /**
377
+ * Config carried in the type system: intersecting a property type with
378
+ * `DbConfig` brands (`string & Unique & MaxLength<3>`, or a custom
379
+ * `interface Registration extends DbConfig<{…}>`) surfaces a phantom
380
+ * `__rastackDb` property whose literal type arguments are the configuration.
381
+ */
382
+ function configFromType(
383
+ type: ts.Type,
384
+ checker: ts.TypeChecker,
385
+ location: ts.Node,
386
+ ): ColumnConfig {
387
+ const config: ColumnConfig = {};
388
+ const symbol = type.getProperty("__rastackDb");
389
+ if (!symbol) return config;
390
+ const carrier = checker
391
+ .getTypeOfSymbolAtLocation(symbol, location)
392
+ .getNonNullableType();
393
+ const prop = (name: string): ts.Type | undefined => {
394
+ const p = carrier.getProperty(name);
395
+ return p && checker.getTypeOfSymbolAtLocation(p, location);
396
+ };
397
+
398
+ if (prop("int")) config.int = true;
399
+ if (prop("uuid")) config.uuid = true;
400
+ if (prop("datetime")) config.datetime = true;
401
+ if (prop("unique")) config.unique = true;
402
+ if (prop("primaryKey")) config.primaryKey = true;
403
+ const maxLength = prop("maxLength");
404
+ if (maxLength?.isNumberLiteral()) config.maxLength = maxLength.value;
405
+ const dflt = prop("default");
406
+ const value = dflt ? literalValue(dflt, checker) : undefined;
407
+ if (value !== undefined) config.default = value;
408
+ return config;
409
+ }
410
+
411
+ /** Config carried as JSDoc tags (`@unique`, `@maxLength 3`, `@default x`). */
412
+ function configFromTags(tags: Map<string, string>): ColumnConfig {
413
+ const config: ColumnConfig = {};
414
+ if (tags.has("int")) config.int = true;
415
+ if (tags.has("uuid")) config.uuid = true;
416
+ if (tags.has("datetime")) config.datetime = true;
417
+ if (tags.has("unique")) config.unique = true;
418
+ if (tags.has("primarykey")) config.primaryKey = true;
419
+ const maxLength = Number(tags.get("maxlength"));
420
+ if (Number.isFinite(maxLength) && maxLength > 0) config.maxLength = maxLength;
421
+ const dflt = tags.get("default");
422
+ if (dflt !== undefined) {
423
+ try {
424
+ config.default = JSON.parse(dflt);
425
+ } catch {
426
+ config.default = dflt;
427
+ }
428
+ }
429
+ return config;
430
+ }
431
+
432
+ /** The JS value of a literal type (`"active"`, `0`, `true`), else undefined. */
433
+ function literalValue(
434
+ type: ts.Type,
435
+ checker: ts.TypeChecker,
436
+ ): string | number | boolean | undefined {
437
+ if (type.isStringLiteral() || type.isNumberLiteral()) return type.value;
438
+ if (type.flags & ts.TypeFlags.BooleanLiteral) {
439
+ return checker.typeToString(type) === "true";
440
+ }
441
+ return undefined;
442
+ }
443
+
444
+ function unwrapParens(node: ts.TypeNode): ts.TypeNode {
445
+ while (ts.isParenthesizedTypeNode(node)) node = node.type;
446
+ return node;
447
+ }
448
+
449
+ /** `string`, a string-literal type, or a template literal type. */
450
+ function isStringish(node: ts.TypeNode): boolean {
451
+ if (node.kind === ts.SyntaxKind.StringKeyword) return true;
452
+ if (ts.isLiteralTypeNode(node) && ts.isStringLiteralLike(node.literal)) return true;
453
+ return ts.isTemplateLiteralTypeNode(node);
454
+ }
455
+
456
+ /** Element type of `T[]` / `Array<T>` / `ReadonlyArray<T>`, else undefined. */
457
+ function arrayElement(node: ts.TypeNode): ts.TypeNode | undefined {
458
+ if (ts.isArrayTypeNode(node)) return node.elementType;
459
+ if (
460
+ ts.isTypeReferenceNode(node) &&
461
+ ts.isIdentifier(node.typeName) &&
462
+ (node.typeName.text === "Array" || node.typeName.text === "ReadonlyArray") &&
463
+ node.typeArguments?.length === 1
464
+ ) {
465
+ return node.typeArguments[0];
466
+ }
467
+ return undefined;
468
+ }
469
+
470
+ // -- naming -------------------------------------------------------------------
471
+
472
+ interface EntityNaming {
473
+ app: string;
474
+ model: string;
475
+ search?: string[];
476
+ permission?: ResourceModel["permission"];
477
+ }
478
+
479
+ function entityNaming(decl: ts.InterfaceDeclaration): EntityNaming {
480
+ const tags = jsdocTags(decl);
481
+ const raw = decl.name.text;
482
+ // Generated entity interfaces are `I`-prefixed (`IFlight`); strip the prefix
483
+ // so hook usage of generated types resolves to the same resource.
484
+ const stripped = /^I[A-Z]/.test(raw) ? raw.slice(1) : raw;
485
+ const model =
486
+ tags.get("rastackmodel") || stripped.charAt(0).toLowerCase() + stripped.slice(1);
487
+ const app =
488
+ tags.get("rastackapp") ||
489
+ path
490
+ .basename(decl.getSourceFile().fileName)
491
+ .replace(/\.(ts|tsx)$/, "")
492
+ .toLowerCase();
493
+
494
+ const naming: EntityNaming = { app, model };
495
+ const search = tags.get("rastacksearch");
496
+ if (search) naming.search = search.split(",").map((s) => s.trim()).filter(Boolean);
497
+ const permission = tags.get("rastackpermission");
498
+ if (
499
+ permission === "authenticatedOrReadOnly" ||
500
+ permission === "authenticated" ||
501
+ permission === "public"
502
+ ) {
503
+ naming.permission = permission;
504
+ }
505
+ return naming;
506
+ }
507
+
508
+ /** All JSDoc tags on a node, keyed by lower-cased tag name → comment text. */
509
+ function jsdocTags(node: ts.Node): Map<string, string> {
510
+ const tags = new Map<string, string>();
511
+ for (const tag of ts.getJSDocTags(node)) {
512
+ const text = ts.getTextOfJSDocComment(tag.comment) ?? "";
513
+ tags.set(tag.tagName.text.toLowerCase(), text.trim());
514
+ }
515
+ return tags;
516
+ }
517
+
518
+ // -- circular-dependency detection ---------------------------------------------
519
+
520
+ /**
521
+ * Detect cycles in the FK graph. Works on the *merged* resource set, so cycles
522
+ * authored through the `resource()` DSL are caught too. A cycle every edge of
523
+ * which is required is an error — no row of any resource in the cycle could
524
+ * ever be inserted. A cycle containing at least one nullable FK (an optional
525
+ * interface property) is breakable and reported as a warning.
526
+ */
527
+ export function detectCycles(resources: ResourceModel[]): EntityDiagnostic[] {
528
+ interface Edge {
529
+ from: string;
530
+ to: string;
531
+ field: string;
532
+ nullable: boolean;
533
+ }
534
+ const adjacency = new Map<string, Edge[]>();
535
+ for (const r of resources) {
536
+ const from = `${r.app}.${r.model}`;
537
+ const edges: Edge[] = [];
538
+ for (const rel of r.relations) {
539
+ const field = r.fields.find((f) => f.name === rel.field);
540
+ edges.push({
541
+ from,
542
+ to: rel.target,
543
+ field: rel.field,
544
+ nullable: field?.null === true,
545
+ });
546
+ }
547
+ adjacency.set(from, edges);
548
+ }
549
+
550
+ const diagnostics: EntityDiagnostic[] = [];
551
+ const reported = new Set<string>();
552
+ const state = new Map<string, 1 | 2>(); // 1 = on stack, 2 = done
553
+ const stack: { node: string; edgeIn?: Edge }[] = [];
554
+
555
+ const reportCycle = (closingEdge: Edge): void => {
556
+ const start = stack.findIndex((frame) => frame.node === closingEdge.to);
557
+ const nodes = stack.slice(start).map((frame) => frame.node);
558
+ const edges = [
559
+ ...stack.slice(start + 1).map((frame) => frame.edgeIn!),
560
+ closingEdge,
561
+ ];
562
+ const key = [...nodes].sort().join("|");
563
+ if (reported.has(key)) return;
564
+ reported.add(key);
565
+
566
+ const chain = [...nodes, closingEdge.to].join(" → ");
567
+ const breakable = edges.find((e) => e.nullable);
568
+ diagnostics.push(
569
+ breakable
570
+ ? {
571
+ severity: "warning",
572
+ message:
573
+ `Circular dependency: ${chain} — breakable via nullable ` +
574
+ `field ${breakable.from}.${breakable.field}.`,
575
+ }
576
+ : {
577
+ severity: "error",
578
+ message:
579
+ `Circular dependency: ${chain} — every foreign key in the ` +
580
+ `cycle is required, so no row can ever be inserted. Make ` +
581
+ `one of the fields optional to break the cycle.`,
582
+ },
583
+ );
584
+ };
585
+
586
+ const visit = (node: string, edgeIn?: Edge): void => {
587
+ state.set(node, 1);
588
+ stack.push({ node, edgeIn });
589
+ for (const edge of adjacency.get(node) ?? []) {
590
+ const targetState = state.get(edge.to);
591
+ if (targetState === 1) reportCycle(edge);
592
+ else if (targetState !== 2) visit(edge.to, edge);
593
+ }
594
+ stack.pop();
595
+ state.set(node, 2);
596
+ };
597
+
598
+ for (const node of adjacency.keys()) {
599
+ if (!state.has(node)) visit(node);
600
+ }
601
+ return diagnostics;
602
+ }