@jskit-ai/jskit-catalog 0.2.36 → 0.2.38
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/catalog/packages.json +269 -268
- package/package.json +1 -1
- package/scripts/data-access.mjs +224 -50
- package/scripts/jskit.mjs +11 -1
- package/scripts/project-packages.mjs +31 -0
- package/scripts/test-databases.mjs +76 -2
package/package.json
CHANGED
package/scripts/data-access.mjs
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { readdir, readFile, realpath } from "node:fs/promises";
|
|
2
|
+
import { createRequire } from "node:module";
|
|
2
3
|
import path from "node:path";
|
|
4
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
3
5
|
import { toPosixPath } from "./files.mjs";
|
|
4
6
|
import { blankJsComments, lineIndex } from "./material3-source.mjs";
|
|
5
7
|
|
|
@@ -10,9 +12,11 @@ import { blankJsComments, lineIndex } from "./material3-source.mjs";
|
|
|
10
12
|
// without a literal reason fails; and every file the infrastructure exception lifts past the data-access lint rules is
|
|
11
13
|
// listed for review. In the app's own code, which no lint directive can hide from it, a feature's system scope and an
|
|
12
14
|
// infrastructure exception fail until the checked-in BASELINE_FILE lists them, so each new one is reviewed; a module
|
|
13
|
-
// that requires
|
|
14
|
-
// that
|
|
15
|
-
//
|
|
15
|
+
// that requires a capability handing out power past the owners (CROSS_OWNER_CAPABILITIES) fails until it is reviewed
|
|
16
|
+
// too; an eslint-disable or inline eslint config that turns off a data-access rule, or every rule, fails in any file
|
|
17
|
+
// but the client's; and so does an app file the app's own ESLint config lints without every data-access rule as an
|
|
18
|
+
// error, or ignores though it is server code. As information, it lists the fields of each app table resource that its CRUD contracts never name,
|
|
19
|
+
// which no API shows or accepts.
|
|
16
20
|
|
|
17
21
|
const GUIDE = "https://mobily-enterprises.github.io/jskit-ai/guide/framework/data-access";
|
|
18
22
|
const SECTIONS = Object.freeze({
|
|
@@ -22,6 +26,7 @@ const SECTIONS = Object.freeze({
|
|
|
22
26
|
"data/baseline": "hard-rules",
|
|
23
27
|
"data/runtime-capability": "the-resource-is-the-only-door",
|
|
24
28
|
"data/lint-disabled": "hard-rules",
|
|
29
|
+
"data/lint-config": "hard-rules",
|
|
25
30
|
"data/unnamed-fields": "layer-2-the-crud-surface"
|
|
26
31
|
});
|
|
27
32
|
// What each rule asks, said once above its findings in the report.
|
|
@@ -30,33 +35,69 @@ const RULE_EXPLANATIONS = Object.freeze({
|
|
|
30
35
|
"data/system-scope": "every system scope and systemContext() call gives its reason as a string literal.",
|
|
31
36
|
"data/baseline": "each infrastructure exception and feature system scope of the app is reviewed, then listed in the checked-in " +
|
|
32
37
|
"jskit-data-access.json; review each one below, then add the entry it shows.",
|
|
33
|
-
"data/runtime-capability": "a module that requires
|
|
34
|
-
"own them. A feature owns its tables with owns and gets their handles; only a module whose job is data
|
|
35
|
-
"declared with the infrastructure exception, takes
|
|
36
|
-
|
|
37
|
-
|
|
38
|
+
"data/runtime-capability": "a module that requires a capability handing out power past the owners reaches rows past the " +
|
|
39
|
+
"features that own them. A feature owns its tables with owns and gets their handles; only a module whose job is data " +
|
|
40
|
+
"access itself, declared with the infrastructure exception, takes runtime.database or runtime.resources, and a module " +
|
|
41
|
+
"takes any other one once the baseline's systemScopes lists it with its reason.",
|
|
42
|
+
"data/lint-disabled": "neither eslint-disable nor an inline eslint config comment can turn off a data-access rule, and a " +
|
|
43
|
+
"disable that names no rule turns them all off. Change the code, or name the rules the directive turns off.",
|
|
44
|
+
"data/lint-config": "the app's ESLint config makes every data-access rule an error in each server file, and ignores no " +
|
|
45
|
+
"server code; a config that turns one off, lowers it or ignores the file hides that code from it. Put the rule back, or, " +
|
|
46
|
+
"once the module's whole job is data access, list { \"file\", \"rule\" } in the baseline's \"infrastructure\"."
|
|
38
47
|
});
|
|
39
48
|
// The reviewed list of the app's infrastructure exceptions and feature system scopes, at the app's root.
|
|
40
49
|
const BASELINE_FILE = "jskit-data-access.json";
|
|
41
50
|
const DATA_ACCESS_RULES = Object.freeze(["no-direct-knex", "no-transaction-reader", "sql-literal", "no-hand-scope", "no-hand-context", "knex-reason"]);
|
|
42
51
|
// An eslint-disable directive and the rules it names, before any "--" description.
|
|
43
52
|
const LINT_DISABLE = /\/[/*]\s*eslint-disable(?:-next-line|-line)?(?![\w-])([^\n]*)/gu;
|
|
44
|
-
//
|
|
45
|
-
const
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
|
|
53
|
+
// An inline config comment's text, `eslint jskit/no-direct-knex: "off"`, and the rules it sets.
|
|
54
|
+
const LINT_CONFIG = /^\s*eslint\s([\s\S]*)$/u;
|
|
55
|
+
// The capabilities that hand a module power past the features owning the rows, with what each gives: one list. Requiring
|
|
56
|
+
// runtime.database or runtime.resources is for a module declared infrastructure only; requiring any other one passes
|
|
57
|
+
// once the baseline's systemScopes lists it, with its reason: { "file", "capability", "systemScope": "<reason>" }.
|
|
58
|
+
const CROSS_OWNER_CAPABILITIES = Object.freeze({
|
|
59
|
+
"runtime.database": { gives: "the database, past every owner", infrastructureOnly: true },
|
|
60
|
+
"runtime.resources": { gives: "every table resource's handle", infrastructureOnly: true },
|
|
61
|
+
"realtime.audience-scope": { gives: "contexts for any workspace and user (forWorkspace, forUser, forWorkspaceUser)" },
|
|
62
|
+
"auth.local.accounts": { gives: "every local sign-in account, read and written in local auth's system context" },
|
|
63
|
+
"auth.local.sessions": { gives: "every sign-in session, read and written in local auth's system context" },
|
|
64
|
+
"auth.local.recovery": { gives: "every recovery token, read and written in local auth's system context" },
|
|
65
|
+
"connectors.store": { gives: "every owner's connections, with their credentials decrypted" },
|
|
66
|
+
"assistant.repositories": { gives: "every actor's and workspace's assistant conversations, messages and settings" },
|
|
67
|
+
"payments.store": { gives: "every merchant's payment accounts, customers and catalogues" }
|
|
68
|
+
});
|
|
69
|
+
const escapeRegExp = (text) => text.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
|
|
70
|
+
// A capability id written as a string, compared as the kernel resolves it: trimmed and in any case.
|
|
71
|
+
const CAPABILITY_NAMES = Object.keys(CROSS_OWNER_CAPABILITIES).map(escapeRegExp).join("|");
|
|
72
|
+
const CROSS_OWNER_CAPABILITY = new RegExp(`(["'\`])\\s*(${CAPABILITY_NAMES})\\s*\\1`, "giu");
|
|
73
|
+
// The root of the app or of one of its packages, where a folder's name says what the whole tree is: a migrations/ or
|
|
74
|
+
// test/ folder deeper in server code is server code (as in @jskit-ai/config-eslint's dataAccess.js).
|
|
75
|
+
const ROOT = String.raw`(?:(?:packages|tooling)/[^/]+/)?`;
|
|
76
|
+
// Client code, which the data-access rules leave alone: a package's src/client. An app's own src/ is linted whole, since
|
|
77
|
+
// server code sits there too.
|
|
78
|
+
const CLIENT_FILE = new RegExp(`^${ROOT}src/client/`, "u");
|
|
79
|
+
// What the data-access lint leaves alone (NOT_DATA_ACCESS_SOURCE in @jskit-ai/config-eslint's dataAccess.js), for a path
|
|
80
|
+
// from the directory of the ESLint config that governs the file, a nested application's own included: client code,
|
|
81
|
+
// tests, migrations, seeds and build output at a root, seed scripts, and test files.
|
|
82
|
+
const LINT_EXEMPT = new RegExp(`^${ROOT}(?:(?:src/client|src/test|test|tests|__tests__|e2e|test-support|migrations|seeds?|` +
|
|
83
|
+
"dist|build|coverage)/|(?:(?:scripts|bin)/)?seed[^/]*\\.[cm]?js$)|\\.(?:test|spec|vitest)\\.[^/]*$", "u");
|
|
84
|
+
// Server code, which the app's ESLint config may not ignore: a package's src/server or src/shared, or the app's server/.
|
|
85
|
+
const SERVER_FILE = new RegExp(`^(?:${ROOT}src/(?:server|shared)/|server/)`, "u");
|
|
49
86
|
const FRAMEWORK_DIRECTORY = path.join("node_modules", "@jskit-ai");
|
|
50
87
|
const SOURCE_FILE = /\.[cm]?js$/u;
|
|
51
88
|
const SKIPPED_FILE = /\.(?:test|spec|vitest)\.[cm]?js$/u;
|
|
52
|
-
// Code that defines no table resource or system scope an app runs: dependencies,
|
|
53
|
-
// migrations and seeds, and the examples, templates and documentation JSKIT packages carry.
|
|
54
|
-
const
|
|
55
|
-
"
|
|
56
|
-
"
|
|
89
|
+
// Code that defines no table resource or system scope an app runs: dependencies, anywhere; and, at a root (ROOT), build
|
|
90
|
+
// output, tests and fixtures, migrations and seeds, and the examples, templates and documentation JSKIT packages carry.
|
|
91
|
+
const SKIPPED_ROOT_DIRECTORIES = new Set([
|
|
92
|
+
"dist", "build", "coverage", "test", "tests", "__tests__", "e2e", "fixtures", "test-results", "migrations", "seed",
|
|
93
|
+
"seeds", "templates", "patterns", "examples", "docs", "site", "skills", "test-support", "src/test"
|
|
57
94
|
]);
|
|
58
|
-
const
|
|
95
|
+
const PACKAGE_DIRECTORY = /^(?:packages|tooling)\/[^/]+\/(.+)$/u;
|
|
96
|
+
const skipsDirectory = (name) => path.posix.basename(name) === "node_modules" ||
|
|
97
|
+
SKIPPED_ROOT_DIRECTORIES.has(PACKAGE_DIRECTORY.exec(name)?.[1] ?? name);
|
|
98
|
+
const MARKERS = new RegExp(`defineTableResource|defineCrudContract|systemScope|systemContext|readable-check: allow|eslint|${CAPABILITY_NAMES}`, "iu");
|
|
59
99
|
const TABLE_RESOURCE_CALL = /\bdefineTableResource\s*\(/gu;
|
|
100
|
+
const TABLE_RESOURCE_MARKER = /\bdefineTableResource\s*\(/u;
|
|
60
101
|
// A CRUD contract on a table resource named by its variable: `defineCrudContract(receivalResource, { ... })`.
|
|
61
102
|
const CRUD_CONTRACT_CALL = /\bdefineCrudContract\s*\(\s*([A-Za-z_$][\w$]*)\s*,\s*/gu;
|
|
62
103
|
// The fields an ownership kind keeps for itself: json-rest-api fills them from the context.
|
|
@@ -225,7 +266,7 @@ function readTableResources(file, { code, masked, position }) {
|
|
|
225
266
|
for (const match of masked.matchAll(TABLE_RESOURCE_CALL)) {
|
|
226
267
|
const object = objectArgument(masked, match.index, match[0].length);
|
|
227
268
|
if (!object) continue;
|
|
228
|
-
const keys = topLevelKeys(masked, object.start, object.end, ["name", "tableName", "ownership", "schema"]);
|
|
269
|
+
const keys = topLevelKeys(masked, object.start, object.end, ["name", "tableName", "ownership", "schema", "foreignKeys"]);
|
|
229
270
|
const ownership = keyValue(code, masked, keys, "ownership")?.text ?? null;
|
|
230
271
|
resources.push({
|
|
231
272
|
file,
|
|
@@ -233,7 +274,8 @@ function readTableResources(file, { code, masked, position }) {
|
|
|
233
274
|
name: keyValue(code, masked, keys, "name")?.text ?? null,
|
|
234
275
|
tableName: keyValue(code, masked, keys, "tableName")?.text ?? null,
|
|
235
276
|
variable: /(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*$/u.exec(masked.slice(Math.max(0, match.index - 120), match.index))?.[1] ?? null,
|
|
236
|
-
fields: readSchemaFields(code, masked, keys, ownership)
|
|
277
|
+
fields: readSchemaFields(code, masked, keys, ownership),
|
|
278
|
+
foreignKeys: keys.has("foreignKeys")
|
|
237
279
|
});
|
|
238
280
|
}
|
|
239
281
|
return resources;
|
|
@@ -328,30 +370,138 @@ function readInfrastructureExcuses(file, source) {
|
|
|
328
370
|
});
|
|
329
371
|
}
|
|
330
372
|
|
|
373
|
+
const isDataAccessRule = (rule) => DATA_ACCESS_RULES.includes(rule.replace(/^jskit\//u, ""));
|
|
374
|
+
|
|
375
|
+
// The block comments of a file, each { index, value }, read as blankJsComments reads comments: text in a string or a line
|
|
376
|
+
// comment is no comment. ESLint reads inline config only from block comments.
|
|
377
|
+
function blockComments(source) {
|
|
378
|
+
const comments = [];
|
|
379
|
+
let index = 0;
|
|
380
|
+
while (index < source.length) {
|
|
381
|
+
const opening = source.slice(index, index + 2);
|
|
382
|
+
if (opening === "//" || opening === "/*") {
|
|
383
|
+
const end = source.indexOf(opening === "//" ? "\n" : "*/", index + 2);
|
|
384
|
+
const stop = end < 0 ? source.length : end;
|
|
385
|
+
if (opening === "/*") comments.push({ index, value: source.slice(index + 2, stop) });
|
|
386
|
+
index = opening === "/*" ? stop + 2 : stop;
|
|
387
|
+
} else if ("\"'`".includes(source[index])) {
|
|
388
|
+
const quote = source[index];
|
|
389
|
+
index += 1;
|
|
390
|
+
while (index < source.length && source[index] !== quote && (quote === "`" || source[index] !== "\n")) {
|
|
391
|
+
index += source[index] === "\\" ? 2 : 1;
|
|
392
|
+
}
|
|
393
|
+
index += 1;
|
|
394
|
+
} else {
|
|
395
|
+
index += 1;
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
return comments;
|
|
399
|
+
}
|
|
400
|
+
|
|
331
401
|
// Every eslint-disable in a file that turns off a data-access rule: one that names one, or one that names no rule and so
|
|
332
|
-
// turns off every rule.
|
|
402
|
+
// turns off every rule; and every inline config comment that sets a data-access rule, which can only weaken it.
|
|
333
403
|
function readLintDisables(file, source, position) {
|
|
334
404
|
const disables = [];
|
|
335
405
|
for (const match of source.matchAll(LINT_DISABLE)) {
|
|
336
406
|
const named = match[1].replace(/\*\/.*$/u, "").split(/\s--\s|\s--$/u)[0].split(",").map((rule) => rule.trim()).filter(Boolean);
|
|
337
|
-
const rules = named.filter(
|
|
407
|
+
const rules = named.filter(isDataAccessRule);
|
|
338
408
|
if (named.length < 1 || rules.length > 0) {
|
|
339
|
-
disables.push({ file, line: position(match.index).line, rules });
|
|
409
|
+
disables.push({ file, line: position(match.index).line, kind: "disable", rules });
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
for (const comment of blockComments(source)) {
|
|
413
|
+
const settings = LINT_CONFIG.exec(comment.value)?.[1].split(/\s--\s/u)[0] || "";
|
|
414
|
+
const rules = [...settings.matchAll(/(?:^|[\s,{])["']?((?:jskit\/)?[a-z][\w-]*)["']?\s*:/gu)].map((rule) => rule[1]).filter(isDataAccessRule);
|
|
415
|
+
if (rules.length > 0) {
|
|
416
|
+
disables.push({ file, line: position(comment.index).line, kind: "config", rules });
|
|
340
417
|
}
|
|
341
418
|
}
|
|
342
419
|
return disables;
|
|
343
420
|
}
|
|
344
421
|
|
|
345
|
-
// Every place a file's code takes
|
|
346
|
-
//
|
|
422
|
+
// Every place a file's code takes a cross-owner capability: each time it names one outside a provides object (the module
|
|
423
|
+
// that provides it), and other than as the id of a feature that provides it in the same file.
|
|
347
424
|
function readRuntimeCapabilities(file, { code, masked, position }) {
|
|
348
425
|
const provides = [...masked.matchAll(/\bprovides\s*:\s*\{/gu)].map((match) => {
|
|
349
426
|
const start = match.index + match[0].length - 1;
|
|
350
427
|
return { start, end: closingOffset(masked, start) };
|
|
351
428
|
});
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
429
|
+
const names = [...code.matchAll(CROSS_OWNER_CAPABILITY)].map((match) => ({
|
|
430
|
+
match,
|
|
431
|
+
capability: match[2].toLowerCase(),
|
|
432
|
+
provided: provides.some(({ start, end }) => match.index > start && match.index < end)
|
|
433
|
+
}));
|
|
434
|
+
const provided = new Set(names.filter((name) => name.provided).map(({ capability }) => capability));
|
|
435
|
+
const ownId = ({ match, capability }) => provided.has(capability) &&
|
|
436
|
+
/\bid\s*:\s*$/u.test(masked.slice(Math.max(0, match.index - 12), match.index));
|
|
437
|
+
return names.filter((name) => !name.provided && !ownId(name))
|
|
438
|
+
.map(({ match, capability }) => ({ file, line: position(match.index).line, capability }));
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// Whether a rule's setting in a computed config is "error", which ESLint gives as 2 or [2, ...options].
|
|
442
|
+
const isError = (setting) => [2, "error"].includes(Array.isArray(setting) ? setting[0] : setting);
|
|
443
|
+
|
|
444
|
+
// The app's ESLint, from the app or else beside this check, set to find each file's config as `eslint` does; null when
|
|
445
|
+
// neither has one, or the app has no config: then the lint itself cannot run, which fails on its own.
|
|
446
|
+
async function appEslint(root) {
|
|
447
|
+
for (const base of [path.join(root, "package.json"), fileURLToPath(import.meta.url)]) {
|
|
448
|
+
let resolved;
|
|
449
|
+
try {
|
|
450
|
+
resolved = createRequire(base).resolve("eslint");
|
|
451
|
+
} catch {
|
|
452
|
+
continue;
|
|
453
|
+
}
|
|
454
|
+
const api = await import(pathToFileURL(resolved).href);
|
|
455
|
+
const eslint = new (api.ESLint || api.default.ESLint)({ cwd: root });
|
|
456
|
+
return await eslint.findConfigFile(path.join(root, "package.json")) ? eslint : null;
|
|
457
|
+
}
|
|
458
|
+
return null;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// Fails each app file its ESLint config lints without every data-access rule as an error, and each server file it
|
|
462
|
+
// ignores, unless the baseline's infrastructure lists { file, rule } for each rule that is not on. Each file is judged by
|
|
463
|
+
// the config that governs it, as `eslint` does, and what the rules leave alone (LINT_EXEMPT) is told from that config's
|
|
464
|
+
// directory: a nested application's client code is its own.
|
|
465
|
+
async function checkLintConfig(root, files, baseline, report) {
|
|
466
|
+
const eslint = await appEslint(root);
|
|
467
|
+
if (!eslint) return;
|
|
468
|
+
for (const file of files) {
|
|
469
|
+
const absolute = path.join(root, file);
|
|
470
|
+
let configFile;
|
|
471
|
+
let config;
|
|
472
|
+
try {
|
|
473
|
+
configFile = await eslint.findConfigFile(absolute);
|
|
474
|
+
config = configFile ? await eslint.calculateConfigForFile(absolute) : undefined;
|
|
475
|
+
} catch (error) {
|
|
476
|
+
report({ file, line: 1 }, "data/lint-config", `ESLint cannot read the app's config for this file: ${String(error?.message || error).split("\n")[0]}`);
|
|
477
|
+
return;
|
|
478
|
+
}
|
|
479
|
+
const governed = configFile ? toPosixPath(path.relative(path.dirname(configFile), absolute)) : "";
|
|
480
|
+
if (!configFile || LINT_EXEMPT.test(governed) || (!config && !SERVER_FILE.test(governed))) continue;
|
|
481
|
+
const off = DATA_ACCESS_RULES.filter((rule) => !isError(config?.rules?.[`jskit/${rule}`]) &&
|
|
482
|
+
!baseline.infrastructure.some((item) => item?.file === file && item?.rule === rule));
|
|
483
|
+
if (off.length < 1) continue;
|
|
484
|
+
report({ file, line: 1 }, "data/lint-config", config
|
|
485
|
+
? `the ESLint config does not make ${off.map((rule) => `jskit/${rule}`).join(", ")} an error here`
|
|
486
|
+
: "the ESLint config ignores this server file, so no data-access rule checks it");
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
// Fails each cross-owner capability a module requires that is not reviewed: runtime.database and runtime.resources
|
|
491
|
+
// outside a module declared infrastructure, and any other one the baseline's systemScopes does not list with a reason.
|
|
492
|
+
function checkCapabilities(baseline, uses, report) {
|
|
493
|
+
for (const { file, line, capability, infrastructure } of uses) {
|
|
494
|
+
const { gives, infrastructureOnly } = CROSS_OWNER_CAPABILITIES[capability];
|
|
495
|
+
if (infrastructureOnly) {
|
|
496
|
+
if (!infrastructure) report({ file, line }, "data/runtime-capability", `requires ${capability}`);
|
|
497
|
+
continue;
|
|
498
|
+
}
|
|
499
|
+
const entry = baseline.systemScopes.find((item) => item?.file === file && item?.capability === capability);
|
|
500
|
+
if (typeof entry?.systemScope !== "string" || !entry.systemScope.trim()) {
|
|
501
|
+
report({ file, line }, "data/runtime-capability", `requires ${capability}, which gives ${gives}: once the module must, add ` +
|
|
502
|
+
`${JSON.stringify({ file, capability, systemScope: "<why it needs this>" })} to "systemScopes".`);
|
|
503
|
+
}
|
|
504
|
+
}
|
|
355
505
|
}
|
|
356
506
|
|
|
357
507
|
// The app's reviewed baseline, or empty lists when it has none; a file that is not that JSON is a finding.
|
|
@@ -370,7 +520,7 @@ async function readBaseline(root, report) {
|
|
|
370
520
|
systemScopes: Array.isArray(baseline.systemScopes) ? baseline.systemScopes : []
|
|
371
521
|
};
|
|
372
522
|
} catch {
|
|
373
|
-
report({ file: BASELINE_FILE, line: 1 }, "data/baseline", `${BASELINE_FILE} is not JSON; it lists { "infrastructure": [{ "file", "rule" }], "systemScopes": [{ "feature", "systemScope" }] }.`);
|
|
523
|
+
report({ file: BASELINE_FILE, line: 1 }, "data/baseline", `${BASELINE_FILE} is not JSON; it lists { "infrastructure": [{ "file", "rule" }], "systemScopes": [{ "feature", "systemScope" }, { "file", "capability", "systemScope" }] }.`);
|
|
374
524
|
return { infrastructure: [], systemScopes: [] };
|
|
375
525
|
}
|
|
376
526
|
}
|
|
@@ -402,7 +552,7 @@ async function walk(root, directory, files) {
|
|
|
402
552
|
for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
|
|
403
553
|
const relative = directory ? path.join(directory, entry.name) : entry.name;
|
|
404
554
|
if (entry.isDirectory()) {
|
|
405
|
-
if (!entry.name.startsWith(".") && !
|
|
555
|
+
if (!entry.name.startsWith(".") && !skipsDirectory(toPosixPath(relative))) await walk(root, relative, files);
|
|
406
556
|
} else if (entry.isFile() && SOURCE_FILE.test(entry.name) && !SKIPPED_FILE.test(entry.name)) {
|
|
407
557
|
files.push(relative);
|
|
408
558
|
}
|
|
@@ -472,26 +622,50 @@ function checkSystemScopes(scopes, report) {
|
|
|
472
622
|
}
|
|
473
623
|
}
|
|
474
624
|
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
* exceptions.
|
|
478
|
-
* @param {{ projectRoot?: string }} options
|
|
479
|
-
*/
|
|
480
|
-
async function checkDataAccess({ projectRoot = process.cwd() } = {}) {
|
|
481
|
-
const root = path.resolve(projectRoot);
|
|
482
|
-
const sources = [];
|
|
625
|
+
// The app's source files and those of its installed @jskit-ai packages (framework: true), each { file, absolute }.
|
|
626
|
+
async function sourceFiles(root) {
|
|
483
627
|
const appFiles = [];
|
|
484
628
|
await walk(root, "", appFiles);
|
|
485
|
-
sources
|
|
629
|
+
const sources = appFiles.map((file) => ({ file, absolute: path.join(root, file), framework: false }));
|
|
486
630
|
for (const { directory, real } of await frameworkPackages(root)) {
|
|
487
631
|
const files = [];
|
|
488
632
|
await walk(real, "", files);
|
|
489
633
|
sources.push(...files.map((file) => ({ file: path.join(directory, file), absolute: path.join(real, file), framework: true })));
|
|
490
634
|
}
|
|
635
|
+
return sources;
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
/**
|
|
639
|
+
* The table resources the app and its installed @jskit-ai packages define, read from source as checkDataAccess reads
|
|
640
|
+
* them: each { file, absolute, framework, name, tableName, foreignKeys }, `foreignKeys` true when the definition
|
|
641
|
+
* declares them. `jskit test --databases` checks the declared keys against the database.
|
|
642
|
+
* @param {{ projectRoot?: string }} options
|
|
643
|
+
*/
|
|
644
|
+
async function readTableResourceSources({ projectRoot = process.cwd() } = {}) {
|
|
645
|
+
const resources = [];
|
|
646
|
+
for (const { file, absolute, framework } of await sourceFiles(path.resolve(projectRoot))) {
|
|
647
|
+
const source = await readFile(absolute, "utf8");
|
|
648
|
+
if (!TABLE_RESOURCE_MARKER.test(source)) continue;
|
|
649
|
+
const read = readSourceFile(source);
|
|
650
|
+
resources.push(...readTableResources(toPosixPath(file), read).map(({ file: name, line, name: resourceName, tableName, foreignKeys }) =>
|
|
651
|
+
({ file: name, line, absolute, framework, name: resourceName, tableName, foreignKeys })));
|
|
652
|
+
}
|
|
653
|
+
return resources;
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
/**
|
|
657
|
+
* Reads the app's source and its installed @jskit-ai packages for table resources, system scopes and infrastructure
|
|
658
|
+
* exceptions.
|
|
659
|
+
* @param {{ projectRoot?: string }} options
|
|
660
|
+
*/
|
|
661
|
+
async function checkDataAccess({ projectRoot = process.cwd() } = {}) {
|
|
662
|
+
const root = path.resolve(projectRoot);
|
|
663
|
+
const sources = await sourceFiles(root);
|
|
491
664
|
const resources = [];
|
|
492
665
|
const contracts = [];
|
|
493
666
|
const scopes = [];
|
|
494
667
|
const excuses = [];
|
|
668
|
+
const capabilities = [];
|
|
495
669
|
const findings = [];
|
|
496
670
|
const report = ({ file, line }, rule, message) => findings.push({ file, line, rule, message });
|
|
497
671
|
for (const { file, absolute, framework } of sources) {
|
|
@@ -505,21 +679,21 @@ async function checkDataAccess({ projectRoot = process.cwd() } = {}) {
|
|
|
505
679
|
contracts.push(...readCrudContracts(read));
|
|
506
680
|
const fileExcuses = readInfrastructureExcuses(name, source);
|
|
507
681
|
excuses.push(...fileExcuses);
|
|
508
|
-
|
|
509
|
-
for (const use of readRuntimeCapabilities(name, read)) {
|
|
510
|
-
report(use, "data/runtime-capability", `requires ${use.capability}`);
|
|
511
|
-
}
|
|
512
|
-
}
|
|
682
|
+
capabilities.push(...readRuntimeCapabilities(name, read).map((use) => ({ ...use, infrastructure: fileExcuses.length > 0 })));
|
|
513
683
|
if (CLIENT_FILE.test(name)) continue;
|
|
514
684
|
for (const disable of readLintDisables(name, source, read.position)) {
|
|
515
|
-
report(disable, "data/lint-disabled", disable.
|
|
516
|
-
? `eslint
|
|
517
|
-
: "eslint-disable names no rule");
|
|
685
|
+
report(disable, "data/lint-disabled", disable.kind === "config"
|
|
686
|
+
? `an inline eslint config sets ${disable.rules.join(", ")}`
|
|
687
|
+
: disable.rules.length > 0 ? `eslint-disable turns off ${disable.rules.join(", ")}` : "eslint-disable names no rule");
|
|
518
688
|
}
|
|
519
689
|
}
|
|
520
690
|
checkTables(resources, report);
|
|
521
691
|
checkSystemScopes(scopes, report);
|
|
522
|
-
|
|
692
|
+
const baseline = await readBaseline(root, report);
|
|
693
|
+
checkBaseline(baseline, excuses, scopes, report);
|
|
694
|
+
checkCapabilities(baseline, capabilities, report);
|
|
695
|
+
const appFiles = sources.filter((source) => !source.framework).map((source) => toPosixPath(source.file));
|
|
696
|
+
await checkLintConfig(root, appFiles, baseline, report);
|
|
523
697
|
findings.sort((left, right) => left.file.localeCompare(right.file) || left.line - right.line);
|
|
524
698
|
return {
|
|
525
699
|
ok: findings.length < 1,
|
|
@@ -561,4 +735,4 @@ function formatDataAccessReport(result) {
|
|
|
561
735
|
return lines.join("\n");
|
|
562
736
|
}
|
|
563
737
|
|
|
564
|
-
export { checkDataAccess, formatDataAccessReport };
|
|
738
|
+
export { checkDataAccess, formatDataAccessReport, readTableResourceSources };
|
package/scripts/jskit.mjs
CHANGED
|
@@ -247,8 +247,18 @@ async function main(argv = process.argv.slice(2)) {
|
|
|
247
247
|
`${result.changedFiles.map((file) => `- ${file}`).join("\n")}\n`
|
|
248
248
|
);
|
|
249
249
|
}
|
|
250
|
+
if (result.addedMigrations.length > 0) {
|
|
251
|
+
process.stdout.write(
|
|
252
|
+
`The update installed ${result.addedMigrations.length} new package migration(s); run npm run db:migrate before ` +
|
|
253
|
+
`the app serves requests, since the new packages read what they create:\n` +
|
|
254
|
+
`${result.addedMigrations.map((entry) => `- ${entry}`).join("\n")}\n`
|
|
255
|
+
);
|
|
256
|
+
}
|
|
250
257
|
if (!options.install) {
|
|
251
|
-
process.stdout.write(
|
|
258
|
+
process.stdout.write(
|
|
259
|
+
"Skipped npm install; run it before jskit check. If it installs new package migrations, run npm run db:migrate " +
|
|
260
|
+
"before the app serves requests.\n"
|
|
261
|
+
);
|
|
252
262
|
}
|
|
253
263
|
return;
|
|
254
264
|
}
|
|
@@ -438,6 +438,32 @@ async function findImports(root, packageIds) {
|
|
|
438
438
|
return imports;
|
|
439
439
|
}
|
|
440
440
|
|
|
441
|
+
// The migration files the installed @jskit-ai packages ship (jskit.migrations.directories and their constraints/),
|
|
442
|
+
// each "<package>: <path in the package>", as the app's migrations read them from node_modules.
|
|
443
|
+
async function installedPackageMigrations(root) {
|
|
444
|
+
const scope = path.join(root, "node_modules", JSKIT_SCOPE_PREFIX);
|
|
445
|
+
const names = await readdir(scope).catch((error) => {
|
|
446
|
+
if (error?.code === "ENOENT") return [];
|
|
447
|
+
throw error;
|
|
448
|
+
});
|
|
449
|
+
const migrations = new Set();
|
|
450
|
+
for (const name of names) {
|
|
451
|
+
const packageRoot = path.join(scope, name);
|
|
452
|
+
const manifestPath = path.join(packageRoot, "package.json");
|
|
453
|
+
if (!(await fileExists(manifestPath))) continue;
|
|
454
|
+
const directories = (await readJsonRecord(manifestPath)).value.jskit?.migrations?.directories;
|
|
455
|
+
for (const directory of Array.isArray(directories) ? directories : []) {
|
|
456
|
+
for (const folder of [directory, path.join(directory, "constraints")]) {
|
|
457
|
+
const files = await readdir(path.join(packageRoot, folder)).catch(() => []);
|
|
458
|
+
for (const file of files.filter((entry) => entry.endsWith(".cjs"))) {
|
|
459
|
+
migrations.add(`${JSKIT_SCOPE_PREFIX}${name}: ${toPosixPath(path.join(folder, file))}`);
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
return migrations;
|
|
465
|
+
}
|
|
466
|
+
|
|
441
467
|
/**
|
|
442
468
|
* Pins a project's JSKIT dependencies to the catalog and installs them; a
|
|
443
469
|
* failed install or check restores the manifests, lockfile and node_modules.
|
|
@@ -544,8 +570,11 @@ async function updateProject({
|
|
|
544
570
|
|
|
545
571
|
const packageLockPath = path.join(root, "package-lock.json");
|
|
546
572
|
const packageLockSnapshot = install ? await readOptionalFile(packageLockPath) : null;
|
|
573
|
+
// npm install replaces node_modules, so the migrations the packages shipped are read before it.
|
|
574
|
+
const migrationsBefore = install ? await installedPackageMigrations(root) : new Set();
|
|
547
575
|
await writeManifestUpdates(updates);
|
|
548
576
|
let installTransaction = null;
|
|
577
|
+
let addedMigrations = [];
|
|
549
578
|
try {
|
|
550
579
|
if (install) {
|
|
551
580
|
const updatedManifests = await discoverProjectManifests(root, additionalManifests);
|
|
@@ -555,6 +584,7 @@ async function updateProject({
|
|
|
555
584
|
if (!result.ok) {
|
|
556
585
|
throw new Error(`JSKIT graph remains inconsistent after npm install:\n- ${result.issues.join("\n- ")}`);
|
|
557
586
|
}
|
|
587
|
+
addedMigrations = [...await installedPackageMigrations(root)].filter((entry) => !migrationsBefore.has(entry)).sort();
|
|
558
588
|
await installTransaction.commit();
|
|
559
589
|
}
|
|
560
590
|
} catch (error) {
|
|
@@ -586,6 +616,7 @@ async function updateProject({
|
|
|
586
616
|
updates.map((update) => toPosixPath(path.relative(root, update.manifest.absolutePath)))
|
|
587
617
|
),
|
|
588
618
|
catalogVersion,
|
|
619
|
+
addedMigrations: Object.freeze(addedMigrations),
|
|
589
620
|
retiredPackages: Object.freeze(retiredPackageIds.map((packageId) => Object.freeze({
|
|
590
621
|
packageId,
|
|
591
622
|
replacedBy: RETIRED_PACKAGES[packageId].packageId
|
|
@@ -3,6 +3,7 @@ import { createRequire } from "node:module";
|
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import process from "node:process";
|
|
5
5
|
import { pathToFileURL } from "node:url";
|
|
6
|
+
import { readTableResourceSources } from "./data-access.mjs";
|
|
6
7
|
|
|
7
8
|
// `jskit test --databases`: each test file running at once gets a database of its own, a copy of TEST_DB_NAME restored
|
|
8
9
|
// from a schema snapshot, from the application's installed @jskit-ai/database-runtime and its knexfile.js, which loads
|
|
@@ -13,6 +14,9 @@ import { pathToFileURL } from "node:url";
|
|
|
13
14
|
const OPTION = "--databases";
|
|
14
15
|
const KNEXFILES = Object.freeze(["knexfile.js", "knexfile.mjs", "knexfile.cjs"]);
|
|
15
16
|
const TEST_DATABASES = "@jskit-ai/database-runtime/server/testDatabases";
|
|
17
|
+
const RESOURCE_RUNTIME = "@jskit-ai/resource-runtime/server";
|
|
18
|
+
const TABLE_RESOURCES = "@jskit-ai/resource-core/shared/tableResource";
|
|
19
|
+
const GUIDE = "https://mobily-enterprises.github.io/jskit-ai/guide/framework/data-access#layer-1-the-table-resource";
|
|
16
20
|
|
|
17
21
|
/** Runs `load` and puts process.env back as it was, returning what `load` returned and the environment it saw. */
|
|
18
22
|
async function withOwnEnvironment(load) {
|
|
@@ -26,6 +30,76 @@ async function withOwnEnvironment(load) {
|
|
|
26
30
|
}
|
|
27
31
|
}
|
|
28
32
|
|
|
33
|
+
// A module's exported table resources: each export, or each item of an exported list or object.
|
|
34
|
+
function exportedTableResources(namespace, isTableResource) {
|
|
35
|
+
return Object.values(namespace).flatMap((value) => {
|
|
36
|
+
if (isTableResource(value)) return [value];
|
|
37
|
+
const items = Array.isArray(value) ? value : value && typeof value === "object" ? Object.values(value) : [];
|
|
38
|
+
return items.filter((item) => isTableResource(item));
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const keyText = ({ tableName, columns, referencedTableName, referencedColumns }) =>
|
|
43
|
+
`${tableName} (${columns.join(", ")}) -> ${referencedTableName} (${referencedColumns.join(", ")})`;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Compares the foreignKeys the app's table resources and its @jskit-ai packages' declare with the keys TEST_DB_NAME
|
|
47
|
+
* holds, once a run's databases are ready: json-rest-api relies on a declaration to skip work, so a declared key the
|
|
48
|
+
* database lacks fails the run. A package's table that the database does not have is left out: node_modules can hold
|
|
49
|
+
* a package whose migrations the app never runs (one only a devDependency brings, or one left behind), and the app
|
|
50
|
+
* never reads that table. The app's own tables are always checked. A key of one of the app's own tables that nothing
|
|
51
|
+
* declares is reported, as work json-rest-api could skip. It imports only the modules that declare foreignKeys; an app
|
|
52
|
+
* without the resource runtime has nothing to check.
|
|
53
|
+
* @param {string} cwd
|
|
54
|
+
* @returns {(knex: object) => Promise<string>}
|
|
55
|
+
*/
|
|
56
|
+
function foreignKeyCheckFor(cwd) {
|
|
57
|
+
return async (knex) => {
|
|
58
|
+
const appRequire = createRequire(path.join(cwd, "package.json"));
|
|
59
|
+
let runtime;
|
|
60
|
+
let tableResources;
|
|
61
|
+
try {
|
|
62
|
+
[runtime, tableResources] = [appRequire.resolve(RESOURCE_RUNTIME), appRequire.resolve(TABLE_RESOURCES)];
|
|
63
|
+
} catch {
|
|
64
|
+
return "";
|
|
65
|
+
}
|
|
66
|
+
const sources = await readTableResourceSources({ projectRoot: cwd });
|
|
67
|
+
const tables = sources.filter((source) => !source.framework && source.tableName).map((source) => source.tableName);
|
|
68
|
+
const declaringFiles = [...new Set(sources.filter((source) => source.foreignKeys).map((source) => source.absolute))];
|
|
69
|
+
if (tables.length < 1 && declaringFiles.length < 1) return "";
|
|
70
|
+
const { isTableResource } = await import(pathToFileURL(tableResources).href);
|
|
71
|
+
const resources = [];
|
|
72
|
+
const absentPackageTables = new Set();
|
|
73
|
+
for (const file of declaringFiles) {
|
|
74
|
+
const declaring = sources.filter((entry) => entry.absolute === file && entry.foreignKeys);
|
|
75
|
+
const exported = exportedTableResources(await import(pathToFileURL(file).href), isTableResource);
|
|
76
|
+
for (const source of declaring) {
|
|
77
|
+
if (!exported.some((resource) => resource.foreignKeys && resource.name === source.name)) {
|
|
78
|
+
throw new Error(`${source.file}:${source.line} declares foreignKeys on a table resource the module does not export, so ` +
|
|
79
|
+
`jskit test ${OPTION} cannot check them against the database: export the resource.`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
for (const resource of exported.filter((entry) => entry.foreignKeys)) {
|
|
83
|
+
if (declaring[0].framework && !(await knex.schema.hasTable(resource.tableName))) absentPackageTables.add(resource.tableName);
|
|
84
|
+
else resources.push(resource);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
const { checkForeignKeys } = await import(pathToFileURL(runtime).href);
|
|
88
|
+
const { missing, undeclared } = await checkForeignKeys({ knex, resources: [...new Set(resources)], tables });
|
|
89
|
+
if (missing.length > 0) {
|
|
90
|
+
throw new Error(`jskit test ${OPTION}: ${missing.length} declared foreign key(s) the test database does not hold. ` +
|
|
91
|
+
`json-rest-api relies on a declared key, so a wrong one changes results: fix the declaration or add the key ` +
|
|
92
|
+
`with a migration (${GUIDE}).\n` + missing.map((key) => ` ${key.resource}: ${keyText(key)}`).join("\n"));
|
|
93
|
+
}
|
|
94
|
+
const declared = [...new Set(resources)].reduce((count, resource) => count + resource.foreignKeys.length, 0);
|
|
95
|
+
const checked = `foreign keys: ${declared} declared, all in the database` + (absentPackageTables.size < 1 ? "" :
|
|
96
|
+
` (${absentPackageTables.size} JSKIT package table(s) the database does not have are not checked)`);
|
|
97
|
+
if (undeclared.length < 1) return checked;
|
|
98
|
+
return `${checked}; ${undeclared.length} database key(s) on the app's tables are not declared, so json-rest-api cannot ` +
|
|
99
|
+
`skip the work they would save (declare each on its table resource's foreignKeys): ${undeclared.map(keyText).join("; ")}`;
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
29
103
|
/**
|
|
30
104
|
* The run's databases for `count` files at once, or one file at a time on the connected database.
|
|
31
105
|
* @param {number} count at most the run's test budget, and no more than its test files
|
|
@@ -44,7 +118,7 @@ async function testDatabaseRunFor(count, cwd = process.cwd()) {
|
|
|
44
118
|
const { result: { default: knexConfig, testDatabaseSchema: schema }, environment } = await withOwnEnvironment(async () =>
|
|
45
119
|
import(pathToFileURL(knexfile).href));
|
|
46
120
|
const { testDatabaseRun } = await import(pathToFileURL(testDatabases).href);
|
|
47
|
-
return testDatabaseRun({ knexConfig, environment, count, schema, appRoot: cwd });
|
|
121
|
+
return testDatabaseRun({ knexConfig, environment, count, schema, appRoot: cwd, checkDatabase: foreignKeyCheckFor(cwd) });
|
|
48
122
|
}
|
|
49
123
|
|
|
50
|
-
export { OPTION as DATABASES_OPTION, testDatabaseRunFor };
|
|
124
|
+
export { OPTION as DATABASES_OPTION, foreignKeyCheckFor, testDatabaseRunFor };
|