@jskit-ai/jskit-catalog 0.2.35 → 0.2.37

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/scripts/jskit.mjs CHANGED
@@ -11,6 +11,7 @@ import {
11
11
  formatCheckReport,
12
12
  formatExportSummary
13
13
  } from "./i18n.mjs";
14
+ import { checkDataAccess, formatDataAccessReport } from "./data-access.mjs";
14
15
  import { checkMaterial3, formatMaterial3Report, formatNavigationReport } from "./material3.mjs";
15
16
  import { checkProject, updateProject } from "./project-packages.mjs";
16
17
  import { runTests } from "./test-command.mjs";
@@ -45,7 +46,11 @@ function usage() {
45
46
  " workers do not come from testConcurrency() of @jskit-ai/jskit-catalog/test-budget. A Playwright",
46
47
  " config with a webServer uses browserTestConfig() of @jskit-ai/jskit-catalog/browser-tests (one",
47
48
  " production-build server and database per worker), never a development server, and every one",
48
- " has a globalTimeout.",
49
+ " has a globalTimeout. It fails when two table resources (defineTableResource) of the app and its",
50
+ " installed JSKIT packages share a table or a name (data/one-resource-per-table), or when a system",
51
+ " scope (a feature's systemScope, a systemContext() call) gives no literal reason (data/system-scope),",
52
+ " and it lists every system scope and every file the infrastructure exception lifts past the",
53
+ " data-access lint rules for review.",
49
54
  " check m3 List application UI source that departs from the JSKIT Material 3 guide, each finding with its",
50
55
  " guide section: the whole app, or only the given files and directories (a section, or a task's",
51
56
  " changed files). Its visual rules (m3/*) are a report; its nav/* rules are plain jskit check's.",
@@ -231,6 +236,9 @@ async function main(argv = process.argv.slice(2)) {
231
236
  additionalManifests: options.additionalManifests,
232
237
  install: options.install
233
238
  });
239
+ for (const { packageId, replacedBy } of result.retiredPackages) {
240
+ process.stdout.write(`Removed ${packageId}, which this release retired: ${replacedBy} replaced it.\n`);
241
+ }
234
242
  if (result.changedFiles.length < 1) {
235
243
  process.stdout.write(`JSKIT manifests already match catalog ${result.catalogVersion}.\n`);
236
244
  } else {
@@ -239,8 +247,18 @@ async function main(argv = process.argv.slice(2)) {
239
247
  `${result.changedFiles.map((file) => `- ${file}`).join("\n")}\n`
240
248
  );
241
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
+ }
242
257
  if (!options.install) {
243
- process.stdout.write("Skipped npm install; run it before jskit check.\n");
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
+ );
244
262
  }
245
263
  return;
246
264
  }
@@ -253,7 +271,10 @@ async function main(argv = process.argv.slice(2)) {
253
271
  });
254
272
  const ui = await checkMaterial3({ projectRoot: options.projectRoot, navigationOnly: !MATERIAL3_GATE });
255
273
  const tests = await checkTestRuns({ projectRoot: options.projectRoot, manifests: result.manifests });
274
+ const data = await checkDataAccess({ projectRoot: options.projectRoot });
275
+ // The data rules first: a graph that fails on many pin versions would otherwise bury them.
256
276
  const reports = [
277
+ formatDataAccessReport(data),
257
278
  result.ok
258
279
  ? `JSKIT project graph is coordinated across ${result.manifests.length} manifest(s) and ` +
259
280
  `${result.installations.length} resolved package installation(s).`
@@ -261,7 +282,7 @@ async function main(argv = process.argv.slice(2)) {
261
282
  MATERIAL3_GATE ? formatMaterial3Report(ui) : formatNavigationReport(ui),
262
283
  formatTestRunsReport(tests)
263
284
  ];
264
- if (!result.ok || !ui.ok || !tests.ok) {
285
+ if (!result.ok || !ui.ok || !tests.ok || !data.ok) {
265
286
  throw new Error(reports.join("\n"));
266
287
  }
267
288
  process.stdout.write(`${reports.join("\n")}\n`);
@@ -1,4 +1,4 @@
1
- import { realpath, rename, rm, writeFile } from "node:fs/promises";
1
+ import { readdir, readFile, realpath, rename, rm, writeFile } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { fileExists, toPosixPath } from "./files.mjs";
4
4
  import { readOptionalFile, restoreProjectFiles, runNpmInstall, writeManifestUpdates } from "./project-install.mjs";
@@ -13,6 +13,16 @@ const DEPENDENCY_FIELDS = Object.freeze([
13
13
  "optionalDependencies"
14
14
  ]);
15
15
  const UPDATE_SCRIPT = "npx --yes @jskit-ai/jskit-catalog@latest update";
16
+ // JSKIT packages a release retired, each with what replaced it. The updater removes their declarations itself, and
17
+ // refuses while the application's own code still imports one, naming the import to use instead.
18
+ const RETIRED_PACKAGES = Object.freeze({
19
+ "@jskit-ai/json-rest-api-core": Object.freeze({
20
+ packageId: "@jskit-ai/resource-runtime",
21
+ importFrom: "@jskit-ai/resource-runtime/server (its types from @jskit-ai/resource-runtime/server/types)"
22
+ })
23
+ });
24
+ const SOURCE_FILE_PATTERN = /\.(?:[cm]?[jt]s|vue)$/u;
25
+ const SKIPPED_DIRECTORIES = new Set(["node_modules", "dist", "build", "coverage"]);
16
26
  const CHECK_SCRIPT = "jskit check";
17
27
 
18
28
  function normalizeCatalog(catalog = {}) {
@@ -395,6 +405,65 @@ async function preparePackageLockForInstall(projectRoot, manifests, packageLockP
395
405
  }
396
406
  }
397
407
 
408
+ /**
409
+ * The application's files, outside node_modules and build output, that import one of the packages.
410
+ * @param {string} root
411
+ * @param {string[]} packageIds
412
+ * @returns {Promise<Map<string, string[]>>} The importing files, relative to root, by package.
413
+ */
414
+ async function findImports(root, packageIds) {
415
+ const imports = new Map();
416
+ const patterns = packageIds.map((packageId) => {
417
+ const name = packageId.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
418
+ return [packageId, new RegExp(`["']${name}(?:/[^"']*)?["']`, "u")];
419
+ });
420
+ const visit = async (directory) => {
421
+ for (const entry of await readdir(directory, { withFileTypes: true })) {
422
+ const absolutePath = path.join(directory, entry.name);
423
+ if (entry.isDirectory()) {
424
+ if (!entry.name.startsWith(".") && !SKIPPED_DIRECTORIES.has(entry.name)) {
425
+ await visit(absolutePath);
426
+ }
427
+ } else if (entry.isFile() && SOURCE_FILE_PATTERN.test(entry.name)) {
428
+ const source = await readFile(absolutePath, "utf8");
429
+ for (const [packageId, pattern] of patterns) {
430
+ if (pattern.test(source)) {
431
+ imports.set(packageId, [...(imports.get(packageId) || []), toPosixPath(path.relative(root, absolutePath))]);
432
+ }
433
+ }
434
+ }
435
+ }
436
+ };
437
+ await visit(root);
438
+ return imports;
439
+ }
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
+
398
467
  /**
399
468
  * Pins a project's JSKIT dependencies to the catalog and installs them; a
400
469
  * failed install or check restores the manifests, lockfile and node_modules.
@@ -414,10 +483,20 @@ async function updateProject({
414
483
  const localPackageNames = collectLocalPackageNames(manifests);
415
484
  const declarations = collectManifestDeclarations(manifests, root)
416
485
  .filter((entry) => !localPackageNames.has(entry.packageId));
486
+ const retiredPackageIds = [...new Set(
487
+ declarations.map((entry) => entry.packageId).filter((packageId) => Object.hasOwn(RETIRED_PACKAGES, packageId))
488
+ )].sort();
489
+ const retiredImports = retiredPackageIds.length > 0 ? await findImports(root, retiredPackageIds) : new Map();
490
+ if (retiredImports.size > 0) {
491
+ throw new Error(`Cannot update while the application imports retired JSKIT packages:\n${[...retiredImports]
492
+ .map(([packageId, files]) => `- ${packageId} (in ${files.sort().join(", ")}): ${RETIRED_PACKAGES[packageId].packageId} ` +
493
+ `replaced it; import from ${RETIRED_PACKAGES[packageId].importFrom} instead.`)
494
+ .join("\n")}`);
495
+ }
417
496
  const unknownPackageIds = [...new Set(
418
497
  declarations
419
498
  .map((entry) => entry.packageId)
420
- .filter((packageId) => !normalizedCatalog.versions.has(packageId))
499
+ .filter((packageId) => !normalizedCatalog.versions.has(packageId) && !retiredPackageIds.includes(packageId))
421
500
  )].sort();
422
501
  if (unknownPackageIds.length > 0) {
423
502
  throw new Error(`Cannot update JSKIT packages missing from the coordinated catalog: ${unknownPackageIds.join(", ")}.`);
@@ -449,6 +528,11 @@ async function updateProject({
449
528
  if (!packageId.startsWith(JSKIT_SCOPE_PREFIX) || localPackageNames.has(packageId)) {
450
529
  continue;
451
530
  }
531
+ if (retiredPackageIds.includes(packageId)) {
532
+ delete next[field][packageId];
533
+ changed = true;
534
+ continue;
535
+ }
452
536
  const expectedVersion = normalizedCatalog.versions.get(packageId);
453
537
  if (next[field][packageId] !== expectedVersion) {
454
538
  next[field][packageId] = expectedVersion;
@@ -486,8 +570,11 @@ async function updateProject({
486
570
 
487
571
  const packageLockPath = path.join(root, "package-lock.json");
488
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();
489
575
  await writeManifestUpdates(updates);
490
576
  let installTransaction = null;
577
+ let addedMigrations = [];
491
578
  try {
492
579
  if (install) {
493
580
  const updatedManifests = await discoverProjectManifests(root, additionalManifests);
@@ -497,6 +584,7 @@ async function updateProject({
497
584
  if (!result.ok) {
498
585
  throw new Error(`JSKIT graph remains inconsistent after npm install:\n- ${result.issues.join("\n- ")}`);
499
586
  }
587
+ addedMigrations = [...await installedPackageMigrations(root)].filter((entry) => !migrationsBefore.has(entry)).sort();
500
588
  await installTransaction.commit();
501
589
  }
502
590
  } catch (error) {
@@ -527,7 +615,12 @@ async function updateProject({
527
615
  changedFiles: Object.freeze(
528
616
  updates.map((update) => toPosixPath(path.relative(root, update.manifest.absolutePath)))
529
617
  ),
530
- catalogVersion
618
+ catalogVersion,
619
+ addedMigrations: Object.freeze(addedMigrations),
620
+ retiredPackages: Object.freeze(retiredPackageIds.map((packageId) => Object.freeze({
621
+ packageId,
622
+ replacedBy: RETIRED_PACKAGES[packageId].packageId
623
+ })))
531
624
  });
532
625
  }
533
626
 
@@ -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 };
@@ -249,6 +249,17 @@ async function validateMigrationDirectory(packageName, absoluteDirectory, relati
249
249
  return entries;
250
250
  }
251
251
 
252
+ // Each apiSummary surface names an entrypoint the package exports, or a group of them ("./server" for its "./server/..."
253
+ // subpaths), so the catalog never describes an import that fails.
254
+ function validateApiSummarySurfaces(packageJson) {
255
+ const exported = packageJson.exports && typeof packageJson.exports === "object" ? Object.keys(packageJson.exports) : [];
256
+ for (const { subpath } of packageJson.jskit?.metadata?.apiSummary?.surfaces || []) {
257
+ if (!exported.some((entry) => entry === subpath || entry.startsWith(`${subpath}/`))) {
258
+ throw new Error(`${packageJson.name}#jskit.metadata.apiSummary describes ${subpath}, which the package does not export.`);
259
+ }
260
+ }
261
+ }
262
+
252
263
  async function validatePackage(packageRecord, localVersions, migrationOwners) {
253
264
  const { packageJson, packageRoot, manifestPath } = packageRecord;
254
265
  const packageId = String(packageJson.name || "").trim();
@@ -282,6 +293,7 @@ async function validatePackage(packageRecord, localVersions, migrationOwners) {
282
293
  throw new Error(`${packageId} cannot both provide and require the same capability.`);
283
294
  }
284
295
 
296
+ validateApiSummarySurfaces(packageJson);
285
297
  const serverProviderCount = await validateProviderList(packageRecord, "server");
286
298
  await validateProviderList(packageRecord, "client");
287
299
  await validateMigrations(packageRecord, migrationOwners);
@@ -337,12 +349,22 @@ function validateCapabilityClosure(packages, { builtinCapabilities = BUILTIN_CAP
337
349
  }
338
350
  }
339
351
 
352
+ // The application's one table runtime: a package that takes it as a dependency can install a second copy of it beside
353
+ // the application's, with its own tables, transactions and error classes. Every package takes it as a peer.
354
+ const TABLE_RUNTIME_PACKAGE_IDS = Object.freeze(["@jskit-ai/database-runtime", "@jskit-ai/resource-runtime"]);
355
+
340
356
  function validateSingletonPeerDependencies(packages) {
341
357
  const singletonPackageIds = new Set(collectSingletonPackageIds(packages.map(({ packageJson }) => packageJson)));
342
358
 
343
359
  for (const { packageJson } of packages) {
344
360
  for (const section of ["dependencies", "optionalDependencies"]) {
345
361
  for (const dependencyId of Object.keys(packageJson[section] || {})) {
362
+ if (TABLE_RUNTIME_PACKAGE_IDS.includes(dependencyId)) {
363
+ throw new Error(
364
+ `${packageJson.name}#${section}.${dependencyId} must be declared in peerDependencies because ` +
365
+ `${dependencyId} is the application's one table runtime.`
366
+ );
367
+ }
346
368
  if (!singletonPackageIds.has(dependencyId)) {
347
369
  continue;
348
370
  }
@@ -400,6 +422,7 @@ if (process.argv[1] && path.resolve(process.argv[1]) === SCRIPT_PATH) {
400
422
 
401
423
  export {
402
424
  main,
425
+ validateApiSummarySurfaces,
403
426
  validateCapabilityClosure,
404
427
  validateMigrations,
405
428
  validateProviderExport,