@gonvex/cli 0.5.2-staging.2 → 0.5.2-staging.20
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/dist/index.js +71 -7
- package/dist/index.js.map +1 -1
- package/dist/lazy-reference-bindings.d.ts +3 -0
- package/dist/lazy-reference-bindings.js +22 -0
- package/dist/lazy-reference-bindings.js.map +1 -0
- package/dist/local-bindings.d.ts +4 -0
- package/dist/local-bindings.js +106 -0
- package/dist/local-bindings.js.map +1 -0
- package/dist/local-dependencies.d.ts +58 -0
- package/dist/local-dependencies.js +850 -0
- package/dist/local-dependencies.js.map +1 -0
- package/dist/local-schema.d.ts +6 -0
- package/dist/local-schema.js +55 -0
- package/dist/local-schema.js.map +1 -0
- package/dist/manifest-types.d.ts +43 -2
- package/dist/module-artifact.js +155 -8
- package/dist/module-artifact.js.map +1 -1
- package/dist/native-local-bindings.d.ts +2 -0
- package/dist/native-local-bindings.js +22 -0
- package/dist/native-local-bindings.js.map +1 -0
- package/dist/pglite-browser-bundle.d.ts +3 -0
- package/dist/pglite-browser-bundle.js +35 -0
- package/dist/pglite-browser-bundle.js.map +1 -0
- package/package.json +7 -5
- package/dist/templates/vite-react/gonvex/_build/module.js +0 -572
|
@@ -0,0 +1,850 @@
|
|
|
1
|
+
// Static data dependencies of Reducers that the client executes locally.
|
|
2
|
+
//
|
|
3
|
+
// The generated client runs every locally executing Reducer body on the
|
|
4
|
+
// device before the server replays it. The portable local runtime can read
|
|
5
|
+
// only tables that have a generated `__local.<table>` Replica Collection. A
|
|
6
|
+
// read of any other table stops the local run with IncompleteReplicaError; the
|
|
7
|
+
// client then queues the intent with no local result, so the change waits for
|
|
8
|
+
// the server and never shows while offline.
|
|
9
|
+
//
|
|
10
|
+
// This analysis finds, for each such Reducer, every table its body can touch,
|
|
11
|
+
// directly or through any project function it calls, using the TypeScript
|
|
12
|
+
// type checker:
|
|
13
|
+
//
|
|
14
|
+
// - Module SDK data helpers (`selectRows`, `selectFirst`, `getRow`,
|
|
15
|
+
// `selectDataBatch`, `existsDataRows`, `summarizeRows`, `insertDataRows`,
|
|
16
|
+
// `updateDataRows`, `deleteDataRows`, `updateDataWhere`, `deleteDataWhere`,
|
|
17
|
+
// `applyDataWrites`) and the database methods (`select`, `insert`, `update`,
|
|
18
|
+
// `delete`, `deleteMany`, `query`) on any value typed as a Gonvex database.
|
|
19
|
+
// - The table expression is resolved from literals, literal types, constants,
|
|
20
|
+
// object and array literals, spreads, `for...of` and array callbacks. When
|
|
21
|
+
// it comes from a parameter, the parameter becomes a sink of that function
|
|
22
|
+
// and every call site resolves its own argument, so generic helpers that
|
|
23
|
+
// receive a table name are followed per call site.
|
|
24
|
+
// - A table expression that cannot be resolved is reported, never dropped.
|
|
25
|
+
//
|
|
26
|
+
// Precision: the analysis over-approximates. Every branch counts, whether or
|
|
27
|
+
// not it runs for a given argument, and a helper that can touch several tables
|
|
28
|
+
// contributes all of them. It does not follow functions reached only through
|
|
29
|
+
// dynamic dispatch (a function stored in a map and called by key, a class
|
|
30
|
+
// method called through an interface); a table taken from a parameter of
|
|
31
|
+
// such a function is reported as unresolved rather than guessed.
|
|
32
|
+
//
|
|
33
|
+
// Local semantics (pinned by @gonvex/local-runtime tests): every structured
|
|
34
|
+
// read needs coverage; `update`, `delete`, `deleteMany` and the data-write
|
|
35
|
+
// helpers read the row first; `insert` reads only when the row carries an
|
|
36
|
+
// explicit primary key (an intent-generated key is new by construction).
|
|
37
|
+
// Raw SQL (`db.query`) always fails locally.
|
|
38
|
+
//
|
|
39
|
+
// The TypeScript compiler is loaded lazily, only for projects that have
|
|
40
|
+
// locally executing Reducers.
|
|
41
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
42
|
+
import { createRequire } from "node:module";
|
|
43
|
+
import { dirname, join, relative, resolve, sep } from "node:path";
|
|
44
|
+
const readHelpers = {
|
|
45
|
+
selectRows: { argument: 1, path: ["table"], operation: "select" },
|
|
46
|
+
selectFirst: { argument: 1, path: ["table"], operation: "select" },
|
|
47
|
+
summarizeRows: { argument: 1, path: ["table"], operation: "summarize" },
|
|
48
|
+
getRow: { argument: 1, path: [], operation: "select" },
|
|
49
|
+
selectDataBatch: { argument: 1, path: ["[]", "table"], operation: "select" },
|
|
50
|
+
existsDataRows: { argument: 1, path: ["[]", "table"], operation: "exists" },
|
|
51
|
+
updateDataRows: { argument: 1, path: [], operation: "update" },
|
|
52
|
+
deleteDataRows: { argument: 1, path: [], operation: "delete" },
|
|
53
|
+
updateDataWhere: { argument: 1, path: ["table"], operation: "update" },
|
|
54
|
+
deleteDataWhere: { argument: 1, path: ["table"], operation: "delete" },
|
|
55
|
+
};
|
|
56
|
+
const databaseMethods = new Set(["select", "insert", "update", "delete", "deleteMany", "query"]);
|
|
57
|
+
const nonExecutedDefinitions = new Set(["action", "query", "internalQuery", "liveQuery", "replicaCollection", "visibility", "cron", "tenantCron"]);
|
|
58
|
+
const arrayCallbacks = new Set(["map", "flatMap", "forEach", "filter", "some", "every", "find", "findIndex", "reduce"]);
|
|
59
|
+
// The compiler API is loaded on demand; every helper below runs after it is set.
|
|
60
|
+
let ts;
|
|
61
|
+
const empty = () => ({ tables: new Set(), params: [], unresolved: false });
|
|
62
|
+
const unresolvedEvaluation = () => ({ tables: new Set(), params: [], unresolved: true });
|
|
63
|
+
function merge(...values) {
|
|
64
|
+
const result = empty();
|
|
65
|
+
for (const value of values) {
|
|
66
|
+
value.tables.forEach((table) => result.tables.add(table));
|
|
67
|
+
result.params.push(...value.params);
|
|
68
|
+
result.unresolved ||= value.unresolved;
|
|
69
|
+
}
|
|
70
|
+
return result;
|
|
71
|
+
}
|
|
72
|
+
function isFunctionUnit(node) {
|
|
73
|
+
return ts.isFunctionDeclaration(node) || ts.isFunctionExpression(node) || ts.isArrowFunction(node)
|
|
74
|
+
|| ts.isMethodDeclaration(node) || ts.isGetAccessorDeclaration(node) || ts.isSetAccessorDeclaration(node)
|
|
75
|
+
|| ts.isConstructorDeclaration(node);
|
|
76
|
+
}
|
|
77
|
+
function skipOuter(expression) {
|
|
78
|
+
let current = expression;
|
|
79
|
+
while (ts.isParenthesizedExpression(current) || ts.isAsExpression(current) || ts.isSatisfiesExpression(current)
|
|
80
|
+
|| ts.isNonNullExpression(current) || ts.isTypeAssertionExpression(current))
|
|
81
|
+
current = current.expression;
|
|
82
|
+
return current;
|
|
83
|
+
}
|
|
84
|
+
function propertyName(name) {
|
|
85
|
+
if (!name)
|
|
86
|
+
return undefined;
|
|
87
|
+
if (ts.isIdentifier(name) || ts.isStringLiteralLike(name) || ts.isNumericLiteral(name))
|
|
88
|
+
return name.text;
|
|
89
|
+
return undefined;
|
|
90
|
+
}
|
|
91
|
+
function slash(path) {
|
|
92
|
+
return path.split(sep).join("/");
|
|
93
|
+
}
|
|
94
|
+
class LocalReducerDependencyAnalyzer {
|
|
95
|
+
program;
|
|
96
|
+
root;
|
|
97
|
+
sdkRoots;
|
|
98
|
+
checker;
|
|
99
|
+
summaries = new Map();
|
|
100
|
+
inProgress = new Set();
|
|
101
|
+
constructor(program, root, sdkRoots) {
|
|
102
|
+
this.program = program;
|
|
103
|
+
this.root = root;
|
|
104
|
+
this.sdkRoots = sdkRoots;
|
|
105
|
+
this.checker = program.getTypeChecker();
|
|
106
|
+
}
|
|
107
|
+
isSdkFile(fileName) {
|
|
108
|
+
const file = slash(fileName);
|
|
109
|
+
return file.includes("/@gonvex/module-sdk/") || this.sdkRoots.some((root) => file.startsWith(root));
|
|
110
|
+
}
|
|
111
|
+
isProjectFile(file) {
|
|
112
|
+
if (file.isDeclarationFile || this.isSdkFile(file.fileName))
|
|
113
|
+
return false;
|
|
114
|
+
const path = slash(relative(this.root, file.fileName));
|
|
115
|
+
return !path.startsWith("..") && !path.includes("node_modules/") && !path.includes("/_generated/");
|
|
116
|
+
}
|
|
117
|
+
site(node) {
|
|
118
|
+
const file = node.getSourceFile();
|
|
119
|
+
const { line } = file.getLineAndCharacterOfPosition(node.getStart(file));
|
|
120
|
+
return `${slash(relative(this.root, file.fileName))}:${line + 1}`;
|
|
121
|
+
}
|
|
122
|
+
symbol(node) {
|
|
123
|
+
let symbol = this.checker.getSymbolAtLocation(node);
|
|
124
|
+
if (symbol && symbol.flags & ts.SymbolFlags.Alias)
|
|
125
|
+
symbol = this.checker.getAliasedSymbol(symbol);
|
|
126
|
+
return symbol;
|
|
127
|
+
}
|
|
128
|
+
sdkHelper(callee) {
|
|
129
|
+
const target = ts.isPropertyAccessExpression(callee) ? callee.name : callee;
|
|
130
|
+
const symbol = this.symbol(target);
|
|
131
|
+
const declaration = symbol?.declarations?.[0];
|
|
132
|
+
if (!symbol || !declaration)
|
|
133
|
+
return undefined;
|
|
134
|
+
if (!this.isSdkFile(declaration.getSourceFile().fileName) || !ts.isFunctionDeclaration(declaration))
|
|
135
|
+
return undefined;
|
|
136
|
+
return symbol.getName();
|
|
137
|
+
}
|
|
138
|
+
isDatabase(expression) {
|
|
139
|
+
const type = this.checker.getNonNullableType(this.checker.getTypeAtLocation(expression));
|
|
140
|
+
const has = (name) => !!this.checker.getPropertyOfType(type, name);
|
|
141
|
+
if (has("query") && (has("insert") || has("select")))
|
|
142
|
+
return true;
|
|
143
|
+
// An untyped context (`ctx: any`) still reaches the database as `ctx.db`.
|
|
144
|
+
const untyped = (type.flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown)) !== 0;
|
|
145
|
+
const target = skipOuter(expression);
|
|
146
|
+
return untyped && ((ts.isPropertyAccessExpression(target) && target.name.text === "db") || (ts.isIdentifier(target) && target.text === "db"));
|
|
147
|
+
}
|
|
148
|
+
/** Literal string values of the value at `path` inside the expression's type. */
|
|
149
|
+
literalTables(expression, valuePath) {
|
|
150
|
+
let types = [this.checker.getTypeAtLocation(expression)];
|
|
151
|
+
for (const step of valuePath) {
|
|
152
|
+
const next = [];
|
|
153
|
+
for (const type of types) {
|
|
154
|
+
for (const member of type.isUnion() ? type.types : [type]) {
|
|
155
|
+
if (step === "[]") {
|
|
156
|
+
const element = this.checker.isTupleType(member)
|
|
157
|
+
? this.checker.getTypeArguments(member)
|
|
158
|
+
: [this.checker.getIndexTypeOfType(member, ts.IndexKind.Number)];
|
|
159
|
+
for (const value of element)
|
|
160
|
+
if (value)
|
|
161
|
+
next.push(value);
|
|
162
|
+
}
|
|
163
|
+
else if (step === "{}") {
|
|
164
|
+
for (const property of this.checker.getPropertiesOfType(member))
|
|
165
|
+
next.push(this.checker.getTypeOfSymbol(property));
|
|
166
|
+
const index = this.checker.getIndexTypeOfType(member, ts.IndexKind.String);
|
|
167
|
+
if (index)
|
|
168
|
+
next.push(index);
|
|
169
|
+
}
|
|
170
|
+
else if (/^\d+$/.test(step) && !this.checker.isTupleType(member)) {
|
|
171
|
+
const index = this.checker.getIndexTypeOfType(member, ts.IndexKind.Number);
|
|
172
|
+
if (!index)
|
|
173
|
+
return undefined;
|
|
174
|
+
next.push(index);
|
|
175
|
+
}
|
|
176
|
+
else {
|
|
177
|
+
const property = this.checker.getPropertyOfType(member, step);
|
|
178
|
+
if (!property)
|
|
179
|
+
return undefined;
|
|
180
|
+
next.push(this.checker.getTypeOfSymbol(property));
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
if (!next.length)
|
|
185
|
+
return undefined;
|
|
186
|
+
types = next;
|
|
187
|
+
}
|
|
188
|
+
const values = new Set();
|
|
189
|
+
for (const type of types) {
|
|
190
|
+
for (const member of type.isUnion() ? type.types : [type]) {
|
|
191
|
+
if (member.flags & (ts.TypeFlags.Undefined | ts.TypeFlags.Null))
|
|
192
|
+
continue;
|
|
193
|
+
if (!member.isStringLiteral())
|
|
194
|
+
return undefined;
|
|
195
|
+
values.add(member.value);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return values.size ? values : undefined;
|
|
199
|
+
}
|
|
200
|
+
unitOfParameter(parameter) {
|
|
201
|
+
return isFunctionUnit(parameter.parent) ? parameter.parent : undefined;
|
|
202
|
+
}
|
|
203
|
+
/** The array a callback parameter iterates, for `items.map((item) => ...)`. */
|
|
204
|
+
callbackSource(fn, index) {
|
|
205
|
+
const call = fn.parent;
|
|
206
|
+
if (!ts.isCallExpression(call) || call.arguments[0] !== fn || !ts.isPropertyAccessExpression(call.expression))
|
|
207
|
+
return undefined;
|
|
208
|
+
const method = call.expression.name.text;
|
|
209
|
+
if (!arrayCallbacks.has(method))
|
|
210
|
+
return undefined;
|
|
211
|
+
if (index !== (method === "reduce" ? 1 : 0))
|
|
212
|
+
return undefined;
|
|
213
|
+
return call.expression.expression;
|
|
214
|
+
}
|
|
215
|
+
evaluateParameter(parameter, valuePath, unit, depth) {
|
|
216
|
+
const fn = this.unitOfParameter(parameter);
|
|
217
|
+
if (!fn)
|
|
218
|
+
return unresolvedEvaluation();
|
|
219
|
+
const index = fn.parameters.indexOf(parameter);
|
|
220
|
+
if (fn === unit) {
|
|
221
|
+
const own = { tables: new Set(), params: [{ index, path: valuePath }], unresolved: false };
|
|
222
|
+
// A default value is used when a call site omits the argument.
|
|
223
|
+
return parameter.initializer ? merge(own, this.evaluate(parameter.initializer, valuePath, unit, depth + 1)) : own;
|
|
224
|
+
}
|
|
225
|
+
const source = this.callbackSource(fn, index);
|
|
226
|
+
if (source)
|
|
227
|
+
return this.evaluate(source, ["[]", ...valuePath], unit, depth + 1);
|
|
228
|
+
// A factory parameter captured by an inner function: its call sites decide.
|
|
229
|
+
for (let current = unit.parent; current; current = current.parent) {
|
|
230
|
+
if (current === fn)
|
|
231
|
+
return { tables: new Set(), params: [{ index, path: valuePath, owner: fn }], unresolved: false };
|
|
232
|
+
}
|
|
233
|
+
return unresolvedEvaluation();
|
|
234
|
+
}
|
|
235
|
+
evaluateDeclaration(declaration, valuePath, unit, depth) {
|
|
236
|
+
if (ts.isParameter(declaration))
|
|
237
|
+
return this.evaluateParameter(declaration, valuePath, unit, depth);
|
|
238
|
+
if (ts.isBindingElement(declaration)) {
|
|
239
|
+
// Walk out of the destructuring pattern, prefixing the property names.
|
|
240
|
+
const prefix = [];
|
|
241
|
+
let current = declaration;
|
|
242
|
+
while (ts.isBindingElement(current)) {
|
|
243
|
+
const pattern = current.parent;
|
|
244
|
+
if (ts.isArrayBindingPattern(pattern))
|
|
245
|
+
prefix.unshift(String(pattern.elements.indexOf(current)));
|
|
246
|
+
else {
|
|
247
|
+
const name = propertyName(current.propertyName ?? current.name);
|
|
248
|
+
if (!name)
|
|
249
|
+
return unresolvedEvaluation();
|
|
250
|
+
prefix.unshift(name);
|
|
251
|
+
}
|
|
252
|
+
current = pattern.parent;
|
|
253
|
+
}
|
|
254
|
+
if (ts.isParameter(current))
|
|
255
|
+
return this.evaluateParameter(current, [...prefix, ...valuePath], unit, depth);
|
|
256
|
+
if (ts.isVariableDeclaration(current))
|
|
257
|
+
return this.evaluateVariable(current, [...prefix, ...valuePath], unit, depth);
|
|
258
|
+
return unresolvedEvaluation();
|
|
259
|
+
}
|
|
260
|
+
if (ts.isVariableDeclaration(declaration))
|
|
261
|
+
return this.evaluateVariable(declaration, valuePath, unit, depth);
|
|
262
|
+
if (ts.isPropertyAssignment(declaration))
|
|
263
|
+
return this.evaluate(declaration.initializer, valuePath, unit, depth + 1);
|
|
264
|
+
if (ts.isShorthandPropertyAssignment(declaration)) {
|
|
265
|
+
const value = this.checker.getShorthandAssignmentValueSymbol(declaration);
|
|
266
|
+
const target = value?.valueDeclaration;
|
|
267
|
+
return target ? this.evaluateDeclaration(target, valuePath, unit, depth + 1) : unresolvedEvaluation();
|
|
268
|
+
}
|
|
269
|
+
if (ts.isEnumMember(declaration) && declaration.initializer)
|
|
270
|
+
return this.evaluate(declaration.initializer, valuePath, unit, depth + 1);
|
|
271
|
+
return unresolvedEvaluation();
|
|
272
|
+
}
|
|
273
|
+
evaluateVariable(declaration, valuePath, unit, depth) {
|
|
274
|
+
const list = declaration.parent;
|
|
275
|
+
const loop = list?.parent;
|
|
276
|
+
if (loop && ts.isForOfStatement(loop) && loop.initializer === list)
|
|
277
|
+
return this.evaluate(loop.expression, ["[]", ...valuePath], unit, depth + 1);
|
|
278
|
+
if (!declaration.initializer)
|
|
279
|
+
return unresolvedEvaluation();
|
|
280
|
+
return this.evaluate(declaration.initializer, valuePath, unit, depth + 1);
|
|
281
|
+
}
|
|
282
|
+
/** Tables the value at `valuePath` inside `expression` can name. */
|
|
283
|
+
evaluate(input, valuePath, unit, depth = 0) {
|
|
284
|
+
if (depth > 24)
|
|
285
|
+
return unresolvedEvaluation();
|
|
286
|
+
const expression = skipOuter(input);
|
|
287
|
+
const typed = this.literalTables(expression, valuePath);
|
|
288
|
+
if (typed)
|
|
289
|
+
return { tables: typed, params: [], unresolved: false };
|
|
290
|
+
if (!valuePath.length && ts.isStringLiteralLike(expression))
|
|
291
|
+
return { tables: new Set([expression.text]), params: [], unresolved: false };
|
|
292
|
+
if (ts.isConditionalExpression(expression)) {
|
|
293
|
+
return merge(this.evaluate(expression.whenTrue, valuePath, unit, depth + 1), this.evaluate(expression.whenFalse, valuePath, unit, depth + 1));
|
|
294
|
+
}
|
|
295
|
+
if (ts.isBinaryExpression(expression) && [ts.SyntaxKind.QuestionQuestionToken, ts.SyntaxKind.BarBarToken, ts.SyntaxKind.AmpersandAmpersandToken].includes(expression.operatorToken.kind)) {
|
|
296
|
+
return merge(this.evaluate(expression.left, valuePath, unit, depth + 1), this.evaluate(expression.right, valuePath, unit, depth + 1));
|
|
297
|
+
}
|
|
298
|
+
if (ts.isObjectLiteralExpression(expression) && valuePath.length) {
|
|
299
|
+
const [head, ...rest] = valuePath;
|
|
300
|
+
const parts = [];
|
|
301
|
+
for (const property of expression.properties) {
|
|
302
|
+
if (ts.isSpreadAssignment(property)) {
|
|
303
|
+
// A spread may or may not carry the property: follow it unless a
|
|
304
|
+
// later explicit property overrides it.
|
|
305
|
+
parts.push(this.evaluateOptional(property.expression, valuePath, unit, depth + 1));
|
|
306
|
+
}
|
|
307
|
+
else if (head === "{}" || propertyName(property.name) === head) {
|
|
308
|
+
if (head !== "{}")
|
|
309
|
+
parts.length = 0;
|
|
310
|
+
if (ts.isPropertyAssignment(property))
|
|
311
|
+
parts.push(this.evaluate(property.initializer, rest, unit, depth + 1));
|
|
312
|
+
else if (ts.isShorthandPropertyAssignment(property)) {
|
|
313
|
+
const value = this.checker.getShorthandAssignmentValueSymbol(property);
|
|
314
|
+
parts.push(value?.valueDeclaration ? this.evaluateDeclaration(value.valueDeclaration, rest, unit, depth + 1) : unresolvedEvaluation());
|
|
315
|
+
}
|
|
316
|
+
else if (head !== "{}")
|
|
317
|
+
parts.push(unresolvedEvaluation());
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
return merge(...parts);
|
|
321
|
+
}
|
|
322
|
+
if (ts.isArrayLiteralExpression(expression) && (valuePath[0] === "[]" || /^\d+$/.test(valuePath[0] ?? ""))) {
|
|
323
|
+
const rest = valuePath.slice(1);
|
|
324
|
+
const position = Number(valuePath[0]);
|
|
325
|
+
if (valuePath[0] !== "[]" && !expression.elements.some(ts.isSpreadElement)) {
|
|
326
|
+
const element = expression.elements[position];
|
|
327
|
+
return element ? this.evaluate(element, rest, unit, depth + 1) : empty();
|
|
328
|
+
}
|
|
329
|
+
return merge(...expression.elements.map((element) => ts.isSpreadElement(element)
|
|
330
|
+
? this.evaluate(element.expression, ["[]", ...rest], unit, depth + 1)
|
|
331
|
+
: this.evaluate(element, rest, unit, depth + 1)));
|
|
332
|
+
}
|
|
333
|
+
if (ts.isIdentifier(expression)) {
|
|
334
|
+
const symbol = this.symbol(expression);
|
|
335
|
+
const declaration = symbol?.valueDeclaration ?? symbol?.declarations?.[0];
|
|
336
|
+
return declaration ? this.evaluateDeclaration(declaration, valuePath, unit, depth) : unresolvedEvaluation();
|
|
337
|
+
}
|
|
338
|
+
if (ts.isPropertyAccessExpression(expression)) {
|
|
339
|
+
const symbol = this.symbol(expression.name);
|
|
340
|
+
const declaration = symbol?.valueDeclaration;
|
|
341
|
+
if (declaration && (ts.isPropertyAssignment(declaration) || ts.isShorthandPropertyAssignment(declaration)) && this.isProjectFile(declaration.getSourceFile())) {
|
|
342
|
+
const direct = this.evaluateDeclaration(declaration, valuePath, unit, depth + 1);
|
|
343
|
+
if (!direct.unresolved)
|
|
344
|
+
return direct;
|
|
345
|
+
}
|
|
346
|
+
return this.evaluate(expression.expression, [expression.name.text, ...valuePath], unit, depth + 1);
|
|
347
|
+
}
|
|
348
|
+
if (ts.isElementAccessExpression(expression)) {
|
|
349
|
+
const argument = skipOuter(expression.argumentExpression);
|
|
350
|
+
if (ts.isStringLiteralLike(argument) || ts.isNumericLiteral(argument))
|
|
351
|
+
return this.evaluate(expression.expression, [argument.text, ...valuePath], unit, depth + 1);
|
|
352
|
+
const receiver = this.checker.getTypeAtLocation(expression.expression);
|
|
353
|
+
const arrayLike = this.checker.isArrayLikeType(receiver);
|
|
354
|
+
return this.evaluate(expression.expression, [arrayLike ? "[]" : "{}", ...valuePath], unit, depth + 1);
|
|
355
|
+
}
|
|
356
|
+
if (ts.isCallExpression(expression) && ts.isIdentifier(expression.expression) && expression.expression.text === "String" && !valuePath.length && expression.arguments[0]) {
|
|
357
|
+
return this.evaluate(expression.arguments[0], valuePath, unit, depth + 1);
|
|
358
|
+
}
|
|
359
|
+
if (ts.isCallExpression(expression) && ts.isPropertyAccessExpression(expression.expression)
|
|
360
|
+
&& ts.isIdentifier(expression.expression.expression) && expression.expression.expression.text === "Object" && expression.arguments[0]) {
|
|
361
|
+
// Object.values(x) / Object.entries(x): the table sits in a property value.
|
|
362
|
+
const method = expression.expression.name.text;
|
|
363
|
+
const source = expression.arguments[0];
|
|
364
|
+
if (method === "values" && valuePath[0] === "[]")
|
|
365
|
+
return this.evaluate(source, ["{}", ...valuePath.slice(1)], unit, depth + 1);
|
|
366
|
+
if (method === "entries" && valuePath[0] === "[]" && valuePath[1] === "1")
|
|
367
|
+
return this.evaluate(source, ["{}", ...valuePath.slice(2)], unit, depth + 1);
|
|
368
|
+
}
|
|
369
|
+
if (ts.isCallExpression(expression) && ts.isPropertyAccessExpression(expression.expression)) {
|
|
370
|
+
// A string method that keeps a name: table.replaceAll('"', '""'), table.trim().
|
|
371
|
+
const method = expression.expression.name.text;
|
|
372
|
+
if (!valuePath.length && ["replaceAll", "replace", "trim", "toString", "valueOf"].includes(method)) {
|
|
373
|
+
return this.evaluate(expression.expression.expression, valuePath, unit, depth + 1);
|
|
374
|
+
}
|
|
375
|
+
// Array plumbing keeps the element shape: rows.filter(...), [...].concat(...).
|
|
376
|
+
if (valuePath[0] === "[]" && ["filter", "slice", "concat", "flat", "reverse", "sort", "toSorted", "toReversed"].includes(method)) {
|
|
377
|
+
return merge(this.evaluate(expression.expression.expression, valuePath, unit, depth + 1), ...(method === "concat" ? expression.arguments.map((argument) => this.evaluateOptional(argument, valuePath, unit, depth + 1)) : []));
|
|
378
|
+
}
|
|
379
|
+
if (valuePath[0] === "[]" && (method === "map" || method === "flatMap")) {
|
|
380
|
+
const callback = expression.arguments[0];
|
|
381
|
+
if (callback && (ts.isArrowFunction(callback) || ts.isFunctionExpression(callback))) {
|
|
382
|
+
return this.evaluateReturns(callback, method === "flatMap" ? valuePath : valuePath.slice(1), unit, depth + 1);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
if (ts.isCallExpression(expression)) {
|
|
387
|
+
// A project helper that builds the read: follow its return expressions.
|
|
388
|
+
for (const target of this.callTargets(expression)) {
|
|
389
|
+
if (this.inProgress.has(target))
|
|
390
|
+
continue;
|
|
391
|
+
this.inProgress.add(target);
|
|
392
|
+
try {
|
|
393
|
+
const returned = this.evaluateReturns(target, valuePath, target, depth + 1);
|
|
394
|
+
// The helper's parameters are this call's arguments.
|
|
395
|
+
return merge({ tables: returned.tables, params: [], unresolved: returned.unresolved }, ...returned.params.map((param) => {
|
|
396
|
+
const argument = expression.arguments[param.index];
|
|
397
|
+
return argument ? this.evaluate(argument, param.path, unit, depth + 1) : empty();
|
|
398
|
+
}));
|
|
399
|
+
}
|
|
400
|
+
finally {
|
|
401
|
+
this.inProgress.delete(target);
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
return unresolvedEvaluation();
|
|
406
|
+
}
|
|
407
|
+
/** Like evaluate, but a value without the property contributes nothing. */
|
|
408
|
+
evaluateOptional(expression, valuePath, unit, depth) {
|
|
409
|
+
const type = this.checker.getTypeAtLocation(skipOuter(expression));
|
|
410
|
+
if (valuePath.length && valuePath[0] !== "[]" && !this.checker.getPropertyOfType(type, valuePath[0])
|
|
411
|
+
&& !this.checker.getIndexInfosOfType(type).length)
|
|
412
|
+
return empty();
|
|
413
|
+
return this.evaluate(expression, valuePath, unit, depth);
|
|
414
|
+
}
|
|
415
|
+
evaluateReturns(fn, valuePath, unit, depth) {
|
|
416
|
+
if (!fn.body)
|
|
417
|
+
return unresolvedEvaluation();
|
|
418
|
+
if (!ts.isBlock(fn.body))
|
|
419
|
+
return this.evaluate(fn.body, valuePath, unit, depth);
|
|
420
|
+
const parts = [];
|
|
421
|
+
const visit = (node) => {
|
|
422
|
+
if (isFunctionUnit(node))
|
|
423
|
+
return;
|
|
424
|
+
if (ts.isReturnStatement(node) && node.expression)
|
|
425
|
+
parts.push(this.evaluate(node.expression, valuePath, unit, depth));
|
|
426
|
+
ts.forEachChild(node, visit);
|
|
427
|
+
};
|
|
428
|
+
ts.forEachChild(fn.body, visit);
|
|
429
|
+
return parts.length ? merge(...parts) : unresolvedEvaluation();
|
|
430
|
+
}
|
|
431
|
+
/** Project functions a call can invoke. */
|
|
432
|
+
callTargets(call) {
|
|
433
|
+
const callee = skipOuter(call.expression);
|
|
434
|
+
const target = ts.isPropertyAccessExpression(callee) ? callee.name : callee;
|
|
435
|
+
return this.unitsOfSymbol(this.symbol(target)).filter(isFunctionUnit);
|
|
436
|
+
}
|
|
437
|
+
unitsOfSymbol(symbol) {
|
|
438
|
+
const units = [];
|
|
439
|
+
for (const declaration of symbol?.declarations ?? []) {
|
|
440
|
+
if (!this.isProjectFile(declaration.getSourceFile()))
|
|
441
|
+
continue;
|
|
442
|
+
const unit = this.unitOfDeclaration(declaration);
|
|
443
|
+
if (unit)
|
|
444
|
+
units.push(unit);
|
|
445
|
+
}
|
|
446
|
+
return units;
|
|
447
|
+
}
|
|
448
|
+
unitOfDeclaration(declaration) {
|
|
449
|
+
if (isFunctionUnit(declaration))
|
|
450
|
+
return declaration.body ? declaration : undefined;
|
|
451
|
+
if ((ts.isVariableDeclaration(declaration) || ts.isPropertyAssignment(declaration) || ts.isPropertyDeclaration(declaration)) && declaration.initializer) {
|
|
452
|
+
const initializer = skipOuter(declaration.initializer);
|
|
453
|
+
if (ts.isVariableDeclaration(declaration) && ts.isCallExpression(initializer) && ts.isIdentifier(initializer.expression)) {
|
|
454
|
+
// Queries, Actions and visibility plans never run inside a Reducer body.
|
|
455
|
+
const definition = this.symbol(initializer.expression);
|
|
456
|
+
const file = definition?.declarations?.[0]?.getSourceFile().fileName ?? "";
|
|
457
|
+
if (definition && nonExecutedDefinitions.has(definition.getName()) && this.isSdkFile(file))
|
|
458
|
+
return undefined;
|
|
459
|
+
}
|
|
460
|
+
if (isFunctionUnit(initializer))
|
|
461
|
+
return initializer;
|
|
462
|
+
// A local value inside a function is already part of that function's
|
|
463
|
+
// unit; only module-level values are units of their own.
|
|
464
|
+
for (let current = declaration.parent; current; current = current.parent) {
|
|
465
|
+
if (isFunctionUnit(current) || ts.isClassStaticBlockDeclaration(current))
|
|
466
|
+
return undefined;
|
|
467
|
+
}
|
|
468
|
+
return initializer;
|
|
469
|
+
}
|
|
470
|
+
if (ts.isShorthandPropertyAssignment(declaration)) {
|
|
471
|
+
const value = this.checker.getShorthandAssignmentValueSymbol(declaration)?.valueDeclaration;
|
|
472
|
+
return value ? this.unitOfDeclaration(value) : undefined;
|
|
473
|
+
}
|
|
474
|
+
return undefined;
|
|
475
|
+
}
|
|
476
|
+
addAccess(summary, evaluation, operation, reads, node, expression) {
|
|
477
|
+
const site = this.site(node);
|
|
478
|
+
for (const table of evaluation.tables)
|
|
479
|
+
summary.accesses.push({ table, operation, reads, site });
|
|
480
|
+
for (const { owner, ...param } of evaluation.params) {
|
|
481
|
+
if (owner)
|
|
482
|
+
summary.closureParams.push({ ...param, owner, operation, reads, site });
|
|
483
|
+
else
|
|
484
|
+
summary.params.push({ ...param, operation, reads, site });
|
|
485
|
+
}
|
|
486
|
+
if (evaluation.unresolved)
|
|
487
|
+
summary.unresolved.push({ operation, site, expression: expression.getText().replace(/\s+/g, " ").slice(0, 160) });
|
|
488
|
+
}
|
|
489
|
+
/** Whether an inserted row carries an explicit primary key, which makes the local insert read. */
|
|
490
|
+
insertCarriesKey(row, elementPath = []) {
|
|
491
|
+
if (!row)
|
|
492
|
+
return true;
|
|
493
|
+
const expression = skipOuter(row);
|
|
494
|
+
if (ts.isArrayLiteralExpression(expression) && elementPath[0] === "[]") {
|
|
495
|
+
return expression.elements.some((element) => this.insertCarriesKey(ts.isSpreadElement(element) ? element.expression : element, ts.isSpreadElement(element) ? elementPath : elementPath.slice(1)));
|
|
496
|
+
}
|
|
497
|
+
if (ts.isObjectLiteralExpression(expression) && !elementPath.length) {
|
|
498
|
+
for (const property of expression.properties) {
|
|
499
|
+
if (ts.isSpreadAssignment(property)) {
|
|
500
|
+
if (this.insertCarriesKey(property.expression))
|
|
501
|
+
return true;
|
|
502
|
+
}
|
|
503
|
+
else if (propertyName(property.name) === "_id") {
|
|
504
|
+
const value = ts.isPropertyAssignment(property) ? skipOuter(property.initializer) : undefined;
|
|
505
|
+
// `_id: undefined` lets the SDK generate the key.
|
|
506
|
+
if (!(value && ts.isIdentifier(value) && value.text === "undefined"))
|
|
507
|
+
return true;
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
return false;
|
|
511
|
+
}
|
|
512
|
+
let type = this.checker.getTypeAtLocation(expression);
|
|
513
|
+
for (const step of elementPath) {
|
|
514
|
+
const element = step === "[]" ? this.checker.getIndexTypeOfType(type, ts.IndexKind.Number) : undefined;
|
|
515
|
+
if (!element)
|
|
516
|
+
return true;
|
|
517
|
+
type = element;
|
|
518
|
+
}
|
|
519
|
+
for (const member of type.isUnion() ? type.types : [type]) {
|
|
520
|
+
const property = this.checker.getPropertyOfType(member, "_id");
|
|
521
|
+
if (property) {
|
|
522
|
+
const valueType = this.checker.getTypeOfSymbol(property);
|
|
523
|
+
if (!(valueType.flags & ts.TypeFlags.Undefined))
|
|
524
|
+
return true;
|
|
525
|
+
continue;
|
|
526
|
+
}
|
|
527
|
+
if (this.checker.getIndexInfosOfType(member).length)
|
|
528
|
+
return true;
|
|
529
|
+
}
|
|
530
|
+
return false;
|
|
531
|
+
}
|
|
532
|
+
inspectCall(call, unit, summary) {
|
|
533
|
+
const callee = skipOuter(call.expression);
|
|
534
|
+
const helper = this.sdkHelper(callee);
|
|
535
|
+
const spec = helper ? readHelpers[helper] : undefined;
|
|
536
|
+
if (spec) {
|
|
537
|
+
const argument = call.arguments[spec.argument];
|
|
538
|
+
if (argument)
|
|
539
|
+
this.addAccess(summary, this.evaluate(argument, spec.path, unit), spec.operation, true, call, argument);
|
|
540
|
+
return true;
|
|
541
|
+
}
|
|
542
|
+
if (helper === "insertDataRows") {
|
|
543
|
+
const table = call.arguments[1];
|
|
544
|
+
if (table)
|
|
545
|
+
this.addAccess(summary, this.evaluate(table, [], unit), "insert", this.insertCarriesKey(call.arguments[2], ["[]"]), call, table);
|
|
546
|
+
return true;
|
|
547
|
+
}
|
|
548
|
+
if (helper === "applyDataWrites") {
|
|
549
|
+
const operations = call.arguments[1];
|
|
550
|
+
if (!operations)
|
|
551
|
+
return true;
|
|
552
|
+
const list = skipOuter(operations);
|
|
553
|
+
if (!ts.isArrayLiteralExpression(list)) {
|
|
554
|
+
this.addAccess(summary, this.evaluate(operations, ["[]", "table"], unit), "write", true, call, operations);
|
|
555
|
+
return true;
|
|
556
|
+
}
|
|
557
|
+
for (const element of list.elements) {
|
|
558
|
+
if (ts.isSpreadElement(element)) {
|
|
559
|
+
this.addAccess(summary, this.evaluate(element.expression, ["[]", "table"], unit), "write", true, call, element);
|
|
560
|
+
continue;
|
|
561
|
+
}
|
|
562
|
+
const operation = skipOuter(element);
|
|
563
|
+
const kind = ts.isObjectLiteralExpression(operation) ? this.literalTables(operation, ["kind"]) : undefined;
|
|
564
|
+
const isInsert = kind?.size === 1 && kind.has("insert");
|
|
565
|
+
const rows = ts.isObjectLiteralExpression(operation)
|
|
566
|
+
? operation.properties.find((property) => ts.isPropertyAssignment(property) && propertyName(property.name) === "rows")
|
|
567
|
+
: undefined;
|
|
568
|
+
const reads = !isInsert || this.insertCarriesKey(rows?.initializer, ["[]"]);
|
|
569
|
+
this.addAccess(summary, this.evaluate(element, ["table"], unit), isInsert ? "insert" : "write", reads, call, element);
|
|
570
|
+
}
|
|
571
|
+
return true;
|
|
572
|
+
}
|
|
573
|
+
if (helper)
|
|
574
|
+
return true;
|
|
575
|
+
if (ts.isPropertyAccessExpression(callee) && databaseMethods.has(callee.name.text) && this.isDatabase(callee.expression)) {
|
|
576
|
+
const method = callee.name.text;
|
|
577
|
+
if (method === "query") {
|
|
578
|
+
summary.rawQueries.push(this.site(call));
|
|
579
|
+
return true;
|
|
580
|
+
}
|
|
581
|
+
if (method === "select") {
|
|
582
|
+
const read = call.arguments[0];
|
|
583
|
+
if (read)
|
|
584
|
+
this.addAccess(summary, this.evaluate(read, ["table"], unit), "select", true, call, read);
|
|
585
|
+
return true;
|
|
586
|
+
}
|
|
587
|
+
const table = call.arguments[0];
|
|
588
|
+
if (!table)
|
|
589
|
+
return true;
|
|
590
|
+
const reads = method === "insert" ? this.insertCarriesKey(call.arguments[1]) : true;
|
|
591
|
+
this.addAccess(summary, this.evaluate(table, [], unit), method, reads, call, table);
|
|
592
|
+
return true;
|
|
593
|
+
}
|
|
594
|
+
return false;
|
|
595
|
+
}
|
|
596
|
+
summary(unit) {
|
|
597
|
+
const cached = this.summaries.get(unit);
|
|
598
|
+
if (cached)
|
|
599
|
+
return cached;
|
|
600
|
+
const summary = { accesses: [], params: [], closureParams: [], unresolved: [], rawQueries: [], references: new Set(), calls: new Set() };
|
|
601
|
+
this.summaries.set(unit, summary);
|
|
602
|
+
this.inProgress.add(unit);
|
|
603
|
+
const body = isFunctionUnit(unit) ? unit.body : unit;
|
|
604
|
+
const visit = (node) => {
|
|
605
|
+
if (ts.isTypeNode(node) || ts.isInterfaceDeclaration(node) || ts.isTypeAliasDeclaration(node) || ts.isImportDeclaration(node))
|
|
606
|
+
return;
|
|
607
|
+
if (node !== unit && isFunctionUnit(node)) {
|
|
608
|
+
const parent = node.parent;
|
|
609
|
+
const named = ts.isFunctionDeclaration(node) || ts.isMethodDeclaration(node)
|
|
610
|
+
|| ((ts.isVariableDeclaration(parent) || ts.isPropertyAssignment(parent) || ts.isPropertyDeclaration(parent)) && parent.initializer === node);
|
|
611
|
+
if (named) {
|
|
612
|
+
// A named inner function is its own unit, reached through its
|
|
613
|
+
// references. Tables it takes from this unit's parameters are this
|
|
614
|
+
// unit's parameter sinks.
|
|
615
|
+
summary.references.add(node);
|
|
616
|
+
const inner = this.inProgress.has(node) ? this.summaries.get(node) : this.summary(node);
|
|
617
|
+
for (const { owner, ...sink } of inner?.closureParams ?? []) {
|
|
618
|
+
if (owner === unit)
|
|
619
|
+
summary.params.push(sink);
|
|
620
|
+
else
|
|
621
|
+
summary.closureParams.push({ ...sink, owner });
|
|
622
|
+
}
|
|
623
|
+
return;
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
if (ts.isCallExpression(node) && !this.inspectCall(node, unit, summary)) {
|
|
627
|
+
for (const target of this.callTargets(node)) {
|
|
628
|
+
summary.calls.add(target);
|
|
629
|
+
const callee = this.inProgress.has(target) ? this.summaries.get(target) : this.summary(target);
|
|
630
|
+
for (const param of callee?.params ?? []) {
|
|
631
|
+
const argument = node.arguments[param.index];
|
|
632
|
+
if (!argument)
|
|
633
|
+
continue;
|
|
634
|
+
this.addAccess(summary, this.evaluate(argument, param.path, unit), param.operation, param.reads, node, argument);
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
if (ts.isIdentifier(node) && !this.isDeclarationName(node)) {
|
|
639
|
+
for (const target of this.unitsOfSymbol(this.symbol(node))) {
|
|
640
|
+
if (target === unit)
|
|
641
|
+
continue;
|
|
642
|
+
summary.references.add(target);
|
|
643
|
+
// A function passed as a value has callers the analysis cannot see.
|
|
644
|
+
if (isFunctionUnit(target) && !this.isCallee(node)) {
|
|
645
|
+
const passed = this.inProgress.has(target) ? this.summaries.get(target) : this.summary(target);
|
|
646
|
+
for (const param of passed?.params ?? []) {
|
|
647
|
+
summary.unresolved.push({ operation: param.operation, site: this.site(node), expression: `${node.getText()} passed as a value (table from parameter ${param.index})` });
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
ts.forEachChild(node, visit);
|
|
653
|
+
};
|
|
654
|
+
if (body)
|
|
655
|
+
visit(body);
|
|
656
|
+
this.inProgress.delete(unit);
|
|
657
|
+
return summary;
|
|
658
|
+
}
|
|
659
|
+
isDeclarationName(node) {
|
|
660
|
+
const parent = node.parent;
|
|
661
|
+
return (ts.isVariableDeclaration(parent) || ts.isFunctionDeclaration(parent) || ts.isParameter(parent)
|
|
662
|
+
|| ts.isBindingElement(parent) || ts.isPropertyAssignment(parent) || ts.isMethodDeclaration(parent)
|
|
663
|
+
|| ts.isPropertyDeclaration(parent) || ts.isClassDeclaration(parent) || ts.isFunctionExpression(parent))
|
|
664
|
+
&& parent.name === node;
|
|
665
|
+
}
|
|
666
|
+
isCallee(node) {
|
|
667
|
+
let current = node;
|
|
668
|
+
while (ts.isPropertyAccessExpression(current.parent) && current.parent.name === current)
|
|
669
|
+
current = current.parent;
|
|
670
|
+
return ts.isCallExpression(current.parent) && current.parent.expression === current;
|
|
671
|
+
}
|
|
672
|
+
/** Every table access reachable from one exported Reducer definition. */
|
|
673
|
+
reducer(entry) {
|
|
674
|
+
const file = this.program.getSourceFile(resolve(this.root, entry.file));
|
|
675
|
+
if (!file)
|
|
676
|
+
throw new Error(`Reducer source ${entry.file} is not part of the analyzed program`);
|
|
677
|
+
const moduleSymbol = this.checker.getSymbolAtLocation(file);
|
|
678
|
+
const exported = moduleSymbol && this.checker.getExportsOfModule(moduleSymbol).find((symbol) => symbol.getName() === entry.exportName);
|
|
679
|
+
const resolved = exported && exported.flags & ts.SymbolFlags.Alias ? this.checker.getAliasedSymbol(exported) : exported;
|
|
680
|
+
const declaration = resolved?.valueDeclaration;
|
|
681
|
+
let root;
|
|
682
|
+
if (declaration && ts.isVariableDeclaration(declaration) && declaration.initializer)
|
|
683
|
+
root = skipOuter(declaration.initializer);
|
|
684
|
+
else if (declaration && isFunctionUnit(declaration) && declaration.body)
|
|
685
|
+
root = declaration;
|
|
686
|
+
if (!root)
|
|
687
|
+
throw new Error(`Reducer ${entry.path} is not an exported definition in ${entry.file}`);
|
|
688
|
+
const result = { path: entry.path, accesses: [], unresolved: [], rawQueries: [] };
|
|
689
|
+
const seen = new Set();
|
|
690
|
+
const called = new Set();
|
|
691
|
+
const queue = [root];
|
|
692
|
+
while (queue.length) {
|
|
693
|
+
const unit = queue.pop();
|
|
694
|
+
if (seen.has(unit))
|
|
695
|
+
continue;
|
|
696
|
+
seen.add(unit);
|
|
697
|
+
const summary = this.summary(unit);
|
|
698
|
+
result.accesses.push(...summary.accesses);
|
|
699
|
+
result.unresolved.push(...summary.unresolved);
|
|
700
|
+
result.rawQueries.push(...summary.rawQueries);
|
|
701
|
+
summary.calls.forEach((target) => called.add(target));
|
|
702
|
+
summary.references.forEach((next) => queue.push(next));
|
|
703
|
+
}
|
|
704
|
+
// A function that takes a table from a parameter is resolved at its call
|
|
705
|
+
// sites. One that nothing reachable calls directly (the Reducer handler,
|
|
706
|
+
// whose arguments come from the client, or a function reached through
|
|
707
|
+
// dynamic dispatch) has callers the analysis cannot see.
|
|
708
|
+
for (const unit of seen) {
|
|
709
|
+
if (!isFunctionUnit(unit) || called.has(unit))
|
|
710
|
+
continue;
|
|
711
|
+
for (const param of this.summary(unit).params) {
|
|
712
|
+
result.unresolved.push({ operation: param.operation, site: param.site, expression: `table from parameter ${param.index} of a function with no static call site` });
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
return result;
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
/**
|
|
719
|
+
* Whether an access reads the table locally. The analysis checks inserts for
|
|
720
|
+
* an explicit `_id`; a table keyed by another column is treated as read by any
|
|
721
|
+
* insert, since the row may carry that key.
|
|
722
|
+
*/
|
|
723
|
+
export function readsLocally(access, tableKeys) {
|
|
724
|
+
return access.reads || (access.operation === "insert" && (tableKeys.get(access.table) ?? "_id") !== "_id");
|
|
725
|
+
}
|
|
726
|
+
/** Tables a Reducer needs in the Local Replica to run locally, sorted. */
|
|
727
|
+
export function localDependencyTables(analysis, tableKeys) {
|
|
728
|
+
return [...new Set(analysis.accesses.filter((access) => readsLocally(access, tableKeys)).map((access) => access.table))].sort();
|
|
729
|
+
}
|
|
730
|
+
/** Accesses a local run cannot complete. `tableKeys` maps every local-schema table to its key. */
|
|
731
|
+
export function localDependencyViolations(analyses, localTables, tableKeys) {
|
|
732
|
+
const violations = [];
|
|
733
|
+
for (const reducer of analyses) {
|
|
734
|
+
const seen = new Set();
|
|
735
|
+
const add = (key, violation) => {
|
|
736
|
+
if (seen.has(key))
|
|
737
|
+
return;
|
|
738
|
+
seen.add(key);
|
|
739
|
+
violations.push({ reducer: reducer.path, ...violation });
|
|
740
|
+
};
|
|
741
|
+
for (const access of reducer.accesses) {
|
|
742
|
+
if (!tableKeys.has(access.table)) {
|
|
743
|
+
add(`unknown\0${access.table}\0${access.site}`, { table: access.table, operation: access.operation, site: access.site, reason: "not a table in the tenant schema" });
|
|
744
|
+
}
|
|
745
|
+
else if (readsLocally(access, tableKeys) && !localTables.has(access.table)) {
|
|
746
|
+
add(`${access.table}\0${access.site}`, { table: access.table, operation: access.operation, site: access.site, reason: "no generated local collection" });
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
for (const unresolved of reducer.unresolved) {
|
|
750
|
+
add(`unresolved\0${unresolved.site}\0${unresolved.expression}`, { table: "?", operation: unresolved.operation, site: unresolved.site, reason: `table cannot be determined statically: ${unresolved.expression}` });
|
|
751
|
+
}
|
|
752
|
+
for (const site of reducer.rawQueries) {
|
|
753
|
+
add(`query\0${site}`, { table: "*", operation: "query", site, reason: "raw SQL cannot run locally" });
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
return violations;
|
|
757
|
+
}
|
|
758
|
+
/** A readable report grouped by table, for CLI errors and warnings. */
|
|
759
|
+
export function formatLocalDependencyViolations(violations) {
|
|
760
|
+
const byTable = new Map();
|
|
761
|
+
for (const violation of violations) {
|
|
762
|
+
const key = `${violation.table} (${violation.reason})`;
|
|
763
|
+
byTable.set(key, [...(byTable.get(key) ?? []), violation]);
|
|
764
|
+
}
|
|
765
|
+
const lines = [
|
|
766
|
+
`${violations.length} data access(es) in locally executing Reducers cannot run on the device.`,
|
|
767
|
+
"A Reducer the client runs locally touches data with no generated __local collection. The local run then stops",
|
|
768
|
+
"and the intent is queued with no local result: the change waits for the server and is invisible offline.",
|
|
769
|
+
"Give the table a visibility plan and a Replica Collection, keep the access out of the Reducer body, or make",
|
|
770
|
+
"the Reducer online-only (offline: { mode: \"onlineOnly\", reason }) so it is not executed locally.",
|
|
771
|
+
"",
|
|
772
|
+
];
|
|
773
|
+
for (const [table, entries] of [...byTable].sort(([left], [right]) => left.localeCompare(right))) {
|
|
774
|
+
const reducers = [...new Set(entries.map((entry) => entry.reducer))];
|
|
775
|
+
lines.push(`${table}: ${reducers.length} Reducer(s)`);
|
|
776
|
+
for (const site of [...new Set(entries.map((entry) => `${entry.site} ${entry.operation}`))].sort())
|
|
777
|
+
lines.push(` at ${site}`);
|
|
778
|
+
lines.push(` e.g. ${reducers.slice(0, 8).join(", ")}${reducers.length > 8 ? ", ..." : ""}`);
|
|
779
|
+
}
|
|
780
|
+
return lines.join("\n");
|
|
781
|
+
}
|
|
782
|
+
/** Compiler options for the backend: its own tsconfig when present, otherwise bundler defaults. */
|
|
783
|
+
function compilerOptions(root, backendDir, declared) {
|
|
784
|
+
const defaults = {
|
|
785
|
+
target: ts.ScriptTarget.ES2022,
|
|
786
|
+
module: ts.ModuleKind.ESNext,
|
|
787
|
+
moduleResolution: ts.ModuleResolutionKind.Bundler,
|
|
788
|
+
strict: true,
|
|
789
|
+
skipLibCheck: true,
|
|
790
|
+
allowJs: true,
|
|
791
|
+
allowImportingTsExtensions: true,
|
|
792
|
+
jsx: ts.JsxEmit.Preserve,
|
|
793
|
+
};
|
|
794
|
+
if (declared && !existsSync(resolve(root, declared)))
|
|
795
|
+
throw new Error(`gonvex.json module.tsconfig ${JSON.stringify(declared)} does not exist`);
|
|
796
|
+
const candidates = declared ? [resolve(root, declared)] : [join(backendDir, "tsconfig.json"), join(root, "tsconfig.json")];
|
|
797
|
+
for (const candidate of candidates) {
|
|
798
|
+
if (!existsSync(candidate))
|
|
799
|
+
continue;
|
|
800
|
+
const config = ts.readConfigFile(candidate, ts.sys.readFile);
|
|
801
|
+
if (config.error) {
|
|
802
|
+
if (declared)
|
|
803
|
+
throw new Error(`gonvex.json module.tsconfig: ${ts.flattenDiagnosticMessageText(config.error.messageText, "\n")}`);
|
|
804
|
+
continue;
|
|
805
|
+
}
|
|
806
|
+
const parsed = ts.parseJsonConfigFileContent(config.config, ts.sys, dirname(candidate));
|
|
807
|
+
// A solution-style tsconfig (`files: []` plus references) has no options.
|
|
808
|
+
if (!declared && !parsed.options.moduleResolution && !parsed.options.paths && !parsed.options.baseUrl)
|
|
809
|
+
continue;
|
|
810
|
+
return { ...defaults, ...parsed.options, noEmit: true, composite: false, incremental: false, declaration: false };
|
|
811
|
+
}
|
|
812
|
+
return { ...defaults, noEmit: true };
|
|
813
|
+
}
|
|
814
|
+
/** Directories that hold @gonvex/module-sdk as seen by the project, symlinks resolved. */
|
|
815
|
+
function sdkRoots(backendDir, options) {
|
|
816
|
+
const roots = new Set();
|
|
817
|
+
const resolution = ts.resolveModuleName("@gonvex/module-sdk", join(backendDir, "index.ts"), options, ts.sys);
|
|
818
|
+
let directory = resolution.resolvedModule ? dirname(resolution.resolvedModule.resolvedFileName) : undefined;
|
|
819
|
+
while (directory && directory !== dirname(directory)) {
|
|
820
|
+
const manifest = join(directory, "package.json");
|
|
821
|
+
if (existsSync(manifest)) {
|
|
822
|
+
try {
|
|
823
|
+
if (JSON.parse(readFileSync(manifest, "utf8")).name === "@gonvex/module-sdk") {
|
|
824
|
+
roots.add(`${slash(directory)}/`);
|
|
825
|
+
roots.add(`${slash(ts.sys.realpath?.(directory) ?? directory)}/`);
|
|
826
|
+
break;
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
catch { /* Keep walking. */ }
|
|
830
|
+
}
|
|
831
|
+
directory = dirname(directory);
|
|
832
|
+
}
|
|
833
|
+
return [...roots];
|
|
834
|
+
}
|
|
835
|
+
/**
|
|
836
|
+
* Analyze the table dependencies of locally executing Reducers. Loads the
|
|
837
|
+
* TypeScript compiler bundled with the CLI on first use.
|
|
838
|
+
*/
|
|
839
|
+
export async function analyzeLocalReducerDependencies(input) {
|
|
840
|
+
if (!input.reducers.length)
|
|
841
|
+
return [];
|
|
842
|
+
ts ??= createRequire(import.meta.url)("typescript");
|
|
843
|
+
const options = compilerOptions(input.root, input.backendDir, input.tsconfig);
|
|
844
|
+
const program = ts.createProgram({ rootNames: [...input.files], options });
|
|
845
|
+
const analyzer = new LocalReducerDependencyAnalyzer(program, input.root, sdkRoots(input.backendDir, options));
|
|
846
|
+
return [...input.reducers]
|
|
847
|
+
.sort((left, right) => left.path.localeCompare(right.path))
|
|
848
|
+
.map((reducer) => analyzer.reducer(reducer));
|
|
849
|
+
}
|
|
850
|
+
//# sourceMappingURL=local-dependencies.js.map
|