@facilis.dev/analyze 0.0.0-stage → 0.1.0
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 +158 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.js +15 -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.d.ts +22 -0
- package/dist/passes/jsx.js +89 -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-preview.d.ts +29 -0
- package/dist/passes/screen-preview.js +159 -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 +130 -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 +190 -0
- package/src/index.ts +24 -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.ts +92 -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-preview.ts +268 -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 +161 -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,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `fileLinks(parent, { table, column })` -> the synthetic link table it generates (D-053).
|
|
3
|
+
*
|
|
4
|
+
* The call is framework DSL rather than a `pgTable(...)` literal, so the table pass cannot
|
|
5
|
+
* see the link table it returns. The options come from the static subset and the parent
|
|
6
|
+
* table from the declaring file's own drafts; a value the interpreter cannot read becomes an
|
|
7
|
+
* explicit unknown instead of aborting the pass.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { callArguments, identifierName, unwrapExpression, type AstNode } from "../ast/node.ts";
|
|
11
|
+
import type { ModuleGraph, ProjectFile } from "../module-graph.ts";
|
|
12
|
+
import type { StaticContext } from "../static-eval.ts";
|
|
13
|
+
import type { Diagnostic, SourceRef, TableFact, UnknownFact } from "../types.ts";
|
|
14
|
+
import { optionsOf } from "./schema-column-types.ts";
|
|
15
|
+
import type { ColumnReference } from "./schema-columns.ts";
|
|
16
|
+
import {
|
|
17
|
+
createDslResolver,
|
|
18
|
+
nodeRef,
|
|
19
|
+
schemaDiagnostics,
|
|
20
|
+
schemaUnknown,
|
|
21
|
+
topLevelDeclarators,
|
|
22
|
+
} from "./schema-dsl.ts";
|
|
23
|
+
import {
|
|
24
|
+
FACILIS_FILES_BINDING,
|
|
25
|
+
facilisFilesDraft,
|
|
26
|
+
linkColumns,
|
|
27
|
+
linkPolicies,
|
|
28
|
+
} from "./schema-file-links-ddl.ts";
|
|
29
|
+
import type { TableDraft } from "./schema-tables.ts";
|
|
30
|
+
|
|
31
|
+
const DB_PACKAGE = "facilis-db";
|
|
32
|
+
const FILE_LINKS = "fileLinks";
|
|
33
|
+
const DEFAULT_PARENT_COLUMN = "parent_id";
|
|
34
|
+
|
|
35
|
+
export interface FileLinkScan {
|
|
36
|
+
readonly drafts: readonly TableDraft[];
|
|
37
|
+
readonly diagnostics: readonly Diagnostic[];
|
|
38
|
+
/** Lookup-only targets of the generated FKs; see `facilisFilesDraft`. */
|
|
39
|
+
readonly externalTables: readonly TableDraft[];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface LinkOptions {
|
|
43
|
+
/** null when the name is not a static non-empty string. */
|
|
44
|
+
readonly table: string | null;
|
|
45
|
+
readonly parentColumn: string;
|
|
46
|
+
readonly unknowns: readonly UnknownFact[];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function readOptions(
|
|
50
|
+
call: AstNode,
|
|
51
|
+
binding: string | null,
|
|
52
|
+
context: StaticContext,
|
|
53
|
+
source: SourceRef,
|
|
54
|
+
): LinkOptions {
|
|
55
|
+
const label = binding ?? "unknown";
|
|
56
|
+
const options = optionsOf(callArguments(call)[1], context);
|
|
57
|
+
const unknowns: UnknownFact[] = [];
|
|
58
|
+
if (options === null) {
|
|
59
|
+
unknowns.push(
|
|
60
|
+
schemaUnknown('fileLinks options of "' + label + '" are not a static object', source),
|
|
61
|
+
);
|
|
62
|
+
return { table: null, parentColumn: DEFAULT_PARENT_COLUMN, unknowns };
|
|
63
|
+
}
|
|
64
|
+
const rawTable = options["table"];
|
|
65
|
+
let table: string | null = null;
|
|
66
|
+
if (typeof rawTable === "string" && rawTable.length > 0) {
|
|
67
|
+
table = rawTable;
|
|
68
|
+
} else {
|
|
69
|
+
unknowns.push(
|
|
70
|
+
schemaUnknown('fileLinks table name of "' + label + '" is not a static string', source),
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
const rawColumn = options["column"];
|
|
74
|
+
let parentColumn = DEFAULT_PARENT_COLUMN;
|
|
75
|
+
if (rawColumn !== undefined) {
|
|
76
|
+
if (typeof rawColumn === "string" && rawColumn.length > 0) {
|
|
77
|
+
parentColumn = rawColumn;
|
|
78
|
+
} else {
|
|
79
|
+
unknowns.push(
|
|
80
|
+
schemaUnknown('fileLinks column name of "' + label + '" is not a static string', source),
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return { table, parentColumn, unknowns };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
interface ParentTarget {
|
|
88
|
+
readonly binding: string;
|
|
89
|
+
readonly property: string;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function readParent(
|
|
93
|
+
call: AstNode,
|
|
94
|
+
drafts: readonly TableDraft[],
|
|
95
|
+
binding: string | null,
|
|
96
|
+
source: SourceRef,
|
|
97
|
+
): { target: ParentTarget | null; unknowns: readonly UnknownFact[] } {
|
|
98
|
+
const label = binding ?? "unknown";
|
|
99
|
+
const parentNode = unwrapExpression(callArguments(call)[0]);
|
|
100
|
+
const parentBinding = parentNode?.type === "Identifier" ? identifierName(parentNode) : undefined;
|
|
101
|
+
if (parentBinding === undefined) {
|
|
102
|
+
return {
|
|
103
|
+
target: null,
|
|
104
|
+
unknowns: [schemaUnknown('fileLinks parent of "' + label + '" is not an identifier', source)],
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
const draft = drafts.find((entry) => entry.binding === parentBinding);
|
|
108
|
+
if (draft === undefined) {
|
|
109
|
+
return {
|
|
110
|
+
target: null,
|
|
111
|
+
unknowns: [
|
|
112
|
+
schemaUnknown(
|
|
113
|
+
'fileLinks parent "' + parentBinding + '" is not a table declared in this file',
|
|
114
|
+
source,
|
|
115
|
+
),
|
|
116
|
+
],
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
const key = draft.fact.primaryKey[0];
|
|
120
|
+
if (draft.fact.primaryKey.length !== 1 || key === undefined) {
|
|
121
|
+
return {
|
|
122
|
+
target: null,
|
|
123
|
+
unknowns: [
|
|
124
|
+
schemaUnknown(
|
|
125
|
+
'fileLinks parent "' + parentBinding + '" needs a single-column primary key',
|
|
126
|
+
source,
|
|
127
|
+
),
|
|
128
|
+
],
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
return { target: { binding: parentBinding, property: key }, unknowns: [] };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function buildLinkDraft(input: {
|
|
135
|
+
readonly call: AstNode;
|
|
136
|
+
readonly binding: string | null;
|
|
137
|
+
readonly drafts: readonly TableDraft[];
|
|
138
|
+
readonly context: StaticContext;
|
|
139
|
+
readonly source: SourceRef;
|
|
140
|
+
}): { draft: TableDraft; external: TableDraft; unknowns: readonly UnknownFact[] } {
|
|
141
|
+
const { call, binding, drafts, context, source } = input;
|
|
142
|
+
const options = readOptions(call, binding, context, source);
|
|
143
|
+
const parent = readParent(call, drafts, binding, source);
|
|
144
|
+
const references: ColumnReference[] = [];
|
|
145
|
+
if (parent.target !== null) {
|
|
146
|
+
references.push({
|
|
147
|
+
column: "parentId",
|
|
148
|
+
target: {
|
|
149
|
+
binding: parent.target.binding,
|
|
150
|
+
property: parent.target.property,
|
|
151
|
+
onDelete: "cascade",
|
|
152
|
+
onUpdate: null,
|
|
153
|
+
source,
|
|
154
|
+
},
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
// The generated fileId column references facilis_files regardless of how the parent reads.
|
|
158
|
+
references.push({
|
|
159
|
+
column: "fileId",
|
|
160
|
+
target: {
|
|
161
|
+
binding: FACILIS_FILES_BINDING,
|
|
162
|
+
property: "id",
|
|
163
|
+
onDelete: "cascade",
|
|
164
|
+
onUpdate: null,
|
|
165
|
+
source,
|
|
166
|
+
},
|
|
167
|
+
});
|
|
168
|
+
const unknowns: UnknownFact[] = [...options.unknowns, ...parent.unknowns];
|
|
169
|
+
const fact: TableFact = {
|
|
170
|
+
name: options.table ?? binding ?? "unknown",
|
|
171
|
+
binding,
|
|
172
|
+
columns: linkColumns(options.parentColumn, source),
|
|
173
|
+
primaryKey: ["id"],
|
|
174
|
+
foreignKeys: [],
|
|
175
|
+
policies: linkPolicies(source),
|
|
176
|
+
rlsEnabled: true,
|
|
177
|
+
scope: { column: "tenantId", kind: "tenant" },
|
|
178
|
+
source,
|
|
179
|
+
unknown: unknowns,
|
|
180
|
+
};
|
|
181
|
+
return { draft: { fact, binding, references }, external: facilisFilesDraft(source), unknowns };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Read the top-level `fileLinks(...)` declarators of one schema file into table drafts. */
|
|
185
|
+
export function collectFileLinkDrafts(
|
|
186
|
+
graph: ModuleGraph,
|
|
187
|
+
file: ProjectFile,
|
|
188
|
+
drafts: readonly TableDraft[],
|
|
189
|
+
): FileLinkScan {
|
|
190
|
+
const resolver = createDslResolver(graph, file);
|
|
191
|
+
const context: StaticContext = { graph, file: file.path };
|
|
192
|
+
const links: TableDraft[] = [];
|
|
193
|
+
const externalTables: TableDraft[] = [];
|
|
194
|
+
const diagnostics: Diagnostic[] = [];
|
|
195
|
+
for (const declarator of topLevelDeclarators(file.program)) {
|
|
196
|
+
const binding = identifierName(declarator.id) ?? null;
|
|
197
|
+
const init = unwrapExpression(declarator.init);
|
|
198
|
+
if (init?.type !== "CallExpression") continue;
|
|
199
|
+
const symbol = resolver(init.callee);
|
|
200
|
+
if (symbol?.pkg !== DB_PACKAGE || symbol.name !== FILE_LINKS) continue;
|
|
201
|
+
const result = buildLinkDraft({
|
|
202
|
+
call: init,
|
|
203
|
+
binding,
|
|
204
|
+
drafts,
|
|
205
|
+
context,
|
|
206
|
+
source: nodeRef(file, init),
|
|
207
|
+
});
|
|
208
|
+
links.push(result.draft);
|
|
209
|
+
externalTables.push(result.external);
|
|
210
|
+
diagnostics.push(...schemaDiagnostics(result.unknowns));
|
|
211
|
+
}
|
|
212
|
+
return { drafts: links, externalTables, diagnostics };
|
|
213
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { diagnostic } from "../diagnostics.ts";
|
|
2
|
+
import type { ModuleGraph } from "../module-graph.ts";
|
|
3
|
+
import type { Diagnostic, ForeignKeyFact, TableFact } from "../types.ts";
|
|
4
|
+
import type { TableDraft } from "./schema-tables.ts";
|
|
5
|
+
|
|
6
|
+
export interface TableLookup {
|
|
7
|
+
/** `file#binding` -> table name; the key keeps same-named bindings in different modules apart. */
|
|
8
|
+
readonly byBinding: ReadonlyMap<string, string>;
|
|
9
|
+
readonly byName: ReadonlyMap<string, TableDraft>;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function buildTableLookup(drafts: readonly TableDraft[]): TableLookup {
|
|
13
|
+
const byBinding = new Map<string, string>();
|
|
14
|
+
const byName = new Map<string, TableDraft>();
|
|
15
|
+
for (const draft of drafts) {
|
|
16
|
+
if (draft.binding !== null) {
|
|
17
|
+
byBinding.set(draft.fact.source.file + "#" + draft.binding, draft.fact.name);
|
|
18
|
+
}
|
|
19
|
+
byName.set(draft.fact.name, draft);
|
|
20
|
+
}
|
|
21
|
+
return { byBinding, byName };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Add lookups for tables a DSL call expands to but the analysis does not emit: a generated
|
|
26
|
+
* `fileLinks` FK points at the framework's `facilis_files`, whose source is absent from a
|
|
27
|
+
* release-ref map. The extra tables resolve FK targets only; they never reach `tables`.
|
|
28
|
+
*/
|
|
29
|
+
export function withFrameworkTables(
|
|
30
|
+
lookup: TableLookup,
|
|
31
|
+
tables: readonly TableDraft[],
|
|
32
|
+
): TableLookup {
|
|
33
|
+
if (tables.length === 0) return lookup;
|
|
34
|
+
const byBinding = new Map(lookup.byBinding);
|
|
35
|
+
const byName = new Map(lookup.byName);
|
|
36
|
+
for (const table of tables) {
|
|
37
|
+
if (!byName.has(table.fact.name)) byName.set(table.fact.name, table);
|
|
38
|
+
if (table.binding === null) continue;
|
|
39
|
+
const key = table.fact.source.file + "#" + table.binding;
|
|
40
|
+
if (!byBinding.has(key)) byBinding.set(key, table.fact.name);
|
|
41
|
+
}
|
|
42
|
+
return { byBinding, byName };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Resolve a table identifier used in the DSL (local const or any project/framework import). */
|
|
46
|
+
export function resolveTableBinding(
|
|
47
|
+
graph: ModuleGraph,
|
|
48
|
+
filePath: string,
|
|
49
|
+
local: string,
|
|
50
|
+
lookup: TableLookup,
|
|
51
|
+
): string | null {
|
|
52
|
+
const binding = graph.resolveBinding(filePath, local);
|
|
53
|
+
if (binding !== null) {
|
|
54
|
+
const table = lookup.byBinding.get(binding.file + "#" + binding.name);
|
|
55
|
+
if (table !== undefined) return table;
|
|
56
|
+
}
|
|
57
|
+
return lookup.byBinding.get(filePath + "#" + local) ?? null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function finalizeTables(
|
|
61
|
+
graph: ModuleGraph,
|
|
62
|
+
drafts: readonly TableDraft[],
|
|
63
|
+
lookup: TableLookup,
|
|
64
|
+
): { tables: TableFact[]; diagnostics: Diagnostic[] } {
|
|
65
|
+
const tables: TableFact[] = [];
|
|
66
|
+
const diagnostics: Diagnostic[] = [];
|
|
67
|
+
for (const draft of drafts) {
|
|
68
|
+
const foreignKeys: ForeignKeyFact[] = [];
|
|
69
|
+
for (const reference of draft.references) {
|
|
70
|
+
const table = draft.fact.name;
|
|
71
|
+
const targetTable = resolveTableBinding(
|
|
72
|
+
graph,
|
|
73
|
+
draft.fact.source.file,
|
|
74
|
+
reference.target.binding,
|
|
75
|
+
lookup,
|
|
76
|
+
);
|
|
77
|
+
if (targetTable === null) {
|
|
78
|
+
diagnostics.push(
|
|
79
|
+
diagnostic(
|
|
80
|
+
"fk-target-known",
|
|
81
|
+
"warning",
|
|
82
|
+
'table "' + table + '" references unknown table "' + reference.target.binding + '"',
|
|
83
|
+
reference.target.source,
|
|
84
|
+
),
|
|
85
|
+
);
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
const targetDraft = lookup.byName.get(targetTable);
|
|
89
|
+
const knownColumn = targetDraft?.fact.columns.some(
|
|
90
|
+
(column) => column.property === reference.target.property,
|
|
91
|
+
);
|
|
92
|
+
if (knownColumn !== true) {
|
|
93
|
+
diagnostics.push(
|
|
94
|
+
diagnostic(
|
|
95
|
+
"fk-target-known",
|
|
96
|
+
"warning",
|
|
97
|
+
'table "' +
|
|
98
|
+
table +
|
|
99
|
+
'" references unknown column "' +
|
|
100
|
+
targetTable +
|
|
101
|
+
"." +
|
|
102
|
+
reference.target.property +
|
|
103
|
+
'"',
|
|
104
|
+
reference.target.source,
|
|
105
|
+
),
|
|
106
|
+
);
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
foreignKeys.push({
|
|
110
|
+
columns: [reference.column],
|
|
111
|
+
targetTable,
|
|
112
|
+
targetColumns: [reference.target.property],
|
|
113
|
+
onDelete: reference.target.onDelete,
|
|
114
|
+
onUpdate: reference.target.onUpdate,
|
|
115
|
+
source: reference.target.source,
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
tables.push({ ...draft.fact, foreignKeys });
|
|
119
|
+
}
|
|
120
|
+
return { tables, diagnostics };
|
|
121
|
+
}
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
import {
|
|
2
|
+
callArguments,
|
|
3
|
+
identifierName,
|
|
4
|
+
memberName,
|
|
5
|
+
nodeArray,
|
|
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 { Diagnostic, PolicyCommand, PolicyFact, SourceRef, UnknownFact } from "../types.ts";
|
|
12
|
+
import { optionsOf } from "./schema-column-types.ts";
|
|
13
|
+
import {
|
|
14
|
+
callbackExpression,
|
|
15
|
+
nodeRef,
|
|
16
|
+
schemaDiagnostics,
|
|
17
|
+
schemaUnknown,
|
|
18
|
+
type DslResolver,
|
|
19
|
+
type DslSymbol,
|
|
20
|
+
} from "./schema-dsl.ts";
|
|
21
|
+
|
|
22
|
+
export interface PolicyInput {
|
|
23
|
+
readonly extras: AstNode | undefined;
|
|
24
|
+
readonly file: ProjectFile;
|
|
25
|
+
readonly graph: ModuleGraph;
|
|
26
|
+
readonly resolver: DslResolver;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface PolicyScan {
|
|
30
|
+
readonly policies: readonly PolicyFact[];
|
|
31
|
+
readonly diagnostics: readonly Diagnostic[];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const COMMANDS: Readonly<Record<string, PolicyCommand>> = {
|
|
35
|
+
select: "SELECT",
|
|
36
|
+
insert: "INSERT",
|
|
37
|
+
update: "UPDATE",
|
|
38
|
+
delete: "DELETE",
|
|
39
|
+
all: "ALL",
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
function scan(policies: readonly PolicyFact[]): PolicyScan {
|
|
43
|
+
return {
|
|
44
|
+
policies,
|
|
45
|
+
diagnostics: schemaDiagnostics(policies.flatMap((policy) => policy.unknown)),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function fixedPolicy(
|
|
50
|
+
name: string,
|
|
51
|
+
command: PolicyCommand,
|
|
52
|
+
restrictive: boolean,
|
|
53
|
+
source: SourceRef,
|
|
54
|
+
unknown: readonly UnknownFact[] = [],
|
|
55
|
+
): PolicyFact {
|
|
56
|
+
return { name, command, restrictive, source, unknown };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function unknownPolicyScan(name: string, reason: string, source: SourceRef): PolicyScan {
|
|
60
|
+
const unknown = schemaUnknown(reason, source);
|
|
61
|
+
return {
|
|
62
|
+
policies: [{ name, command: "ALL", restrictive: false, source, unknown: [unknown] }],
|
|
63
|
+
diagnostics: schemaDiagnostics([unknown]),
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function calleeLabel(callee: unknown): string {
|
|
68
|
+
const node = unwrapExpression(callee);
|
|
69
|
+
if (node === undefined) return "policy";
|
|
70
|
+
return identifierName(node) ?? memberName(node) ?? "policy";
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function readPgPolicy(call: AstNode, context: StaticContext, file: ProjectFile): PolicyScan {
|
|
74
|
+
const source = nodeRef(file, call);
|
|
75
|
+
const args = callArguments(call);
|
|
76
|
+
const unknowns: UnknownFact[] = [];
|
|
77
|
+
let name = "unknown";
|
|
78
|
+
const nameResult = staticString(args[0], context);
|
|
79
|
+
if (nameResult.ok) {
|
|
80
|
+
name = nameResult.value;
|
|
81
|
+
} else {
|
|
82
|
+
unknowns.push(schemaUnknown("pgPolicy name is not a static string", source));
|
|
83
|
+
}
|
|
84
|
+
let command: PolicyCommand = "ALL";
|
|
85
|
+
let restrictive = false;
|
|
86
|
+
if (args[1] !== undefined) {
|
|
87
|
+
const options = optionsOf(args[1], context);
|
|
88
|
+
if (options === null) {
|
|
89
|
+
unknowns.push(schemaUnknown("pgPolicy options are not static", source));
|
|
90
|
+
} else {
|
|
91
|
+
const rawFor = options["for"];
|
|
92
|
+
if (rawFor !== undefined) {
|
|
93
|
+
const parsed = typeof rawFor === "string" ? (COMMANDS[rawFor.toLowerCase()] ?? null) : null;
|
|
94
|
+
if (parsed === null) {
|
|
95
|
+
unknowns.push(schemaUnknown('pgPolicy "for" is not a policy command', source));
|
|
96
|
+
} else {
|
|
97
|
+
command = parsed;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
const rawAs = options["as"];
|
|
101
|
+
if (rawAs === "restrictive") {
|
|
102
|
+
restrictive = true;
|
|
103
|
+
} else if (rawAs !== undefined && rawAs !== "permissive") {
|
|
104
|
+
unknowns.push(schemaUnknown('pgPolicy "as" is not restrictive or permissive', source));
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return scan([fixedPolicy(name, command, restrictive, source, unknowns)]);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** `rlsForTable({...})` expands to the same policy set `@facilis.dev/db` builds at runtime. */
|
|
112
|
+
function expandRlsTable(call: AstNode, context: StaticContext, file: ProjectFile): PolicyScan {
|
|
113
|
+
const source = nodeRef(file, call);
|
|
114
|
+
const args = callArguments(call);
|
|
115
|
+
let options: Record<string, unknown> = {};
|
|
116
|
+
if (args[0] !== undefined) {
|
|
117
|
+
const evaluated = optionsOf(args[0], context);
|
|
118
|
+
if (evaluated === null) {
|
|
119
|
+
const unknown = schemaUnknown("rlsForTable options are not static", source);
|
|
120
|
+
return {
|
|
121
|
+
policies: [fixedPolicy("tenant_isolation", "ALL", true, source, [unknown])],
|
|
122
|
+
diagnostics: schemaDiagnostics([unknown]),
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
options = evaluated;
|
|
126
|
+
}
|
|
127
|
+
const policies: PolicyFact[] = [fixedPolicy("tenant_isolation", "ALL", true, source)];
|
|
128
|
+
const ownerColumn = options["ownerColumn"];
|
|
129
|
+
if (Object.hasOwn(options, "ownerColumn")) {
|
|
130
|
+
const unknown =
|
|
131
|
+
typeof ownerColumn === "string"
|
|
132
|
+
? []
|
|
133
|
+
: [schemaUnknown("rlsForTable ownerColumn is not a string", source)];
|
|
134
|
+
policies.push(fixedPolicy("owner_isolation", "ALL", false, source, unknown));
|
|
135
|
+
}
|
|
136
|
+
const rolesColumn = options["rolesColumn"];
|
|
137
|
+
if (Object.hasOwn(options, "rolesColumn")) {
|
|
138
|
+
const unknown =
|
|
139
|
+
typeof rolesColumn === "string"
|
|
140
|
+
? []
|
|
141
|
+
: [schemaUnknown("rlsForTable rolesColumn is not a string", source)];
|
|
142
|
+
policies.push(fixedPolicy("role_access", "ALL", false, source, unknown));
|
|
143
|
+
}
|
|
144
|
+
return scan(policies);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function readRlsExpansion(
|
|
148
|
+
node: AstNode | undefined,
|
|
149
|
+
context: StaticContext,
|
|
150
|
+
file: ProjectFile,
|
|
151
|
+
resolver: DslResolver,
|
|
152
|
+
): PolicyScan | null {
|
|
153
|
+
if (node === undefined) return null;
|
|
154
|
+
let call: AstNode | undefined;
|
|
155
|
+
if (node.type === "CallExpression") {
|
|
156
|
+
call = node;
|
|
157
|
+
} else if (node.type === "MemberExpression" && memberName(node) === "policies") {
|
|
158
|
+
const object = unwrapExpression(node.object);
|
|
159
|
+
if (object?.type === "CallExpression") call = object;
|
|
160
|
+
}
|
|
161
|
+
if (call === undefined) return null;
|
|
162
|
+
const symbol: DslSymbol | null = resolver(call.callee);
|
|
163
|
+
if (
|
|
164
|
+
symbol?.pkg !== "facilis-db" ||
|
|
165
|
+
(symbol.name !== "rlsForTable" && symbol.name !== "withRls")
|
|
166
|
+
) {
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
return expandRlsTable(call, context, file);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function readPolicyEntry(
|
|
173
|
+
element: AstNode,
|
|
174
|
+
context: StaticContext,
|
|
175
|
+
file: ProjectFile,
|
|
176
|
+
resolver: DslResolver,
|
|
177
|
+
): PolicyScan {
|
|
178
|
+
const node = unwrapExpression(element);
|
|
179
|
+
const source = nodeRef(file, node ?? element);
|
|
180
|
+
const expansion = readRlsExpansion(node, context, file, resolver);
|
|
181
|
+
if (expansion !== null) return expansion;
|
|
182
|
+
if (node?.type !== "CallExpression") {
|
|
183
|
+
return unknownPolicyScan("policy", "table extras entry is not a policy call", source);
|
|
184
|
+
}
|
|
185
|
+
const symbol = resolver(node.callee);
|
|
186
|
+
if (symbol?.pkg === "facilis-db") {
|
|
187
|
+
if (symbol.name === "tenantIsolationPolicy") {
|
|
188
|
+
return scan([fixedPolicy("tenant_isolation", "ALL", true, source)]);
|
|
189
|
+
}
|
|
190
|
+
if (symbol.name === "ownerIsolationPolicy") {
|
|
191
|
+
return scan([fixedPolicy("owner_isolation", "ALL", false, source)]);
|
|
192
|
+
}
|
|
193
|
+
if (symbol.name === "rolePolicy") {
|
|
194
|
+
const args = callArguments(node);
|
|
195
|
+
const unknowns: UnknownFact[] = [];
|
|
196
|
+
let name = "role_access";
|
|
197
|
+
if (args[1] !== undefined) {
|
|
198
|
+
const evaluated = staticString(args[1], context);
|
|
199
|
+
if (evaluated.ok) {
|
|
200
|
+
name = evaluated.value;
|
|
201
|
+
} else {
|
|
202
|
+
unknowns.push(schemaUnknown("rolePolicy name is not a static string", source));
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
return scan([fixedPolicy(name, "ALL", false, source, unknowns)]);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
if (symbol?.pkg === "pg-core" && symbol.name === "pgPolicy") {
|
|
209
|
+
return readPgPolicy(node, context, file);
|
|
210
|
+
}
|
|
211
|
+
return unknownPolicyScan(
|
|
212
|
+
calleeLabel(node.callee),
|
|
213
|
+
'policy helper "' + calleeLabel(node.callee) + '" is outside the documented DSL',
|
|
214
|
+
source,
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function readPolicies(
|
|
219
|
+
expression: AstNode,
|
|
220
|
+
context: StaticContext,
|
|
221
|
+
file: ProjectFile,
|
|
222
|
+
resolver: DslResolver,
|
|
223
|
+
): PolicyScan {
|
|
224
|
+
const node = unwrapExpression(expression);
|
|
225
|
+
if (node === undefined) {
|
|
226
|
+
return unknownPolicyScan(
|
|
227
|
+
"policy",
|
|
228
|
+
"table extras are not a policy expression",
|
|
229
|
+
nodeRef(file, expression),
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
if (node.type === "ArrayExpression") {
|
|
233
|
+
const policies: PolicyFact[] = [];
|
|
234
|
+
const diagnostics: Diagnostic[] = [];
|
|
235
|
+
for (const element of nodeArray(node, "elements")) {
|
|
236
|
+
const spreadArgument =
|
|
237
|
+
element.type === "SpreadElement" ? unwrapExpression(element.argument) : undefined;
|
|
238
|
+
const nested =
|
|
239
|
+
element.type === "SpreadElement"
|
|
240
|
+
? spreadArgument === undefined
|
|
241
|
+
? unknownPolicyScan(
|
|
242
|
+
"policy",
|
|
243
|
+
"spread policy entry is not an expression",
|
|
244
|
+
nodeRef(file, element),
|
|
245
|
+
)
|
|
246
|
+
: readPolicies(spreadArgument, context, file, resolver)
|
|
247
|
+
: readPolicyEntry(element, context, file, resolver);
|
|
248
|
+
policies.push(...nested.policies);
|
|
249
|
+
diagnostics.push(...nested.diagnostics);
|
|
250
|
+
}
|
|
251
|
+
return { policies, diagnostics };
|
|
252
|
+
}
|
|
253
|
+
const expansion = readRlsExpansion(node, context, file, resolver);
|
|
254
|
+
if (expansion !== null) return expansion;
|
|
255
|
+
return readPolicyEntry(node, context, file, resolver);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
export function buildPolicyFacts(input: PolicyInput): PolicyScan {
|
|
259
|
+
if (input.extras === undefined) return { policies: [], diagnostics: [] };
|
|
260
|
+
const expression = callbackExpression(input.extras);
|
|
261
|
+
if (expression === undefined) {
|
|
262
|
+
return unknownPolicyScan(
|
|
263
|
+
"policy",
|
|
264
|
+
"table extras are not a readable callback",
|
|
265
|
+
nodeRef(input.file, input.extras),
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
const context: StaticContext = { graph: input.graph, file: input.file.path };
|
|
269
|
+
return readPolicies(expression, context, input.file, input.resolver);
|
|
270
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { diagnostic } from "../diagnostics.ts";
|
|
2
|
+
import type { ModuleGraph, ProjectFile } from "../module-graph.ts";
|
|
3
|
+
import { identifierName, literalString, nodeArray } from "../ast/node.ts";
|
|
4
|
+
import type { Diagnostic, ConfigFacts } from "../types.ts";
|
|
5
|
+
import { nodeRef } from "./schema-dsl.ts";
|
|
6
|
+
import { collectTableDrafts, type TableDraft, type TableScan } from "./schema-tables.ts";
|
|
7
|
+
|
|
8
|
+
/** Merge the tables of `export { users } from "@facilis.dev/auth"` (framework source, recursively). */
|
|
9
|
+
export function collectReExportedDrafts(
|
|
10
|
+
graph: ModuleGraph,
|
|
11
|
+
file: ProjectFile,
|
|
12
|
+
config: ConfigFacts,
|
|
13
|
+
seenNames: Set<string>,
|
|
14
|
+
visitedFiles: Set<string>,
|
|
15
|
+
): TableScan {
|
|
16
|
+
const drafts: TableDraft[] = [];
|
|
17
|
+
const diagnostics: Diagnostic[] = [];
|
|
18
|
+
for (const statement of nodeArray(file.program, "body")) {
|
|
19
|
+
const hasSource =
|
|
20
|
+
statement.type === "ExportNamedDeclaration" || statement.type === "ExportAllDeclaration";
|
|
21
|
+
const specifier = hasSource ? literalString(statement.source) : undefined;
|
|
22
|
+
if (specifier === undefined) continue;
|
|
23
|
+
const targetPath = graph.resolve(file.path, specifier);
|
|
24
|
+
const target = targetPath === null ? undefined : graph.file(targetPath);
|
|
25
|
+
if (target === undefined) {
|
|
26
|
+
diagnostics.push(
|
|
27
|
+
diagnostic(
|
|
28
|
+
"unknown",
|
|
29
|
+
"info",
|
|
30
|
+
'cannot resolve re-exported module "' + specifier + '"',
|
|
31
|
+
nodeRef(file, statement),
|
|
32
|
+
),
|
|
33
|
+
);
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
if (visitedFiles.has(target.path)) continue;
|
|
37
|
+
visitedFiles.add(target.path);
|
|
38
|
+
const local = collectTableDrafts(graph, target, config);
|
|
39
|
+
diagnostics.push(...local.diagnostics);
|
|
40
|
+
for (const draft of local.drafts) {
|
|
41
|
+
if (seenNames.has(draft.fact.name)) continue;
|
|
42
|
+
seenNames.add(draft.fact.name);
|
|
43
|
+
drafts.push(draft);
|
|
44
|
+
}
|
|
45
|
+
const nested = collectReExportedDrafts(graph, target, config, seenNames, visitedFiles);
|
|
46
|
+
drafts.push(...nested.drafts);
|
|
47
|
+
diagnostics.push(...nested.diagnostics);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// The example project imports framework tables and re-exports the local bindings:
|
|
51
|
+
// import { users } from "@facilis.dev/auth"; export { users };
|
|
52
|
+
// Follow the import, keep only the tables whose binding was re-exported.
|
|
53
|
+
for (const statement of nodeArray(file.program, "body")) {
|
|
54
|
+
if (
|
|
55
|
+
statement.type !== "ExportNamedDeclaration" ||
|
|
56
|
+
literalString(statement.source) !== undefined
|
|
57
|
+
)
|
|
58
|
+
continue;
|
|
59
|
+
for (const item of nodeArray(statement, "specifiers")) {
|
|
60
|
+
if (item.type !== "ExportSpecifier") continue;
|
|
61
|
+
const local = identifierName(item.local);
|
|
62
|
+
if (local === undefined) continue;
|
|
63
|
+
const imported = file.module.imports.find((entry) => entry.local === local);
|
|
64
|
+
if (imported === undefined) continue;
|
|
65
|
+
const targetPath = graph.resolve(file.path, imported.specifier);
|
|
66
|
+
const target = targetPath === null ? undefined : graph.file(targetPath);
|
|
67
|
+
if (target === undefined) {
|
|
68
|
+
diagnostics.push(
|
|
69
|
+
diagnostic(
|
|
70
|
+
"unknown",
|
|
71
|
+
"info",
|
|
72
|
+
'cannot resolve re-exported module "' + imported.specifier + '"',
|
|
73
|
+
nodeRef(file, item),
|
|
74
|
+
),
|
|
75
|
+
);
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
if (visitedFiles.has(target.path)) continue;
|
|
79
|
+
visitedFiles.add(target.path);
|
|
80
|
+
const localScan = collectTableDrafts(graph, target, config);
|
|
81
|
+
diagnostics.push(...localScan.diagnostics);
|
|
82
|
+
for (const draft of localScan.drafts) {
|
|
83
|
+
const importedName = imported.imported ?? local;
|
|
84
|
+
if (draft.binding !== importedName && draft.fact.name !== importedName) continue;
|
|
85
|
+
if (seenNames.has(draft.fact.name)) continue;
|
|
86
|
+
seenNames.add(draft.fact.name);
|
|
87
|
+
drafts.push(draft);
|
|
88
|
+
}
|
|
89
|
+
const nested = collectReExportedDrafts(graph, target, config, seenNames, visitedFiles);
|
|
90
|
+
drafts.push(...nested.drafts);
|
|
91
|
+
diagnostics.push(...nested.diagnostics);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return { drafts, diagnostics };
|
|
95
|
+
}
|