@facilis.dev/analyze 0.0.0-stage → 0.1.1
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/LICENSE +21 -0
- package/README.md +18 -2
- package/dist/analyze.d.ts +17 -0
- package/dist/analyze.js +99 -0
- package/dist/ast/node.d.ts +29 -0
- package/dist/ast/node.js +85 -0
- package/dist/ast/walk.d.ts +8 -0
- package/dist/ast/walk.js +40 -0
- package/dist/diagnostics.d.ts +5 -0
- package/dist/diagnostics.js +12 -0
- package/dist/fs.d.ts +13 -0
- package/dist/fs.js +51 -0
- package/dist/graph-edges.d.ts +3 -0
- package/dist/graph-edges.js +148 -0
- package/dist/graph-ids.d.ts +6 -0
- package/dist/graph-ids.js +22 -0
- package/dist/graph.d.ts +15 -0
- package/dist/graph.js +162 -0
- package/dist/index.d.ts +24 -0
- package/dist/index.js +17 -0
- package/dist/json.d.ts +8 -0
- package/dist/json.js +31 -0
- package/dist/module-graph.d.ts +33 -0
- package/dist/module-graph.js +86 -0
- package/dist/module-info.d.ts +27 -0
- package/dist/module-info.js +143 -0
- package/dist/node.d.ts +16 -0
- package/dist/node.js +41 -0
- package/dist/parser.d.ts +16 -0
- package/dist/parser.js +1 -0
- package/dist/passes/clients.d.ts +22 -0
- package/dist/passes/clients.js +167 -0
- package/dist/passes/config.d.ts +16 -0
- package/dist/passes/config.js +229 -0
- package/dist/passes/file-facts.d.ts +13 -0
- package/dist/passes/file-facts.js +130 -0
- package/dist/passes/i18n.d.ts +14 -0
- package/dist/passes/i18n.js +55 -0
- package/dist/passes/job-relations-diagnostics.d.ts +9 -0
- package/dist/passes/job-relations-diagnostics.js +38 -0
- package/dist/passes/job-relations.d.ts +18 -0
- package/dist/passes/job-relations.js +52 -0
- package/dist/passes/jobs.d.ts +20 -0
- package/dist/passes/jobs.js +133 -0
- package/dist/passes/jsx-text.d.ts +28 -0
- package/dist/passes/jsx-text.js +121 -0
- package/dist/passes/jsx.d.ts +24 -0
- package/dist/passes/jsx.js +135 -0
- package/dist/passes/path-expression.d.ts +26 -0
- package/dist/passes/path-expression.js +265 -0
- package/dist/passes/route-config.d.ts +24 -0
- package/dist/passes/route-config.js +152 -0
- package/dist/passes/route-description.d.ts +13 -0
- package/dist/passes/route-description.js +23 -0
- package/dist/passes/route-elevation.d.ts +18 -0
- package/dist/passes/route-elevation.js +136 -0
- package/dist/passes/route-handler.d.ts +27 -0
- package/dist/passes/route-handler.js +87 -0
- package/dist/passes/route-location.d.ts +16 -0
- package/dist/passes/route-location.js +58 -0
- package/dist/passes/route-relation-resolve.d.ts +51 -0
- package/dist/passes/route-relation-resolve.js +172 -0
- package/dist/passes/route-relations-diagnostics.d.ts +15 -0
- package/dist/passes/route-relations-diagnostics.js +79 -0
- package/dist/passes/route-relations-follow.d.ts +16 -0
- package/dist/passes/route-relations-follow.js +116 -0
- package/dist/passes/route-relations-usage.d.ts +50 -0
- package/dist/passes/route-relations-usage.js +44 -0
- package/dist/passes/route-relations-walk.d.ts +25 -0
- package/dist/passes/route-relations-walk.js +199 -0
- package/dist/passes/route-relations.d.ts +17 -0
- package/dist/passes/route-relations.js +43 -0
- package/dist/passes/route-table.d.ts +21 -0
- package/dist/passes/route-table.js +104 -0
- package/dist/passes/route-tables.d.ts +10 -0
- package/dist/passes/route-tables.js +42 -0
- package/dist/passes/routes.d.ts +18 -0
- package/dist/passes/routes.js +152 -0
- package/dist/passes/schema-column-types.d.ts +9 -0
- package/dist/passes/schema-column-types.js +55 -0
- package/dist/passes/schema-columns.d.ts +35 -0
- package/dist/passes/schema-columns.js +197 -0
- package/dist/passes/schema-dsl.d.ts +24 -0
- package/dist/passes/schema-dsl.js +117 -0
- package/dist/passes/schema-file-links-ddl.d.ts +18 -0
- package/dist/passes/schema-file-links-ddl.js +106 -0
- package/dist/passes/schema-file-links.d.ts +19 -0
- package/dist/passes/schema-file-links.js +144 -0
- package/dist/passes/schema-foreign-keys.d.ts +21 -0
- package/dist/passes/schema-foreign-keys.js +80 -0
- package/dist/passes/schema-policies.d.ts +15 -0
- package/dist/passes/schema-policies.js +201 -0
- package/dist/passes/schema-re-exports.d.ts +5 -0
- package/dist/passes/schema-re-exports.js +77 -0
- package/dist/passes/schema-relations.d.ts +8 -0
- package/dist/passes/schema-relations.js +182 -0
- package/dist/passes/schema-scope.d.ts +7 -0
- package/dist/passes/schema-scope.js +20 -0
- package/dist/passes/schema-tables.d.ts +13 -0
- package/dist/passes/schema-tables.js +149 -0
- package/dist/passes/schema.d.ts +9 -0
- package/dist/passes/schema.js +55 -0
- package/dist/passes/screen-components.d.ts +22 -0
- package/dist/passes/screen-components.js +143 -0
- package/dist/passes/screen-intrinsics.d.ts +18 -0
- package/dist/passes/screen-intrinsics.js +21 -0
- package/dist/passes/screen-preview.d.ts +29 -0
- package/dist/passes/screen-preview.js +174 -0
- package/dist/passes/screens.d.ts +12 -0
- package/dist/passes/screens.js +278 -0
- package/dist/passes/static-read.d.ts +47 -0
- package/dist/passes/static-read.js +151 -0
- package/dist/passes/views.d.ts +20 -0
- package/dist/passes/views.js +101 -0
- package/dist/passes/workflow-tables.d.ts +18 -0
- package/dist/passes/workflow-tables.js +71 -0
- package/dist/passes/workflows.d.ts +8 -0
- package/dist/passes/workflows.js +160 -0
- package/dist/path-resolver.d.ts +20 -0
- package/dist/path-resolver.js +134 -0
- package/dist/revision.d.ts +2 -0
- package/dist/revision.js +23 -0
- package/dist/source.d.ts +4 -0
- package/dist/source.js +13 -0
- package/dist/static-eval.d.ts +36 -0
- package/dist/static-eval.js +215 -0
- package/dist/types-graph.d.ts +144 -0
- package/dist/types-graph.js +4 -0
- package/dist/types-jobs.d.ts +28 -0
- package/dist/types-jobs.js +14 -0
- package/dist/types-relations.d.ts +49 -0
- package/dist/types-relations.js +11 -0
- package/dist/types-workflows.d.ts +36 -0
- package/dist/types-workflows.js +3 -0
- package/dist/types.d.ts +226 -0
- package/dist/types.js +1 -0
- package/package.json +49 -4
- package/src/analyze.ts +119 -0
- package/src/ast/node.ts +102 -0
- package/src/ast/walk.ts +37 -0
- package/src/diagnostics.ts +22 -0
- package/src/fs.ts +59 -0
- package/src/graph-edges.ts +167 -0
- package/src/graph-ids.ts +31 -0
- package/src/graph.ts +194 -0
- package/src/index.ts +26 -0
- package/src/json.ts +34 -0
- package/src/module-graph.ts +117 -0
- package/src/module-info.ts +162 -0
- package/src/node.ts +54 -0
- package/src/parser.ts +19 -0
- package/src/passes/clients.ts +233 -0
- package/src/passes/config.ts +278 -0
- package/src/passes/file-facts.ts +142 -0
- package/src/passes/i18n.ts +66 -0
- package/src/passes/job-relations-diagnostics.ts +60 -0
- package/src/passes/job-relations.ts +76 -0
- package/src/passes/jobs.ts +181 -0
- package/src/passes/jsx-text.ts +126 -0
- package/src/passes/jsx.ts +142 -0
- package/src/passes/path-expression.ts +297 -0
- package/src/passes/route-config.ts +188 -0
- package/src/passes/route-description.ts +33 -0
- package/src/passes/route-elevation.ts +161 -0
- package/src/passes/route-handler.ts +105 -0
- package/src/passes/route-location.ts +62 -0
- package/src/passes/route-relation-resolve.ts +228 -0
- package/src/passes/route-relations-diagnostics.ts +115 -0
- package/src/passes/route-relations-follow.ts +146 -0
- package/src/passes/route-relations-usage.ts +98 -0
- package/src/passes/route-relations-walk.ts +255 -0
- package/src/passes/route-relations.ts +62 -0
- package/src/passes/route-table.ts +123 -0
- package/src/passes/route-tables.ts +50 -0
- package/src/passes/routes.ts +188 -0
- package/src/passes/schema-column-types.ts +62 -0
- package/src/passes/schema-columns.ts +287 -0
- package/src/passes/schema-dsl.ts +136 -0
- package/src/passes/schema-file-links-ddl.ts +125 -0
- package/src/passes/schema-file-links.ts +213 -0
- package/src/passes/schema-foreign-keys.ts +121 -0
- package/src/passes/schema-policies.ts +270 -0
- package/src/passes/schema-re-exports.ts +95 -0
- package/src/passes/schema-relations.ts +239 -0
- package/src/passes/schema-scope.ts +29 -0
- package/src/passes/schema-tables.ts +216 -0
- package/src/passes/schema.ts +67 -0
- package/src/passes/screen-components.ts +162 -0
- package/src/passes/screen-intrinsics.ts +29 -0
- package/src/passes/screen-preview.ts +295 -0
- package/src/passes/screens.ts +291 -0
- package/src/passes/static-read.ts +196 -0
- package/src/passes/views.ts +125 -0
- package/src/passes/workflow-tables.ts +87 -0
- package/src/passes/workflows.ts +213 -0
- package/src/path-resolver.ts +146 -0
- package/src/revision.ts +25 -0
- package/src/source.ts +15 -0
- package/src/static-eval.ts +242 -0
- package/src/types-graph.ts +176 -0
- package/src/types-jobs.ts +40 -0
- package/src/types-relations.ts +64 -0
- package/src/types-workflows.ts +42 -0
- package/src/types.ts +299 -0
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Route files -> RouteFacts (docs/findings/ast-dsl-contract.md §3.1).
|
|
3
|
+
*
|
|
4
|
+
* A route is listed even when it does not use the wrapper: the graph is also the integrity
|
|
5
|
+
* view, so an invalid route is reported instead of omitted. The filename is the only source
|
|
6
|
+
* of method/path (Nitro's file router); the default export is the only source of the config.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { identifierName, unwrapExpression, type AstNode } from "../ast/node.ts";
|
|
10
|
+
import { error, info, warning } from "../diagnostics.ts";
|
|
11
|
+
import { defaultExportDeclaration } from "../module-info.ts";
|
|
12
|
+
import type { ModuleGraph, ProjectFile } from "../module-graph.ts";
|
|
13
|
+
import { sourceRef } from "../source.ts";
|
|
14
|
+
import { EMPTY_ROUTE_RELATIONS } from "../types-relations.ts";
|
|
15
|
+
import type { ConfigFacts, Diagnostic, RouteFact, WrapperState } from "../types.ts";
|
|
16
|
+
import { routeConfigFacts } from "./route-config.ts";
|
|
17
|
+
import { scanElevations } from "./route-elevation.ts";
|
|
18
|
+
import { directDbWarnings, handlerReadsBody, handlerReadsForm } from "./route-handler.ts";
|
|
19
|
+
import { routeLocationFromFile } from "./route-location.ts";
|
|
20
|
+
import {
|
|
21
|
+
defaultExportExpression,
|
|
22
|
+
isLocallyDeclared,
|
|
23
|
+
relativePath,
|
|
24
|
+
sortDiagnostics,
|
|
25
|
+
} from "./static-read.ts";
|
|
26
|
+
|
|
27
|
+
const CORE_SPECIFIER = "@facilis.dev/core";
|
|
28
|
+
const ROUTE_DIRS = ["server/routes/", "server/api/"];
|
|
29
|
+
|
|
30
|
+
export interface RouteResult {
|
|
31
|
+
readonly routes: readonly RouteFact[];
|
|
32
|
+
readonly diagnostics: readonly Diagnostic[];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface WrapperResolution {
|
|
36
|
+
readonly state: WrapperState;
|
|
37
|
+
readonly call: AstNode | null;
|
|
38
|
+
readonly reason: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** The default export must be a `defineAuthorizedHandler(...)` call of the core import. */
|
|
42
|
+
function resolveWrapper(file: ProjectFile): WrapperResolution {
|
|
43
|
+
const expression = defaultExportExpression(file);
|
|
44
|
+
if (expression === undefined) {
|
|
45
|
+
return { state: "invalid", call: null, reason: "the route file has no default export" };
|
|
46
|
+
}
|
|
47
|
+
if (expression.type !== "CallExpression") {
|
|
48
|
+
return {
|
|
49
|
+
state: "invalid",
|
|
50
|
+
call: null,
|
|
51
|
+
reason: "the default export is not a defineAuthorizedHandler() call",
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
const callee = unwrapExpression(expression.callee);
|
|
55
|
+
const name = callee?.type === "Identifier" ? identifierName(callee) : undefined;
|
|
56
|
+
if (name === undefined) {
|
|
57
|
+
return { state: "unknown", call: null, reason: "the handler callee cannot be resolved" };
|
|
58
|
+
}
|
|
59
|
+
if (isLocallyDeclared(file, name)) {
|
|
60
|
+
return {
|
|
61
|
+
state: "invalid",
|
|
62
|
+
call: null,
|
|
63
|
+
reason: '"' + name + '" is shadowed by a local declaration',
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
const imported = file.module.imports.find((entry) => entry.local === name);
|
|
67
|
+
if (
|
|
68
|
+
imported === undefined ||
|
|
69
|
+
imported.specifier !== CORE_SPECIFIER ||
|
|
70
|
+
imported.imported !== "defineAuthorizedHandler"
|
|
71
|
+
) {
|
|
72
|
+
return {
|
|
73
|
+
state: "invalid",
|
|
74
|
+
call: null,
|
|
75
|
+
reason: "the default export is not the defineAuthorizedHandler import from @facilis.dev/core",
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
return { state: "valid", call: expression, reason: "" };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function scanRoute(
|
|
82
|
+
graph: ModuleGraph,
|
|
83
|
+
file: ProjectFile,
|
|
84
|
+
config: ConfigFacts,
|
|
85
|
+
): { route: RouteFact | null; diagnostics: readonly Diagnostic[] } {
|
|
86
|
+
const relative = relativePath(graph.root, file.path);
|
|
87
|
+
const dir = ROUTE_DIRS.find((candidate) => relative.startsWith(candidate));
|
|
88
|
+
const declaration = defaultExportDeclaration(file.program);
|
|
89
|
+
const anchor = declaration?.start ?? 0;
|
|
90
|
+
if (dir === undefined) return { route: null, diagnostics: [] };
|
|
91
|
+
const location = routeLocationFromFile(relative.slice(dir.length));
|
|
92
|
+
if (location === undefined) {
|
|
93
|
+
return {
|
|
94
|
+
route: null,
|
|
95
|
+
diagnostics: [
|
|
96
|
+
info(
|
|
97
|
+
"unknown",
|
|
98
|
+
"cannot derive a route method/path from the route file name",
|
|
99
|
+
sourceRef(file.path, file.source, anchor),
|
|
100
|
+
),
|
|
101
|
+
],
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
const wrapper = resolveWrapper(file);
|
|
105
|
+
const ref = sourceRef(file.path, file.source, wrapper.call?.start ?? anchor);
|
|
106
|
+
if (wrapper.state !== "valid" || wrapper.call === null) {
|
|
107
|
+
return {
|
|
108
|
+
route: {
|
|
109
|
+
method: location.method,
|
|
110
|
+
path: location.path,
|
|
111
|
+
sourceFile: file.path,
|
|
112
|
+
source: ref,
|
|
113
|
+
wrapper: wrapper.state,
|
|
114
|
+
permission: null,
|
|
115
|
+
crossSite: null,
|
|
116
|
+
dbTables: null,
|
|
117
|
+
dbColumns: null,
|
|
118
|
+
rawSql: null,
|
|
119
|
+
relations: EMPTY_ROUTE_RELATIONS,
|
|
120
|
+
description: null,
|
|
121
|
+
input: { body: false, form: false, query: false, params: false },
|
|
122
|
+
handlerBodyRead: false,
|
|
123
|
+
handlerFormRead: false,
|
|
124
|
+
elevations: [],
|
|
125
|
+
unknown: [{ code: "route-wrapper", reason: wrapper.reason, source: ref }],
|
|
126
|
+
},
|
|
127
|
+
diagnostics: [error("route-wrapper", wrapper.reason, ref)],
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
const facts = routeConfigFacts(graph, file, wrapper.call, config);
|
|
131
|
+
const elevation = scanElevations(graph, file, wrapper.call);
|
|
132
|
+
return {
|
|
133
|
+
route: {
|
|
134
|
+
method: location.method,
|
|
135
|
+
path: location.path,
|
|
136
|
+
sourceFile: file.path,
|
|
137
|
+
source: ref,
|
|
138
|
+
wrapper: "valid",
|
|
139
|
+
permission: facts.permission,
|
|
140
|
+
crossSite: facts.crossSite,
|
|
141
|
+
dbTables: facts.dbTables,
|
|
142
|
+
dbColumns: facts.dbColumns,
|
|
143
|
+
rawSql: facts.rawSql,
|
|
144
|
+
relations: EMPTY_ROUTE_RELATIONS,
|
|
145
|
+
description: facts.description,
|
|
146
|
+
input: facts.input,
|
|
147
|
+
handlerBodyRead: handlerReadsBody(wrapper.call),
|
|
148
|
+
handlerFormRead: handlerReadsForm(wrapper.call),
|
|
149
|
+
elevations: elevation.facts,
|
|
150
|
+
unknown: [...facts.unknown, ...elevation.unknown],
|
|
151
|
+
},
|
|
152
|
+
diagnostics: [
|
|
153
|
+
...facts.diagnostics,
|
|
154
|
+
...elevation.unknown.map((item) =>
|
|
155
|
+
// An unreadable elevation cannot be shown in the graph; warn without blocking the route.
|
|
156
|
+
warning(item.code, item.reason, item.source),
|
|
157
|
+
),
|
|
158
|
+
...directDbWarnings(file, ref),
|
|
159
|
+
],
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** The `defineAuthorizedHandler(...)` call of a valid wrapper, for later passes (D-157). */
|
|
164
|
+
export function resolveWrapperCall(file: ProjectFile): AstNode | null {
|
|
165
|
+
const wrapper = resolveWrapper(file);
|
|
166
|
+
return wrapper.state === "valid" ? wrapper.call : null;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Read `server/routes/**` and `server/api/**` into route facts. */
|
|
170
|
+
export function buildRouteFacts(graph: ModuleGraph, config: ConfigFacts): RouteResult {
|
|
171
|
+
const files = [...graph.files.values()]
|
|
172
|
+
.filter((file) => ROUTE_DIRS.some((dir) => relativePath(graph.root, file.path).startsWith(dir)))
|
|
173
|
+
.toSorted((left, right) => left.path.localeCompare(right.path));
|
|
174
|
+
const routes: RouteFact[] = [];
|
|
175
|
+
const diagnostics: Diagnostic[] = [];
|
|
176
|
+
for (const file of files) {
|
|
177
|
+
const scanned = scanRoute(graph, file, config);
|
|
178
|
+
if (scanned.route !== null) routes.push(scanned.route);
|
|
179
|
+
diagnostics.push(...scanned.diagnostics);
|
|
180
|
+
}
|
|
181
|
+
routes.sort(
|
|
182
|
+
(left, right) =>
|
|
183
|
+
left.path.localeCompare(right.path) ||
|
|
184
|
+
left.method.localeCompare(right.method) ||
|
|
185
|
+
left.sourceFile.localeCompare(right.sourceFile),
|
|
186
|
+
);
|
|
187
|
+
return { routes, diagnostics: sortDiagnostics(diagnostics) };
|
|
188
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { AstNode } from "../ast/node.ts";
|
|
2
|
+
import { isRecord } from "../json.ts";
|
|
3
|
+
import { evaluateStatic, type StaticContext } from "../static-eval.ts";
|
|
4
|
+
import type { ColumnDefaultFact } from "../types.ts";
|
|
5
|
+
|
|
6
|
+
/** Static object argument of a builder/method, e.g. `{ withTimezone: true }`. */
|
|
7
|
+
export function optionsOf(
|
|
8
|
+
node: AstNode | undefined,
|
|
9
|
+
context: StaticContext,
|
|
10
|
+
): Record<string, unknown> | null {
|
|
11
|
+
if (node === undefined) return null;
|
|
12
|
+
const evaluated = evaluateStatic(node, context);
|
|
13
|
+
return evaluated.ok && isRecord(evaluated.value) ? evaluated.value : null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Display type derived from the builder and its static arguments (never a rendered SQL type). */
|
|
17
|
+
export function displayType(
|
|
18
|
+
name: string,
|
|
19
|
+
args: readonly AstNode[],
|
|
20
|
+
context: StaticContext,
|
|
21
|
+
): string {
|
|
22
|
+
switch (name) {
|
|
23
|
+
case "varchar": {
|
|
24
|
+
const length = optionsOf(args[1], context)?.["length"];
|
|
25
|
+
return typeof length === "number" ? "varchar(" + length + ")" : "varchar";
|
|
26
|
+
}
|
|
27
|
+
case "timestamp": {
|
|
28
|
+
const options = optionsOf(args[1], context);
|
|
29
|
+
const precision = options?.["precision"];
|
|
30
|
+
const base = typeof precision === "number" ? "timestamp(" + precision + ")" : "timestamp";
|
|
31
|
+
return options?.["withTimezone"] === true ? base + " with time zone" : base;
|
|
32
|
+
}
|
|
33
|
+
case "numeric": {
|
|
34
|
+
const options = optionsOf(args[1], context);
|
|
35
|
+
const precision = options?.["precision"];
|
|
36
|
+
if (typeof precision !== "number") return "numeric";
|
|
37
|
+
const scale = options?.["scale"];
|
|
38
|
+
return typeof scale === "number"
|
|
39
|
+
? "numeric(" + precision + ", " + scale + ")"
|
|
40
|
+
: "numeric(" + precision + ")";
|
|
41
|
+
}
|
|
42
|
+
case "doublePrecision":
|
|
43
|
+
return "double precision";
|
|
44
|
+
case "tenantColumn":
|
|
45
|
+
return "uuid";
|
|
46
|
+
default:
|
|
47
|
+
return name;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** `.default(value)`: only primitives are facts; a SQL expression is an explicit unknown. */
|
|
52
|
+
export function readDefault(node: AstNode | undefined, context: StaticContext): ColumnDefaultFact {
|
|
53
|
+
if (node === undefined) return { kind: "unknown", value: null };
|
|
54
|
+
const evaluated = evaluateStatic(node, context);
|
|
55
|
+
if (!evaluated.ok) return { kind: "unknown", value: null };
|
|
56
|
+
const value = evaluated.value;
|
|
57
|
+
if (value === null) return { kind: "literal", value: "null" };
|
|
58
|
+
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
|
|
59
|
+
return { kind: "literal", value: String(value) };
|
|
60
|
+
}
|
|
61
|
+
return { kind: "unknown", value: null };
|
|
62
|
+
}
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
import {
|
|
2
|
+
callArguments,
|
|
3
|
+
identifierName,
|
|
4
|
+
literalString,
|
|
5
|
+
memberName,
|
|
6
|
+
unwrapExpression,
|
|
7
|
+
type AstNode,
|
|
8
|
+
} from "../ast/node.ts";
|
|
9
|
+
import type { ModuleGraph, ProjectFile } from "../module-graph.ts";
|
|
10
|
+
import { staticString, type StaticContext } from "../static-eval.ts";
|
|
11
|
+
import type {
|
|
12
|
+
ColumnDefaultFact,
|
|
13
|
+
ColumnFact,
|
|
14
|
+
Diagnostic,
|
|
15
|
+
SourceRef,
|
|
16
|
+
UnknownFact,
|
|
17
|
+
} from "../types.ts";
|
|
18
|
+
import {
|
|
19
|
+
callbackExpression,
|
|
20
|
+
nodeRef,
|
|
21
|
+
schemaDiagnostics,
|
|
22
|
+
schemaUnknown,
|
|
23
|
+
type DslResolver,
|
|
24
|
+
type DslSymbol,
|
|
25
|
+
} from "./schema-dsl.ts";
|
|
26
|
+
import { displayType, optionsOf, readDefault } from "./schema-column-types.ts";
|
|
27
|
+
|
|
28
|
+
/** A `.references(() => table.column, opts)` target; the table name is resolved after every draft exists. */
|
|
29
|
+
export interface ForeignKeyTarget {
|
|
30
|
+
readonly binding: string;
|
|
31
|
+
readonly property: string;
|
|
32
|
+
readonly onDelete: string | null;
|
|
33
|
+
readonly onUpdate: string | null;
|
|
34
|
+
readonly source: SourceRef;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface ColumnReference {
|
|
38
|
+
readonly column: string;
|
|
39
|
+
readonly target: ForeignKeyTarget;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface ColumnResult {
|
|
43
|
+
readonly column: ColumnFact;
|
|
44
|
+
readonly reference: ForeignKeyTarget | null;
|
|
45
|
+
/** True when the builder is `tenantColumn()`; the table pass turns it into a scope fact. */
|
|
46
|
+
readonly tenant: boolean;
|
|
47
|
+
readonly diagnostics: readonly Diagnostic[];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface ColumnInput {
|
|
51
|
+
readonly property: string;
|
|
52
|
+
readonly expression: unknown;
|
|
53
|
+
/** Property node: the source line when the value is unreadable. */
|
|
54
|
+
readonly propertyNode: AstNode;
|
|
55
|
+
readonly file: ProjectFile;
|
|
56
|
+
readonly graph: ModuleGraph;
|
|
57
|
+
readonly resolver: DslResolver;
|
|
58
|
+
/** Local `pgEnum` const bindings, local name -> enum name. */
|
|
59
|
+
readonly enums: ReadonlyMap<string, string>;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
interface MethodCall {
|
|
63
|
+
readonly name: string;
|
|
64
|
+
readonly args: readonly AstNode[];
|
|
65
|
+
readonly node: AstNode;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
interface BuilderChain {
|
|
69
|
+
readonly builder: DslSymbol | null;
|
|
70
|
+
readonly calleeName: string;
|
|
71
|
+
readonly args: readonly AstNode[];
|
|
72
|
+
readonly methods: readonly MethodCall[];
|
|
73
|
+
readonly enumType: string | null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const PG_BUILDERS = new Set([
|
|
77
|
+
"uuid",
|
|
78
|
+
"text",
|
|
79
|
+
"boolean",
|
|
80
|
+
"timestamp",
|
|
81
|
+
"varchar",
|
|
82
|
+
"integer",
|
|
83
|
+
"bigint",
|
|
84
|
+
"numeric",
|
|
85
|
+
"date",
|
|
86
|
+
"jsonb",
|
|
87
|
+
"json",
|
|
88
|
+
"serial",
|
|
89
|
+
"bigserial",
|
|
90
|
+
"real",
|
|
91
|
+
"doublePrecision",
|
|
92
|
+
]);
|
|
93
|
+
|
|
94
|
+
function isKnownBuilder(symbol: DslSymbol): boolean {
|
|
95
|
+
if (symbol.pkg === "pg-core") return PG_BUILDERS.has(symbol.name);
|
|
96
|
+
return symbol.pkg === "facilis-db" && symbol.name === "tenantColumn";
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Split `uuid("id").primaryKey().defaultRandom()` into its builder call and chained methods. */
|
|
100
|
+
function readBuilderChain(
|
|
101
|
+
expression: unknown,
|
|
102
|
+
resolver: DslResolver,
|
|
103
|
+
enums: ReadonlyMap<string, string>,
|
|
104
|
+
): BuilderChain | null {
|
|
105
|
+
let current = unwrapExpression(expression);
|
|
106
|
+
const methods: MethodCall[] = [];
|
|
107
|
+
let guard = 0;
|
|
108
|
+
while (current !== undefined && guard < 32) {
|
|
109
|
+
guard += 1;
|
|
110
|
+
if (current.type !== "CallExpression") return null;
|
|
111
|
+
const callee = unwrapExpression(current.callee);
|
|
112
|
+
if (callee === undefined) return null;
|
|
113
|
+
const args = callArguments(current);
|
|
114
|
+
if (callee.type === "MemberExpression") {
|
|
115
|
+
const name = memberName(callee);
|
|
116
|
+
if (name === undefined) return null;
|
|
117
|
+
methods.unshift({ name, args, node: current });
|
|
118
|
+
current = unwrapExpression(callee.object);
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
const calleeName = identifierName(callee);
|
|
122
|
+
if (calleeName === undefined) return null;
|
|
123
|
+
const enumType = enums.get(calleeName);
|
|
124
|
+
if (enumType !== undefined) {
|
|
125
|
+
return { builder: null, calleeName, args, methods, enumType };
|
|
126
|
+
}
|
|
127
|
+
return { builder: resolver(callee), calleeName, args, methods, enumType: null };
|
|
128
|
+
}
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function optionString(
|
|
133
|
+
options: Record<string, unknown>,
|
|
134
|
+
key: string,
|
|
135
|
+
source: SourceRef,
|
|
136
|
+
unknowns: UnknownFact[],
|
|
137
|
+
): string | null {
|
|
138
|
+
if (!Object.hasOwn(options, key)) return null;
|
|
139
|
+
const value = options[key];
|
|
140
|
+
if (typeof value === "string") return value;
|
|
141
|
+
unknowns.push(schemaUnknown('references() option "' + key + '" is not a string', source));
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function readReference(
|
|
146
|
+
method: MethodCall,
|
|
147
|
+
context: StaticContext,
|
|
148
|
+
file: ProjectFile,
|
|
149
|
+
): { target: ForeignKeyTarget | null; unknowns: UnknownFact[] } {
|
|
150
|
+
const source = nodeRef(file, method.node);
|
|
151
|
+
const target = callbackExpression(method.args[0]);
|
|
152
|
+
const binding =
|
|
153
|
+
target?.type === "MemberExpression"
|
|
154
|
+
? identifierName(unwrapExpression(target.object))
|
|
155
|
+
: undefined;
|
|
156
|
+
const property = target?.type === "MemberExpression" ? memberName(target) : undefined;
|
|
157
|
+
if (binding === undefined || property === undefined) {
|
|
158
|
+
return {
|
|
159
|
+
target: null,
|
|
160
|
+
unknowns: [
|
|
161
|
+
schemaUnknown("references() target is not a static table.column reference", source),
|
|
162
|
+
],
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
const unknowns: UnknownFact[] = [];
|
|
166
|
+
let onDelete: string | null = null;
|
|
167
|
+
let onUpdate: string | null = null;
|
|
168
|
+
if (method.args[1] !== undefined) {
|
|
169
|
+
const options = optionsOf(method.args[1], context);
|
|
170
|
+
if (options === null) {
|
|
171
|
+
unknowns.push(schemaUnknown("references() options are not static", source));
|
|
172
|
+
} else {
|
|
173
|
+
onDelete = optionString(options, "onDelete", source, unknowns);
|
|
174
|
+
onUpdate = optionString(options, "onUpdate", source, unknowns);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return { target: { binding, property, onDelete, onUpdate, source }, unknowns };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export function buildColumnFact(input: ColumnInput): ColumnResult {
|
|
181
|
+
const context: StaticContext = { graph: input.graph, file: input.file.path };
|
|
182
|
+
const valueNode = unwrapExpression(input.expression);
|
|
183
|
+
const source = nodeRef(input.file, valueNode ?? input.propertyNode);
|
|
184
|
+
const chain = readBuilderChain(input.expression, input.resolver, input.enums);
|
|
185
|
+
const unknowns: UnknownFact[] = [];
|
|
186
|
+
let builder = "unknown";
|
|
187
|
+
let type = "unknown";
|
|
188
|
+
let name = input.property;
|
|
189
|
+
let tenant = false;
|
|
190
|
+
let reference: ForeignKeyTarget | null = null;
|
|
191
|
+
|
|
192
|
+
if (chain === null) {
|
|
193
|
+
unknowns.push(
|
|
194
|
+
schemaUnknown('column "' + input.property + '" is not a column builder call', source),
|
|
195
|
+
);
|
|
196
|
+
} else if (chain.enumType !== null) {
|
|
197
|
+
builder = chain.calleeName;
|
|
198
|
+
type = chain.enumType;
|
|
199
|
+
name = literalString(chain.args[0]) ?? input.property;
|
|
200
|
+
} else if (chain.builder !== null && isKnownBuilder(chain.builder)) {
|
|
201
|
+
builder = chain.builder.name;
|
|
202
|
+
type = displayType(chain.builder.name, chain.args, context);
|
|
203
|
+
tenant = chain.builder.pkg === "facilis-db" && chain.builder.name === "tenantColumn";
|
|
204
|
+
const first = chain.args[0];
|
|
205
|
+
if (first === undefined) {
|
|
206
|
+
// `tenantColumn()` defaults its SQL name to tenant_id in @facilis.dev/db; other builders use the property key.
|
|
207
|
+
name = tenant ? "tenant_id" : input.property;
|
|
208
|
+
} else {
|
|
209
|
+
const evaluated = staticString(first, context);
|
|
210
|
+
if (evaluated.ok) {
|
|
211
|
+
name = evaluated.value;
|
|
212
|
+
} else {
|
|
213
|
+
unknowns.push(
|
|
214
|
+
schemaUnknown('SQL name of column "' + input.property + '" is not static', source),
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
} else {
|
|
219
|
+
builder = chain.builder?.name ?? chain.calleeName;
|
|
220
|
+
name = literalString(chain.args[0]) ?? input.property;
|
|
221
|
+
unknowns.push(
|
|
222
|
+
schemaUnknown('column builder "' + builder + '" is outside the documented DSL', source),
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
let notNull = false;
|
|
227
|
+
let primaryKey = false;
|
|
228
|
+
let unique = false;
|
|
229
|
+
let columnDefault: ColumnDefaultFact | null = null;
|
|
230
|
+
let array = false;
|
|
231
|
+
if (chain !== null) {
|
|
232
|
+
for (const method of chain.methods) {
|
|
233
|
+
switch (method.name) {
|
|
234
|
+
case "notNull":
|
|
235
|
+
notNull = true;
|
|
236
|
+
break;
|
|
237
|
+
case "primaryKey":
|
|
238
|
+
primaryKey = true;
|
|
239
|
+
break;
|
|
240
|
+
case "unique":
|
|
241
|
+
unique = true;
|
|
242
|
+
break;
|
|
243
|
+
case "default":
|
|
244
|
+
columnDefault = readDefault(method.args[0], context);
|
|
245
|
+
break;
|
|
246
|
+
case "defaultRandom":
|
|
247
|
+
columnDefault = { kind: "random", value: null };
|
|
248
|
+
break;
|
|
249
|
+
case "defaultNow":
|
|
250
|
+
columnDefault = { kind: "now", value: null };
|
|
251
|
+
break;
|
|
252
|
+
case "array":
|
|
253
|
+
array = true;
|
|
254
|
+
break;
|
|
255
|
+
case "references": {
|
|
256
|
+
const result = readReference(method, context, input.file);
|
|
257
|
+
reference = result.target;
|
|
258
|
+
unknowns.push(...result.unknowns);
|
|
259
|
+
break;
|
|
260
|
+
}
|
|
261
|
+
default:
|
|
262
|
+
unknowns.push(
|
|
263
|
+
schemaUnknown(
|
|
264
|
+
'column method ".' + method.name + '" is outside the documented DSL',
|
|
265
|
+
nodeRef(input.file, method.node),
|
|
266
|
+
),
|
|
267
|
+
);
|
|
268
|
+
break;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
if (array && type !== "unknown") type += "[]";
|
|
273
|
+
|
|
274
|
+
const column: ColumnFact = {
|
|
275
|
+
property: input.property,
|
|
276
|
+
name,
|
|
277
|
+
builder,
|
|
278
|
+
type,
|
|
279
|
+
notNull,
|
|
280
|
+
primaryKey,
|
|
281
|
+
unique,
|
|
282
|
+
default: columnDefault,
|
|
283
|
+
source,
|
|
284
|
+
unknown: unknowns,
|
|
285
|
+
};
|
|
286
|
+
return { column, reference, tenant, diagnostics: schemaDiagnostics(unknowns) };
|
|
287
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import {
|
|
2
|
+
identifierName,
|
|
3
|
+
isAstNode,
|
|
4
|
+
memberName,
|
|
5
|
+
nodeArray,
|
|
6
|
+
unwrapExpression,
|
|
7
|
+
type AstNode,
|
|
8
|
+
} from "../ast/node.ts";
|
|
9
|
+
import { diagnostic } from "../diagnostics.ts";
|
|
10
|
+
import type { ModuleGraph, ProjectFile } from "../module-graph.ts";
|
|
11
|
+
import { sourceRef } from "../source.ts";
|
|
12
|
+
import type { Diagnostic, Severity, SourceRef, UnknownFact } from "../types.ts";
|
|
13
|
+
|
|
14
|
+
/** Package a DSL symbol came from; calls are classified by import specifier, never by name. */
|
|
15
|
+
export type DslPackage = "pg-core" | "drizzle-orm" | "facilis-db";
|
|
16
|
+
|
|
17
|
+
export interface DslSymbol {
|
|
18
|
+
readonly name: string;
|
|
19
|
+
readonly pkg: DslPackage;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Resolves a callee expression to a whitelisted DSL symbol imported from a known package. */
|
|
23
|
+
export type DslResolver = (callee: unknown) => DslSymbol | null;
|
|
24
|
+
|
|
25
|
+
function packageOf(specifier: string): DslPackage | null {
|
|
26
|
+
if (specifier === "drizzle-orm/pg-core" || specifier.startsWith("drizzle-orm/pg-core/")) {
|
|
27
|
+
return "pg-core";
|
|
28
|
+
}
|
|
29
|
+
if (specifier === "drizzle-orm" || specifier.startsWith("drizzle-orm/")) return "drizzle-orm";
|
|
30
|
+
if (specifier === "@facilis.dev/db" || specifier.startsWith("@facilis.dev/db/")) return "facilis-db";
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Package classification for an aliased specifier that resolved into an installed package. */
|
|
35
|
+
function packageOfResolved(path: string): DslPackage | null {
|
|
36
|
+
if (path.includes("node_modules/drizzle-orm/pg-core/")) return "pg-core";
|
|
37
|
+
if (path.includes("node_modules/drizzle-orm/")) return "drizzle-orm";
|
|
38
|
+
if (path.includes("node_modules/@facilis.dev/db/")) return "facilis-db";
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function createDslResolver(graph: ModuleGraph, file: ProjectFile): DslResolver {
|
|
43
|
+
const named = new Map<string, DslSymbol>();
|
|
44
|
+
const namespaces = new Map<string, DslPackage>();
|
|
45
|
+
for (const imported of file.module.imports) {
|
|
46
|
+
let pkg = packageOf(imported.specifier);
|
|
47
|
+
if (pkg === null) {
|
|
48
|
+
const resolved = graph.resolve(file.path, imported.specifier);
|
|
49
|
+
if (resolved !== null) pkg = packageOfResolved(resolved);
|
|
50
|
+
}
|
|
51
|
+
if (pkg === null) continue;
|
|
52
|
+
if (imported.kind === "namespace") {
|
|
53
|
+
namespaces.set(imported.local, pkg);
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
if (imported.kind !== "named" || imported.imported === null) continue;
|
|
57
|
+
named.set(imported.local, { name: imported.imported, pkg });
|
|
58
|
+
}
|
|
59
|
+
return (callee) => {
|
|
60
|
+
const node = unwrapExpression(callee);
|
|
61
|
+
if (node === undefined) return null;
|
|
62
|
+
if (node.type === "Identifier") {
|
|
63
|
+
const local = identifierName(node);
|
|
64
|
+
return local === undefined ? null : (named.get(local) ?? null);
|
|
65
|
+
}
|
|
66
|
+
if (node.type !== "MemberExpression" || node.computed === true) return null;
|
|
67
|
+
const namespace = identifierName(unwrapExpression(node.object));
|
|
68
|
+
const pkg = namespace === undefined ? undefined : namespaces.get(namespace);
|
|
69
|
+
const name = memberName(node);
|
|
70
|
+
if (pkg === undefined || name === undefined) return null;
|
|
71
|
+
return { name, pkg };
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Variable declarators at module top level, including `export const ...`. */
|
|
76
|
+
export function topLevelDeclarators(program: AstNode): AstNode[] {
|
|
77
|
+
const out: AstNode[] = [];
|
|
78
|
+
for (const statement of nodeArray(program, "body")) {
|
|
79
|
+
const declaration =
|
|
80
|
+
statement.type === "VariableDeclaration"
|
|
81
|
+
? statement
|
|
82
|
+
: statement.type === "ExportNamedDeclaration" && isAstNode(statement.declaration)
|
|
83
|
+
? statement.declaration
|
|
84
|
+
: undefined;
|
|
85
|
+
if (declaration === undefined || declaration.type !== "VariableDeclaration") continue;
|
|
86
|
+
out.push(...nodeArray(declaration, "declarations"));
|
|
87
|
+
}
|
|
88
|
+
return out;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** The expression a callback returns; supports `() => x` and `() => { return x; }`. */
|
|
92
|
+
export function callbackExpression(callback: AstNode | undefined): AstNode | undefined {
|
|
93
|
+
const fn = unwrapExpression(callback);
|
|
94
|
+
if (fn === undefined) return undefined;
|
|
95
|
+
if (fn.type !== "ArrowFunctionExpression" && fn.type !== "FunctionExpression") return undefined;
|
|
96
|
+
const body = unwrapExpression(fn.body);
|
|
97
|
+
if (body === undefined || body.type !== "BlockStatement") return body;
|
|
98
|
+
const returns = nodeArray(body, "body").filter(
|
|
99
|
+
(statement) => statement.type === "ReturnStatement",
|
|
100
|
+
);
|
|
101
|
+
const statement = returns.length === 1 ? returns[0] : undefined;
|
|
102
|
+
return statement === undefined ? undefined : unwrapExpression(statement.argument);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function nodeRef(file: ProjectFile, node: AstNode | undefined): SourceRef {
|
|
106
|
+
return sourceRef(file.path, file.source, typeof node?.start === "number" ? node.start : 0);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Schema DSL files live under the project's `db/` directory (root-aware). */
|
|
110
|
+
export function isSchemaFile(graph: ModuleGraph, path: string): boolean {
|
|
111
|
+
const prefix = graph.root.length === 0 ? "db/" : graph.root + "/db/";
|
|
112
|
+
return path.startsWith(prefix);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** View declarations live under `db/views/` (SPEC §11.2, D-157). */
|
|
116
|
+
export function isViewFile(graph: ModuleGraph, path: string): boolean {
|
|
117
|
+
const prefix = graph.root.length === 0 ? "db/views/" : graph.root + "/db/views/";
|
|
118
|
+
return path.startsWith(prefix);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function schemaUnknown(reason: string, source: SourceRef): UnknownFact {
|
|
122
|
+
return { code: "schema-static", reason, source };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function severityOf(code: string): Severity {
|
|
126
|
+
if (code === "fk-target-known") return "warning";
|
|
127
|
+
if (code === "unknown") return "info";
|
|
128
|
+
return "error";
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Unknowns are also diagnostics: the fact model never hides what the interpreter could not read. */
|
|
132
|
+
export function schemaDiagnostics(unknowns: readonly UnknownFact[]): Diagnostic[] {
|
|
133
|
+
return unknowns.map((unknown) =>
|
|
134
|
+
diagnostic(unknown.code, severityOf(unknown.code), unknown.reason, unknown.source),
|
|
135
|
+
);
|
|
136
|
+
}
|