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.
- package/CHANGELOG.md +2 -0
- package/cache/cache.ts +1 -1
- package/cache/revocation.ts +1 -1
- package/cache/storage.ts +1 -1
- package/cache/types.ts +1 -1
- package/define.ts +2 -0
- package/dist/compile/analyze.js +3 -3
- package/dist/compile/entities.d.ts +68 -0
- package/dist/compile/entities.js +496 -0
- package/dist/compile/index.d.ts +24 -7
- package/dist/compile/index.js +48 -16
- package/dist/compile/model.d.ts +2 -2
- package/dist/compile/model.js +1 -1
- package/dist/compile/program.d.ts +2 -1
- package/dist/compile/program.js +8 -3
- package/dist/csv-schema.js +1 -1
- package/dist/define/db.d.ts +73 -0
- package/dist/define/db.js +35 -0
- package/dist/define/index.d.ts +8 -8
- package/dist/define/index.js +10 -10
- package/dist/index.js +1 -1
- package/dist/rastack-compile.d.ts +11 -0
- package/dist/{rad-compile.js → rastack-compile.js} +22 -12
- package/dist/{rad-wasm-build.d.ts → rastack-wasm-build.d.ts} +2 -2
- package/dist/{rad-wasm-build.js → rastack-wasm-build.js} +6 -6
- package/dist/rastack.d.ts +17 -0
- package/dist/{rad.js → rastack.js} +24 -24
- package/dist/scan.d.ts +3 -3
- package/dist/scan.js +4 -4
- package/dist/seed.d.ts +2 -2
- package/dist/seed.js +9 -9
- package/hooks/real-time/pusher.ts +2 -2
- package/package.json +2 -3
- package/provider/provider.tsx +12 -12
- package/provider/types.ts +16 -16
- package/provider/warehouse.ts +1 -1
- package/provider/wasm.ts +10 -10
- package/src/compile/analyze.ts +3 -3
- package/src/compile/entities.ts +602 -0
- package/src/compile/index.ts +57 -15
- package/src/compile/model.ts +2 -2
- package/src/compile/program.ts +8 -3
- package/src/csv-schema.ts +1 -1
- package/src/define/db.ts +64 -0
- package/src/define/index.ts +16 -16
- package/src/index.ts +1 -1
- package/src/{rad-compile.ts → rastack-compile.ts} +30 -13
- package/src/{rad-wasm-build.ts → rastack-wasm-build.ts} +6 -6
- package/src/{rad.ts → rastack.ts} +24 -24
- package/src/scan.ts +4 -4
- package/src/seed.ts +9 -9
- package/sync/engine.ts +1 -1
- package/sync/types.ts +1 -1
- package/test/cache.spec.ts +1 -1
- package/test/compile.spec.ts +6 -6
- package/test/csv-schema.spec.ts +2 -2
- package/test/entities.spec.ts +381 -0
- package/types.ts +3 -0
- package/dist/rad-compile.d.ts +0 -11
- package/dist/rad.d.ts +0 -17
|
@@ -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
|
+
}
|