@xemahq/repo-build-tooling 0.27.0 → 0.27.2
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/package.json +2 -2
- package/src/composed-image-install.mjs +28 -4
- package/src/composed-image-install.test.mjs +51 -5
- package/src/composed-image-prisma-check.mjs +23 -12
- package/src/composed-image-prisma-check.test.mjs +18 -0
- package/src/composed-image-stage.mjs +7 -5
- package/src/composed-image-stage.test.mjs +41 -0
- package/src/composed-migrate.mjs +71 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xemahq/repo-build-tooling",
|
|
3
|
-
"version": "0.27.
|
|
3
|
+
"version": "0.27.2",
|
|
4
4
|
"description": "Dev-time build tooling shared by every Xema repository. Ships as plain ESM, depending only on @xemahq/release-policy for registry access, so the published artifact is the reviewed source.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Neuralchowder Inc. <developer@xema.dev> (https://xema.dev)",
|
|
@@ -50,6 +50,6 @@
|
|
|
50
50
|
"test": "node src/scrub-swagger-plugin-paths.mjs --self-test && node --test src/check-workspace-range-matches-local.test.mjs && node --test src/check-no-vendored-deps.test.mjs && node --test src/readiness.test.mjs && node --test src/composed-image.test.mjs && node --test src/composed-image-coordinate-cli.test.mjs && node --test src/declaration-refusal.test.mjs && node --test src/smoke-refusal.test.mjs && node --test src/load-image.test.mjs && node --test src/composed-image-coordinate.test.mjs && node --test src/composed-image-dockerfile.test.mjs && node --test src/service-bootstrap.test.mjs && node --test src/service-bootstrap-protocols.test.mjs && node --test src/composed-image-prebuilt-dockerfile.test.mjs && node --test src/composed-image-in-image-closure.test.mjs && node --test src/composed-image-stage.test.mjs && node --test src/composed-image-stage-tree.test.mjs && node --test src/composed-image-install.test.mjs && node --test src/composed-image-proof.test.mjs && node --test src/composed-image-pnpm-context.test.mjs && node --test src/composed-image-sync-injected.test.mjs && node --test src/composed-image-prisma-check.test.mjs && node --test src/composed-image-prisma-engine.test.mjs && node --test src/composed-migrate.test.mjs && node --test src/promote-image.test.mjs && node --test src/arm64-supplement-needed.test.mjs && node --test src/service-image.test.mjs && node --test src/image-cpu-arch.test.mjs"
|
|
51
51
|
},
|
|
52
52
|
"xemaRelease": {
|
|
53
|
-
"sourceDigest": "sha256:
|
|
53
|
+
"sourceDigest": "sha256:3a4f2d10146fe209afa48b5f783954e50bd239602444a7a59920cc7196169c6b"
|
|
54
54
|
}
|
|
55
55
|
}
|
|
@@ -4,8 +4,12 @@
|
|
|
4
4
|
// for the members' closures only and checked to pin nothing new, then production dependencies are
|
|
5
5
|
// installed from the runner's pnpm store alone (`--offline`, `--ignore-scripts`), selected for the
|
|
6
6
|
// TARGET platform (`--os/--cpu/--libc`) so no foreign-platform binary is staged.
|
|
7
|
+
//
|
|
8
|
+
// The re-resolve runs in a scratch copy of the tree's manifests, never in the tree: pnpm 10.33 `--lockfile-only`
|
|
9
|
+
// with a filter that leaves workspace projects out still links a full `node_modules` (dev dependencies included),
|
|
10
|
+
// and the `--prod` install after it does not prune it, so dev-only packages would ship and be scanned.
|
|
7
11
|
import { execFileSync } from 'node:child_process';
|
|
8
|
-
import { copyFileSync, mkdtempSync, rmSync } from 'node:fs';
|
|
12
|
+
import { copyFileSync, mkdirSync, mkdtempSync, readdirSync, rmSync } from 'node:fs';
|
|
9
13
|
import { tmpdir } from 'node:os';
|
|
10
14
|
import { join } from 'node:path';
|
|
11
15
|
|
|
@@ -31,21 +35,41 @@ export const productionInstallCommand = (filters, arch) => [
|
|
|
31
35
|
'--os=linux', `--cpu=${nodeArch(arch)}`, `--libc=${libcOf(arch)}`,
|
|
32
36
|
];
|
|
33
37
|
|
|
38
|
+
/** The root files a resolve reads, beside every workspace `package.json` and patch file. */
|
|
39
|
+
const RESOLVE_ROOT_FILES = Object.freeze(['package.json', 'pnpm-lock.yaml', 'pnpm-workspace.yaml', '.npmrc', '.pnpmfile.cjs']);
|
|
40
|
+
|
|
41
|
+
/** Copy what `pnpm install --lockfile-only` reads from `treeRoot` into `resolveRoot`: no sources, no `node_modules`. */
|
|
42
|
+
export function copyResolveInputs(treeRoot, resolveRoot, directory = '') {
|
|
43
|
+
for (const entry of readdirSync(join(treeRoot, directory), { withFileTypes: true })) {
|
|
44
|
+
const path = join(directory, entry.name);
|
|
45
|
+
if (entry.isDirectory()) {
|
|
46
|
+
if (entry.name !== 'node_modules' && entry.name !== '.git') copyResolveInputs(treeRoot, resolveRoot, path);
|
|
47
|
+
} else if (entry.isFile() && (entry.name === 'package.json' || entry.name.endsWith('.patch') || (directory === '' && RESOLVE_ROOT_FILES.includes(entry.name)))) {
|
|
48
|
+
mkdirSync(join(resolveRoot, directory), { recursive: true });
|
|
49
|
+
copyFileSync(join(treeRoot, path), join(resolveRoot, path));
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
34
54
|
const defaultExec = (file, args, { cwd }) => execFileSync(file, args, { cwd, stdio: 'inherit' });
|
|
35
55
|
|
|
36
56
|
/**
|
|
37
57
|
* Install the members' production closure into `treeRoot` (which holds the root manifests, the lockfile and the
|
|
38
58
|
* closure's package files). `filters` are the members' literal `--filter <package>...` words. Throws when the
|
|
39
|
-
* re-resolve pinned a registry package the committed lockfile does not.
|
|
59
|
+
* re-resolve pinned a registry package the committed lockfile does not. Only the re-resolved `pnpm-lock.yaml` comes
|
|
60
|
+
* back from the scratch copy, so the tree's `node_modules` is the production install's alone.
|
|
40
61
|
*/
|
|
41
62
|
export function installClosure({ treeRoot, filters, arch, exec = defaultExec }) {
|
|
42
63
|
const scratch = mkdtempSync(join(tmpdir(), 'xema-composed-lock-'));
|
|
43
64
|
try {
|
|
44
65
|
const committed = join(scratch, 'pnpm-lock.committed.yaml');
|
|
45
66
|
copyFileSync(join(treeRoot, 'pnpm-lock.yaml'), committed);
|
|
67
|
+
const resolveRoot = join(scratch, 'resolve');
|
|
68
|
+
copyResolveInputs(treeRoot, resolveRoot);
|
|
46
69
|
const [resolveFile, ...resolveArgs] = lockfileOnlyCommand(filters);
|
|
47
|
-
exec(resolveFile, resolveArgs, { cwd:
|
|
48
|
-
assertPinsUnchanged({ root:
|
|
70
|
+
exec(resolveFile, resolveArgs, { cwd: resolveRoot });
|
|
71
|
+
assertPinsUnchanged({ root: resolveRoot, committedLockfile: committed });
|
|
72
|
+
copyFileSync(join(resolveRoot, 'pnpm-lock.yaml'), join(treeRoot, 'pnpm-lock.yaml'));
|
|
49
73
|
const [installFile, ...installArgs] = productionInstallCommand(filters, arch);
|
|
50
74
|
exec(installFile, installArgs, { cwd: treeRoot });
|
|
51
75
|
// pnpm 10.33 frozen offline install does not filter by libc; cpu/os do.
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import assert from 'node:assert/strict';
|
|
2
|
+
import { execFileSync } from 'node:child_process';
|
|
2
3
|
import fs from 'node:fs';
|
|
3
4
|
import os from 'node:os';
|
|
4
5
|
import path from 'node:path';
|
|
@@ -15,11 +16,23 @@ function tree(committed) {
|
|
|
15
16
|
return treeRoot;
|
|
16
17
|
}
|
|
17
18
|
|
|
18
|
-
test('the re-resolve then the production install
|
|
19
|
+
test('the re-resolve runs in a scratch copy of the manifests, then the production install runs in the tree, offline and frozen, for the target platform, with the literal filters', () => {
|
|
19
20
|
const treeRoot = tree(lock('left-pad@1.3.0'));
|
|
21
|
+
fs.writeFileSync(path.join(treeRoot, 'package.json'), '{"name":"root"}');
|
|
22
|
+
fs.mkdirSync(path.join(treeRoot, 'apps/a/dist'), { recursive: true });
|
|
23
|
+
fs.writeFileSync(path.join(treeRoot, 'apps/a/package.json'), '{"name":"@t/a"}');
|
|
24
|
+
fs.writeFileSync(path.join(treeRoot, 'apps/a/dist/main.js'), '');
|
|
20
25
|
const calls = [];
|
|
21
|
-
|
|
22
|
-
|
|
26
|
+
const exec = (file, args, options) => {
|
|
27
|
+
const seen = fs.readdirSync(options.cwd, { recursive: true }).sort();
|
|
28
|
+
if (args.includes('--lockfile-only')) fs.writeFileSync(path.join(options.cwd, 'pnpm-lock.yaml'), `${lock('left-pad@1.3.0')}# re-resolved\n`);
|
|
29
|
+
calls.push({ line: [file, ...args], cwd: options.cwd, seen });
|
|
30
|
+
};
|
|
31
|
+
installClosure({ treeRoot, filters: FILTERS, arch: 'arm64', exec });
|
|
32
|
+
assert.notEqual(calls[0].cwd, treeRoot);
|
|
33
|
+
assert.deepEqual(calls[0].seen, ['apps', 'apps/a', 'apps/a/package.json', 'package.json', 'pnpm-lock.yaml']);
|
|
34
|
+
assert.equal(calls[1].cwd, treeRoot);
|
|
35
|
+
assert.match(fs.readFileSync(path.join(treeRoot, 'pnpm-lock.yaml'), 'utf8'), /# re-resolved/, 'the re-resolved lockfile came back into the tree');
|
|
23
36
|
assert.deepEqual(calls[0].line, lockfileOnlyCommand(FILTERS));
|
|
24
37
|
assert.deepEqual(calls[1].line, productionInstallCommand(FILTERS, 'arm64'));
|
|
25
38
|
const install = calls[1].line.join(' ');
|
|
@@ -32,10 +45,43 @@ test('the re-resolve then the production install run in the tree, offline and fr
|
|
|
32
45
|
test('a re-resolution that pins a registry package the committed lockfile does not stops before the install', () => {
|
|
33
46
|
const treeRoot = tree(lock('left-pad@1.3.0'));
|
|
34
47
|
const calls = [];
|
|
35
|
-
const exec = (file, args) => {
|
|
48
|
+
const exec = (file, args, options) => {
|
|
36
49
|
calls.push(args);
|
|
37
|
-
if (args.includes('--lockfile-only')) fs.writeFileSync(path.join(
|
|
50
|
+
if (args.includes('--lockfile-only')) fs.writeFileSync(path.join(options.cwd, 'pnpm-lock.yaml'), lock('left-pad@1.3.0', 'evil@9.9.9'));
|
|
38
51
|
};
|
|
39
52
|
assert.throws(() => installClosure({ treeRoot, filters: FILTERS, arch: 'amd64', exec }), /pinned registry package\(s\) the committed lockfile does not:\n {2}- evil@9\.9\.9/);
|
|
40
53
|
assert.equal(calls.length, 1, 'the production install never ran');
|
|
54
|
+
assert.doesNotMatch(fs.readFileSync(path.join(treeRoot, 'pnpm-lock.yaml'), 'utf8'), /evil/, 'the refused lockfile never reached the tree');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
// Real pnpm, no registry: `file:` packages only. pnpm 10.33 `--lockfile-only` with a filter that leaves a workspace
|
|
58
|
+
// project out links a full node_modules, dev dependencies included; the `--prod` install after it never prunes them.
|
|
59
|
+
test('after the install the tree holds the production closure only: no dev-only package, even when the filter leaves a project out', () => {
|
|
60
|
+
const treeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'xema-install-real-'));
|
|
61
|
+
const write = (file, json) => {
|
|
62
|
+
fs.mkdirSync(path.dirname(path.join(treeRoot, file)), { recursive: true });
|
|
63
|
+
fs.writeFileSync(path.join(treeRoot, file), JSON.stringify(json));
|
|
64
|
+
};
|
|
65
|
+
try {
|
|
66
|
+
write('package.json', { name: 'root', private: true });
|
|
67
|
+
fs.writeFileSync(path.join(treeRoot, 'pnpm-workspace.yaml'), "packages:\n - 'apps/*'\n");
|
|
68
|
+
write('vendor/prod-only/package.json', { name: 'prod-only', version: '1.0.0' });
|
|
69
|
+
write('vendor/dev-only/package.json', { name: 'dev-only', version: '1.0.0' });
|
|
70
|
+
const deps = { dependencies: { 'prod-only': 'file:../../vendor/prod-only' }, devDependencies: { 'dev-only': 'file:../../vendor/dev-only' } };
|
|
71
|
+
write('apps/lib/package.json', { name: '@t/lib', version: '1.0.0', ...deps });
|
|
72
|
+
write('apps/a/package.json', { name: '@t/a', version: '1.0.0', ...deps, dependencies: { ...deps.dependencies, '@t/lib': 'workspace:*' } });
|
|
73
|
+
write('apps/other/package.json', { name: '@t/other', version: '1.0.0', devDependencies: deps.devDependencies });
|
|
74
|
+
execFileSync('pnpm', ['install', '--lockfile-only', '--offline'], { cwd: treeRoot, stdio: 'ignore' });
|
|
75
|
+
|
|
76
|
+
// CI=true as on the runner, where pnpm keeps a modules directory it would otherwise refuse to reuse without a TTY.
|
|
77
|
+
const exec = (file, args, { cwd }) => execFileSync(file, args, { cwd, stdio: 'pipe', env: { ...process.env, CI: 'true' } });
|
|
78
|
+
installClosure({ treeRoot, filters: ['--filter', '@t/a...'], arch: 'arm64', exec });
|
|
79
|
+
|
|
80
|
+
const entries = fs.readdirSync(path.join(treeRoot, 'node_modules', '.pnpm'));
|
|
81
|
+
assert.ok(entries.some((entry) => entry.startsWith('prod-only@')), `the prod dependency is installed: ${entries}`);
|
|
82
|
+
assert.ok(!entries.some((entry) => entry.startsWith('dev-only@')), `no dev-only package is installed: ${entries}`);
|
|
83
|
+
assert.match(fs.readFileSync(path.join(treeRoot, 'pnpm-lock.yaml'), 'utf8'), /injectWorkspacePackages: true/, 'the tree holds the re-resolved lockfile');
|
|
84
|
+
} finally {
|
|
85
|
+
fs.rmSync(treeRoot, { recursive: true, force: true });
|
|
86
|
+
}
|
|
41
87
|
});
|
|
@@ -1,18 +1,19 @@
|
|
|
1
1
|
// `xema-composed-image prisma-check`, INSIDE the image build, on the TARGET arch.
|
|
2
2
|
// Every member that owns a Prisma schema carries its generated client(s) in
|
|
3
3
|
// `<member>/node_modules/.prisma/<client>/`, generated on the CI runner and staged
|
|
4
|
-
// without running anything
|
|
4
|
+
// without running anything; non-client generator outputs there (the org-erasure graph)
|
|
5
|
+
// must load and export their names. Each client's schema must be the member's own schema as
|
|
5
6
|
// `prisma format` writes it, or the client is stale. The image carries no native engine: the only supported
|
|
6
7
|
// client is Rust-free (a wasm query compiler plus a driver adapter). This refuses any
|
|
7
8
|
// native engine file, compiles the wasm, checks the client matches the member's
|
|
8
9
|
// `prisma` CLI, and constructs it. It never connects.
|
|
9
10
|
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
10
11
|
import { createRequire } from 'node:module';
|
|
11
|
-
import { join } from 'node:path';
|
|
12
|
+
import { basename, join } from 'node:path';
|
|
12
13
|
|
|
13
14
|
import { COMPOSED_IMAGE_DIR } from './composed-image-paths.mjs';
|
|
14
15
|
import { readMembers } from './composed-image-members.mjs';
|
|
15
|
-
import { clientSchemaProblems, memberPrismaSchema } from './composed-migrate.mjs';
|
|
16
|
+
import { clientSchemaProblems, memberPrismaOutputs, memberPrismaSchema } from './composed-migrate.mjs';
|
|
16
17
|
|
|
17
18
|
const NATIVE_ENGINE = [/^libquery_engine-.*\.node$/, /^query-engine-/, /\.so\.node$/, /\.dll\.node$/, /\.dylib\.node$/];
|
|
18
19
|
const WASM_COMPILER = /^query_compiler.*_bg\.wasm$/;
|
|
@@ -35,16 +36,14 @@ function cliVersion(memberRequire, memberDirectory) {
|
|
|
35
36
|
async function schemaVerdicts({ root, imageDir, formatSpawn, concurrency }) {
|
|
36
37
|
const jobs = [];
|
|
37
38
|
for (const [, directory] of readMembers({ root, imageDir })) {
|
|
39
|
+
let schema;
|
|
38
40
|
try {
|
|
39
|
-
|
|
41
|
+
schema = memberPrismaSchema(root, directory);
|
|
40
42
|
} catch {
|
|
41
43
|
continue; // reported by the main pass
|
|
42
44
|
}
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
for (const entry of readdirSync(container, { withFileTypes: true }).filter((e) => e.isDirectory())) {
|
|
46
|
-
jobs.push({ root, memberDir: directory, clientDir: join(container, entry.name) });
|
|
47
|
-
}
|
|
45
|
+
if (schema === undefined) continue;
|
|
46
|
+
for (const clientDir of memberPrismaOutputs(root, directory, schema).clients) jobs.push({ root, memberDir: directory, clientDir });
|
|
48
47
|
}
|
|
49
48
|
const verdicts = await clientSchemaProblems(jobs, { spawn: formatSpawn, concurrency });
|
|
50
49
|
return new Map(jobs.map((job, i) => [job.clientDir, verdicts[i]]));
|
|
@@ -75,10 +74,22 @@ export async function prismaCheck({ root = process.cwd(), imageDir = COMPOSED_IM
|
|
|
75
74
|
continue;
|
|
76
75
|
}
|
|
77
76
|
const memberRequire = createRequire(join(memberDirectory, 'package.json'));
|
|
78
|
-
|
|
77
|
+
const outputs = memberPrismaOutputs(root, directory, schema);
|
|
78
|
+
for (const problem of outputs.problems) problems.push(`${key}: ${problem}`);
|
|
79
|
+
// A non-client artifact (e.g. the org-erasure graph) is imported at boot: it must load and export its names.
|
|
80
|
+
for (const { dir, kind, exports } of outputs.artifacts) {
|
|
81
|
+
const label = `${key} (.prisma/${basename(dir)})`;
|
|
82
|
+
try {
|
|
83
|
+
const module = memberRequire(dir);
|
|
84
|
+
const absent = exports.filter((name) => module?.[name] === undefined);
|
|
85
|
+
if (absent.length > 0) problems.push(`${label}: the ${kind} does not export ${absent.join(', ')}`);
|
|
86
|
+
} catch (error) {
|
|
87
|
+
problems.push(`${label}: the ${kind} does not load: ${error?.message ?? String(error)}`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
for (const clientDirectory of outputs.clients) {
|
|
79
91
|
clients += 1;
|
|
80
|
-
const label = `${key} (.prisma/${
|
|
81
|
-
const clientDirectory = join(container, name);
|
|
92
|
+
const label = `${key} (.prisma/${basename(clientDirectory)})`;
|
|
82
93
|
const staleProblem = stale.get(clientDirectory);
|
|
83
94
|
if (staleProblem !== undefined) {
|
|
84
95
|
problems.push(`${label}: ${staleProblem}`);
|
|
@@ -38,3 +38,21 @@ test('a stale client (the source gained a field after generation) is refused; th
|
|
|
38
38
|
await assert.rejects(prismaCheck({ root: stale, log() {}, formatSpawn: formatNoop }), /differs from the member's prisma\/schema\.prisma .*stale/);
|
|
39
39
|
await assert.rejects(prismaCheck({ root: workspace(), log() {}, formatSpawn: formatNoop }), (e) => !/stale/.test(e.message));
|
|
40
40
|
});
|
|
41
|
+
|
|
42
|
+
test('an org-erasure graph is not treated as a client: it must load and export orgErasureGraph', async () => {
|
|
43
|
+
const schema =
|
|
44
|
+
'generator client {\n provider = "prisma-client-js"\n output = "../node_modules/.prisma/alpha-db"\n}\n\n' +
|
|
45
|
+
'generator orgErasureGraph {\n provider = "xema-org-erasure-graph"\n output = "../node_modules/.prisma/alpha-db-org-erasure-graph"\n}\n\ndatasource db {}\n';
|
|
46
|
+
const graph = (root, js) => {
|
|
47
|
+
write(root, 'biomes/alpha/api/alpha-db-api/prisma/schema.prisma', schema);
|
|
48
|
+
write(root, 'biomes/alpha/api/alpha-db-api/node_modules/.prisma/alpha-db/schema.prisma', schema);
|
|
49
|
+
write(root, 'biomes/alpha/api/alpha-db-api/node_modules/.prisma/alpha-db-org-erasure-graph/index.js', js);
|
|
50
|
+
write(root, 'biomes/alpha/api/alpha-db-api/node_modules/.prisma/alpha-db-org-erasure-graph/index.d.ts', '');
|
|
51
|
+
return root;
|
|
52
|
+
};
|
|
53
|
+
// The fixture client has no wasm compiler, so the check fails, but only on the client, never on the graph.
|
|
54
|
+
await assert.rejects(prismaCheck({ root: graph(workspace(), 'exports.orgErasureGraph = {};\n'), log() {}, formatSpawn: formatNoop }), (e) =>
|
|
55
|
+
/\.prisma\/alpha-db\): no query_compiler/.test(e.message) && !/org-erasure-graph/.test(e.message));
|
|
56
|
+
await assert.rejects(prismaCheck({ root: graph(workspace(), 'exports.other = 1;\n'), log() {}, formatSpawn: formatNoop }),
|
|
57
|
+
/\.prisma\/alpha-db-org-erasure-graph\): the org-erasure graph does not export orgErasureGraph/);
|
|
58
|
+
});
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
//
|
|
6
6
|
// <dir>/out/<package dir>/<output>/ every closure package's outputs (default: dist);
|
|
7
7
|
// `--also <pkg>` adds that package's closure (no member checks)
|
|
8
|
-
// <dir>/out/<pkg dir>/node_modules/.prisma/ the generated Prisma clients of every closure root that has
|
|
8
|
+
// <dir>/out/<pkg dir>/node_modules/.prisma/ the generated Prisma clients (and artifacts such as the org-erasure graph) of every closure root that has
|
|
9
9
|
// `prisma/schema.prisma` (members and `--also` roots alike)
|
|
10
10
|
// <dir>/tool/ this package (package.json + src, no tests):
|
|
11
11
|
// bind-mounted into the build, never copied into a layer
|
|
@@ -18,7 +18,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
18
18
|
|
|
19
19
|
import { COMPOSED_IMAGE_DIR, NODE_MAJOR_LABEL } from './composed-image-paths.mjs';
|
|
20
20
|
import { memberClosure, readMembers } from './composed-image-members.mjs';
|
|
21
|
-
import { clientSchemaProblems, memberPrismaSchema } from './composed-migrate.mjs';
|
|
21
|
+
import { clientSchemaProblems, memberPrismaOutputs, memberPrismaSchema } from './composed-migrate.mjs';
|
|
22
22
|
|
|
23
23
|
export const DEFAULT_OUTPUTS = Object.freeze(['dist']);
|
|
24
24
|
const TOOL_PACKAGE = join(dirname(fileURLToPath(import.meta.url)), '..');
|
|
@@ -114,9 +114,11 @@ export async function planBuildOutputs({
|
|
|
114
114
|
const hasClient = existsSync(clients) && readdirSync(clients, { withFileTypes: true }).some((e) => e.isDirectory());
|
|
115
115
|
if (schema !== undefined && !hasClient) problems.push(`${key}: ${directory} has prisma/schema.prisma but no node_modules/.prisma/<client>/ — run prisma generate`);
|
|
116
116
|
if (schema !== undefined && hasClient) {
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
117
|
+
// Each folder by what generated it: clients are checked against the schema, other artifacts
|
|
118
|
+
// (e.g. the org-erasure graph the process imports at boot) must be present and complete.
|
|
119
|
+
const prismaOutputs = memberPrismaOutputs(root, directory, schema);
|
|
120
|
+
for (const problem of prismaOutputs.problems) problems.push(`${key}: ${problem}`);
|
|
121
|
+
for (const clientDir of prismaOutputs.clients) schemaJobs.push({ key, root, memberDir: directory, clientDir });
|
|
120
122
|
}
|
|
121
123
|
if (schema === undefined && hasClient) problems.push(`${key}: ${directory} ships node_modules/.prisma/<client>/ but has no prisma/schema.prisma`);
|
|
122
124
|
}
|
|
@@ -173,3 +173,44 @@ test('--also refuses a non-member closure root with prisma/schema.prisma but no
|
|
|
173
173
|
await assert.rejects(stage(root, { exec: alsoPnpm(root, EXTRA), also: ['@t/extra'] }), /packages\/sdks\/extra has prisma\/schema\.prisma but no generated client: run prisma generate/);
|
|
174
174
|
assert.equal(fs.existsSync(path.join(root, '.xema-stage')), false);
|
|
175
175
|
});
|
|
176
|
+
|
|
177
|
+
const GRAPH_SCHEMA =
|
|
178
|
+
'generator client {\n provider = "prisma-client-js"\n output = "../node_modules/.prisma/alpha-db"\n}\n\n' +
|
|
179
|
+
'generator orgErasureGraph {\n provider = "xema-org-erasure-graph"\n output = "../node_modules/.prisma/alpha-db-org-erasure-graph"\n}\n\ndatasource db {}\n';
|
|
180
|
+
|
|
181
|
+
function withErasureGraph() {
|
|
182
|
+
const root = ready();
|
|
183
|
+
write(root, 'biomes/alpha/api/alpha-db-api/prisma/schema.prisma', GRAPH_SCHEMA);
|
|
184
|
+
write(root, 'biomes/alpha/api/alpha-db-api/node_modules/.prisma/alpha-db/schema.prisma', GRAPH_SCHEMA);
|
|
185
|
+
write(root, 'biomes/alpha/api/alpha-db-api/node_modules/.prisma/alpha-db-org-erasure-graph/index.js', 'exports.orgErasureGraph = {};\n');
|
|
186
|
+
write(root, 'biomes/alpha/api/alpha-db-api/node_modules/.prisma/alpha-db-org-erasure-graph/index.d.ts', 'export declare const orgErasureGraph: unknown;\n');
|
|
187
|
+
return root;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
test('stages a member with a Prisma client and an org-erasure graph, carrying the graph', async () => {
|
|
191
|
+
const root = withErasureGraph();
|
|
192
|
+
await stage(root);
|
|
193
|
+
const staged = files(path.join(root, '.xema-stage/out'));
|
|
194
|
+
assert.ok(staged.includes('biomes/alpha/api/alpha-db-api/node_modules/.prisma/alpha-db-org-erasure-graph/index.js'));
|
|
195
|
+
assert.ok(staged.includes('biomes/alpha/api/alpha-db-api/node_modules/.prisma/alpha-db/index.js'));
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test('a client without its schema still fails beside an org-erasure graph; a missing or incomplete graph fails clearly', async () => {
|
|
199
|
+
const noClientSchema = withErasureGraph();
|
|
200
|
+
fs.rmSync(path.join(noClientSchema, 'biomes/alpha/api/alpha-db-api/node_modules/.prisma/alpha-db/schema.prisma'));
|
|
201
|
+
await assert.rejects(stage(noClientSchema), (e) => /\.prisma\/alpha-db: carries no schema\.prisma/.test(e.message) && !/org-erasure-graph/.test(e.message));
|
|
202
|
+
const missing = withErasureGraph();
|
|
203
|
+
fs.rmSync(path.join(missing, 'biomes/alpha/api/alpha-db-api/node_modules/.prisma/alpha-db-org-erasure-graph'), { recursive: true });
|
|
204
|
+
await assert.rejects(stage(missing), /generator orgErasureGraph \(provider "xema-org-erasure-graph"\): node_modules\/\.prisma\/alpha-db-org-erasure-graph is missing — run prisma generate/);
|
|
205
|
+
const incomplete = withErasureGraph();
|
|
206
|
+
fs.rmSync(path.join(incomplete, 'biomes/alpha/api/alpha-db-api/node_modules/.prisma/alpha-db-org-erasure-graph/index.d.ts'));
|
|
207
|
+
await assert.rejects(stage(incomplete), /the org-erasure graph node_modules\/\.prisma\/alpha-db-org-erasure-graph lacks index\.d\.ts/);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test('refuses a generator of an unknown provider writing into node_modules/.prisma', async () => {
|
|
211
|
+
const root = withErasureGraph();
|
|
212
|
+
const schema = `${GRAPH_SCHEMA}generator other {\n provider = "some-generator"\n output = "../node_modules/.prisma/alpha-db-other"\n}\n`;
|
|
213
|
+
write(root, 'biomes/alpha/api/alpha-db-api/prisma/schema.prisma', schema);
|
|
214
|
+
write(root, 'biomes/alpha/api/alpha-db-api/node_modules/.prisma/alpha-db/schema.prisma', schema);
|
|
215
|
+
await assert.rejects(stage(root), /generator other \(provider "some-generator"\) writes node_modules\/\.prisma\/alpha-db-other, a kind the composed image does not know/);
|
|
216
|
+
});
|
package/src/composed-migrate.mjs
CHANGED
|
@@ -11,9 +11,9 @@
|
|
|
11
11
|
// platform migration runner drops it the same way). Exits with the child's code.
|
|
12
12
|
// This file is also the single home of `memberPrismaSchema`, which the build tool imports.
|
|
13
13
|
import { spawn as spawnAsync, spawnSync } from 'node:child_process';
|
|
14
|
-
import { copyFileSync, existsSync, mkdtempSync, readFileSync, realpathSync, rmSync } from 'node:fs';
|
|
14
|
+
import { copyFileSync, existsSync, mkdtempSync, readFileSync, readdirSync, realpathSync, rmSync } from 'node:fs';
|
|
15
15
|
import { availableParallelism, tmpdir } from 'node:os';
|
|
16
|
-
import { dirname, join, resolve } from 'node:path';
|
|
16
|
+
import { basename, dirname, join, resolve } from 'node:path';
|
|
17
17
|
import { pathToFileURL } from 'node:url';
|
|
18
18
|
|
|
19
19
|
/**
|
|
@@ -42,6 +42,75 @@ export function memberPrismaSchema(root, memberDir) {
|
|
|
42
42
|
return existsSync(schema) ? schema : undefined;
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
+
/**
|
|
46
|
+
* Generators that write into `node_modules/.prisma/` something that is NOT a Prisma client,
|
|
47
|
+
* keyed by provider: what the output is, the files it must carry and the names its module
|
|
48
|
+
* must export. The process imports these at boot, so a missing one is a stage failure.
|
|
49
|
+
*/
|
|
50
|
+
export const PRISMA_ARTIFACT_GENERATORS = Object.freeze({
|
|
51
|
+
'xema-org-erasure-graph': Object.freeze({ kind: 'org-erasure graph', files: ['index.js', 'index.d.ts'], exports: ['orgErasureGraph'] }),
|
|
52
|
+
});
|
|
53
|
+
const PRISMA_CLIENT_PROVIDERS = new Set(['prisma-client-js', 'prisma-client']);
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The schema's generator blocks: `[{ name, provider, output }]`. `output` is the absolute
|
|
57
|
+
* directory (resolved against the schema's directory, as Prisma does), or undefined when the
|
|
58
|
+
* block sets none or not as a string literal (e.g. `env(...)`).
|
|
59
|
+
*/
|
|
60
|
+
export function schemaGenerators(schemaPath) {
|
|
61
|
+
const text = readFileSync(schemaPath, 'utf8').replace(/^\s*\/\/.*$/gm, '');
|
|
62
|
+
const generators = [];
|
|
63
|
+
for (const block of text.matchAll(/^\s*generator\s+(\w+)\s*\{([^}]*)\}/gm)) {
|
|
64
|
+
const provider = /^\s*provider\s*=\s*"([^"]*)"/m.exec(block[2])?.[1];
|
|
65
|
+
const output = /^\s*output\s*=\s*"([^"]*)"/m.exec(block[2])?.[1];
|
|
66
|
+
generators.push({ name: block[1], provider, output: output === undefined ? undefined : resolve(dirname(schemaPath), output) });
|
|
67
|
+
}
|
|
68
|
+
return generators;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Sort a member's `node_modules/.prisma/<dir>/` folders by the generator that wrote them, read
|
|
73
|
+
* from the member's schema: `{ clients, artifacts, problems }`. A folder a client generator
|
|
74
|
+
* declares, or one no generator declares, is a Prisma client (`clients`: absolute dirs, to be
|
|
75
|
+
* checked against the schema). A folder a `PRISMA_ARTIFACT_GENERATORS` provider declares is that
|
|
76
|
+
* artifact (`artifacts`: `{ dir, provider, kind, exports }`) and must carry its files. A declared
|
|
77
|
+
* output that is absent, or a generator of an unknown provider writing into `.prisma/`, is a problem.
|
|
78
|
+
*/
|
|
79
|
+
export function memberPrismaOutputs(root, memberDir, schemaPath) {
|
|
80
|
+
const container = join(resolve(root, memberDir), 'node_modules', '.prisma');
|
|
81
|
+
const present = new Set(
|
|
82
|
+
existsSync(container) ? readdirSync(container, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => join(container, e.name)) : [],
|
|
83
|
+
);
|
|
84
|
+
const declared = new Map(); // dir -> provider
|
|
85
|
+
const problems = [];
|
|
86
|
+
for (const { name, provider, output } of schemaGenerators(schemaPath)) {
|
|
87
|
+
if (output === undefined || dirname(output) !== container) continue;
|
|
88
|
+
const label = `${memberDir}: generator ${name} (provider "${provider}")`;
|
|
89
|
+
if (!PRISMA_CLIENT_PROVIDERS.has(provider) && PRISMA_ARTIFACT_GENERATORS[provider] === undefined) {
|
|
90
|
+
problems.push(`${label} writes node_modules/.prisma/${basename(output)}, a kind the composed image does not know`);
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
declared.set(output, provider);
|
|
94
|
+
if (!present.has(output)) problems.push(`${label}: node_modules/.prisma/${basename(output)} is missing — run prisma generate`);
|
|
95
|
+
}
|
|
96
|
+
const clients = [];
|
|
97
|
+
const artifacts = [];
|
|
98
|
+
for (const dir of [...present].sort()) {
|
|
99
|
+
const spec = PRISMA_ARTIFACT_GENERATORS[declared.get(dir)];
|
|
100
|
+
if (spec === undefined) {
|
|
101
|
+
clients.push(dir);
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
const missing = spec.files.filter((file) => !existsSync(join(dir, file)));
|
|
105
|
+
if (missing.length > 0) {
|
|
106
|
+
problems.push(`${memberDir}: the ${spec.kind} node_modules/.prisma/${basename(dir)} lacks ${missing.join(', ')} — run prisma generate`);
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
artifacts.push({ dir, provider: declared.get(dir), kind: spec.kind, exports: spec.exports });
|
|
110
|
+
}
|
|
111
|
+
return { clients, artifacts, problems };
|
|
112
|
+
}
|
|
113
|
+
|
|
45
114
|
/** Async process run with collected output: `{ status, stdout, stderr }` or `{ error }`. */
|
|
46
115
|
function runAsync(command, args, options) {
|
|
47
116
|
return new Promise((done) => {
|