@xemahq/repo-build-tooling 0.10.2 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/package.json +3 -3
- package/src/composed-image-dockerfile.mjs +4 -4
- package/src/composed-image-dockerfile.test.mjs +6 -0
- package/src/composed-image-fixture.test.mjs +55 -0
- package/src/composed-image-members.mjs +108 -0
- package/src/composed-image-prebuilt-dockerfile.mjs +220 -0
- package/src/composed-image-prebuilt-dockerfile.test.mjs +116 -0
- package/src/composed-image-prisma-check.mjs +127 -0
- package/src/composed-image-prisma-check.test.mjs +40 -0
- package/src/composed-image-stage.mjs +148 -0
- package/src/composed-image-stage.test.mjs +153 -0
- package/src/composed-image-sync-injected.mjs +91 -0
- package/src/composed-image-sync-injected.test.mjs +43 -0
- package/src/composed-image.mjs +104 -51
- package/src/composed-migrate.mjs +147 -0
- package/src/composed-migrate.test.mjs +102 -0
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// `xema-composed-image prisma-check`, INSIDE the image build, on the TARGET arch.
|
|
2
|
+
// Every member that owns a Prisma schema carries its generated client(s) in
|
|
3
|
+
// `<member>/node_modules/.prisma/<client>/`, generated on the CI runner and staged
|
|
4
|
+
// without running anything. Each client's schema must be the member's own schema as
|
|
5
|
+
// `prisma format` writes it, or the client is stale. The image carries no native engine: the only supported
|
|
6
|
+
// client is Rust-free (a wasm query compiler plus a driver adapter). This refuses any
|
|
7
|
+
// native engine file, compiles the wasm, checks the client matches the member's
|
|
8
|
+
// `prisma` CLI, and constructs it. It never connects.
|
|
9
|
+
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
10
|
+
import { createRequire } from 'node:module';
|
|
11
|
+
import { join } from 'node:path';
|
|
12
|
+
|
|
13
|
+
import { COMPOSED_IMAGE_DIR } from './composed-image-paths.mjs';
|
|
14
|
+
import { readMembers } from './composed-image-members.mjs';
|
|
15
|
+
import { clientSchemaProblems, memberPrismaSchema } from './composed-migrate.mjs';
|
|
16
|
+
|
|
17
|
+
const NATIVE_ENGINE = [/^libquery_engine-.*\.node$/, /^query-engine-/, /\.so\.node$/, /\.dll\.node$/, /\.dylib\.node$/];
|
|
18
|
+
const WASM_COMPILER = /^query_compiler.*_bg\.wasm$/;
|
|
19
|
+
|
|
20
|
+
/** Split a client directory's file names into native engine files (refused) and wasm query compilers (required). */
|
|
21
|
+
export function classifyClientFiles(files) {
|
|
22
|
+
return {
|
|
23
|
+
native: files.filter((file) => NATIVE_ENGINE.some((pattern) => pattern.test(file))).sort(),
|
|
24
|
+
wasm: files.filter((file) => WASM_COMPILER.test(file)).sort(),
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function cliVersion(memberRequire, memberDirectory) {
|
|
29
|
+
const direct = join(memberDirectory, 'node_modules', 'prisma', 'package.json');
|
|
30
|
+
const path = existsSync(direct) ? direct : memberRequire.resolve('prisma/package.json');
|
|
31
|
+
return JSON.parse(readFileSync(path, 'utf8')).version;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Every client's schema verdict, the formats run concurrently: `Map<clientDir, problem|undefined>`. */
|
|
35
|
+
async function schemaVerdicts({ root, imageDir, formatSpawn, concurrency }) {
|
|
36
|
+
const jobs = [];
|
|
37
|
+
for (const [, directory] of readMembers({ root, imageDir })) {
|
|
38
|
+
try {
|
|
39
|
+
if (memberPrismaSchema(root, directory) === undefined) continue;
|
|
40
|
+
} catch {
|
|
41
|
+
continue; // reported by the main pass
|
|
42
|
+
}
|
|
43
|
+
const container = join(root, directory, 'node_modules', '.prisma');
|
|
44
|
+
if (!existsSync(container)) continue;
|
|
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
|
+
}
|
|
48
|
+
}
|
|
49
|
+
const verdicts = await clientSchemaProblems(jobs, { spawn: formatSpawn, concurrency });
|
|
50
|
+
return new Map(jobs.map((job, i) => [job.clientDir, verdicts[i]]));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function prismaCheck({ root = process.cwd(), imageDir = COMPOSED_IMAGE_DIR, log = console.log, formatSpawn, concurrency } = {}) {
|
|
54
|
+
const problems = [];
|
|
55
|
+
const stale = await schemaVerdicts({ root, imageDir, formatSpawn, concurrency });
|
|
56
|
+
let clients = 0;
|
|
57
|
+
for (const [key, directory] of readMembers({ root, imageDir })) {
|
|
58
|
+
const memberDirectory = join(root, directory);
|
|
59
|
+
let schema;
|
|
60
|
+
try {
|
|
61
|
+
schema = memberPrismaSchema(root, directory);
|
|
62
|
+
} catch (error) {
|
|
63
|
+
problems.push(`${key}: ${error.message}`);
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
const container = join(memberDirectory, 'node_modules', '.prisma');
|
|
67
|
+
const dirs = existsSync(container) ? readdirSync(container, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name).sort() : [];
|
|
68
|
+
if (schema === undefined && dirs.length === 0) continue;
|
|
69
|
+
if (schema === undefined) {
|
|
70
|
+
problems.push(`${key}: ships node_modules/.prisma/${dirs.join(', ')} but has no prisma/schema.prisma`);
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
if (dirs.length === 0) {
|
|
74
|
+
problems.push(`${key}: ${directory}/node_modules/.prisma has no generated client`);
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
const memberRequire = createRequire(join(memberDirectory, 'package.json'));
|
|
78
|
+
for (const name of dirs) {
|
|
79
|
+
clients += 1;
|
|
80
|
+
const label = `${key} (.prisma/${name})`;
|
|
81
|
+
const clientDirectory = join(container, name);
|
|
82
|
+
const staleProblem = stale.get(clientDirectory);
|
|
83
|
+
if (staleProblem !== undefined) {
|
|
84
|
+
problems.push(`${label}: ${staleProblem}`);
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
const { native, wasm } = classifyClientFiles(readdirSync(clientDirectory));
|
|
88
|
+
if (native.length > 0) {
|
|
89
|
+
problems.push(`${label}: native engine file(s) ${native.join(', ')} — the image carries no native engine; generate a Rust-free client`);
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
if (wasm.length === 0) {
|
|
93
|
+
problems.push(`${label}: no query_compiler*_bg.wasm — not a Rust-free client`);
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
for (const file of wasm) {
|
|
97
|
+
try {
|
|
98
|
+
await WebAssembly.compile(readFileSync(join(clientDirectory, file)));
|
|
99
|
+
} catch (error) {
|
|
100
|
+
problems.push(`${label}: ${file} does not compile: ${error?.message ?? String(error)}`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
try {
|
|
104
|
+
const client = memberRequire(clientDirectory);
|
|
105
|
+
const generated = client.Prisma?.prismaVersion?.client;
|
|
106
|
+
const cli = cliVersion(memberRequire, memberDirectory);
|
|
107
|
+
if (generated !== cli) problems.push(`${label}: generated client is ${generated}, the member's prisma CLI is ${cli}`);
|
|
108
|
+
let PrismaPg;
|
|
109
|
+
try {
|
|
110
|
+
({ PrismaPg } = memberRequire('@prisma/adapter-pg'));
|
|
111
|
+
} catch (error) {
|
|
112
|
+
problems.push(`${label}: Rust-free client but the member cannot resolve @prisma/adapter-pg (${error?.message ?? error})`);
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
const instance = new client.PrismaClient({
|
|
116
|
+
adapter: new PrismaPg({ connectionString: 'postgresql://prisma-check@127.0.0.1:1/prisma-check' }),
|
|
117
|
+
});
|
|
118
|
+
await instance.$disconnect();
|
|
119
|
+
} catch (error) {
|
|
120
|
+
problems.push(`${label}: the client does not construct: ${error?.message ?? String(error)}`);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
if (problems.length > 0) throw new Error(`prisma clients cannot run in this image:\n${problems.map((p) => ` - ${p}`).join('\n')}`);
|
|
125
|
+
log(`composed prisma-check: ${clients} generated client(s) are Rust-free and construct on ${process.platform}/${process.arch}`);
|
|
126
|
+
return clients;
|
|
127
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import test from 'node:test';
|
|
3
|
+
|
|
4
|
+
import { classifyClientFiles, prismaCheck } from './composed-image-prisma-check.mjs';
|
|
5
|
+
import { formatNoop, workspace, write } from './composed-image-fixture.test.mjs';
|
|
6
|
+
|
|
7
|
+
test('native engine files are separated from wasm query compilers', () => {
|
|
8
|
+
assert.deepEqual(classifyClientFiles(['index.js', 'query_compiler_fast_bg.wasm', 'query_compiler_fast_bg.js']), { native: [], wasm: ['query_compiler_fast_bg.wasm'] });
|
|
9
|
+
for (const native of ['libquery_engine-debian-openssl-3.0.x.so.node', 'query-engine-debian-openssl-3.0.x', 'a.dll.node', 'a.dylib.node']) {
|
|
10
|
+
assert.deepEqual(classifyClientFiles([native, 'index.js']).native, [native]);
|
|
11
|
+
}
|
|
12
|
+
assert.deepEqual(classifyClientFiles(['index.js']), { native: [], wasm: [] });
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test('any native engine file is refused outright, as is a client with no wasm compiler', async () => {
|
|
16
|
+
const root = workspace();
|
|
17
|
+
write(root, 'biomes/alpha/api/alpha-db-api/node_modules/.prisma/alpha-db/libquery_engine-debian-openssl-3.0.x.so.node', 'x');
|
|
18
|
+
await assert.rejects(prismaCheck({ root, log() {}, formatSpawn: formatNoop }), /native engine file\(s\) libquery_engine-debian-openssl-3\.0\.x\.so\.node/);
|
|
19
|
+
const bare = workspace();
|
|
20
|
+
await assert.rejects(prismaCheck({ root: bare, log() {}, formatSpawn: formatNoop }), /no query_compiler\*_bg\.wasm/);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test('a shipped client with no schema, a schema directory and a non-default config path are refused', async () => {
|
|
24
|
+
const noSchema = workspace();
|
|
25
|
+
write(noSchema, 'biomes/alpha/api/alpha-api/node_modules/.prisma/alpha-api/index.js', '//');
|
|
26
|
+
await assert.rejects(prismaCheck({ root: noSchema, log() {}, formatSpawn: formatNoop }), /alpha\/alpha-api: ships node_modules\/\.prisma\/alpha-api but has no prisma\/schema\.prisma/);
|
|
27
|
+
const dir = workspace();
|
|
28
|
+
write(dir, 'biomes/alpha/api/alpha-db-api/prisma/schema/a.prisma', '');
|
|
29
|
+
await assert.rejects(prismaCheck({ root: dir, log() {}, formatSpawn: formatNoop }), /multi-file schema/);
|
|
30
|
+
const config = workspace();
|
|
31
|
+
write(config, 'biomes/alpha/api/alpha-db-api/prisma.config.ts', "export default { schema: 'db/schema.prisma' };\n");
|
|
32
|
+
await assert.rejects(prismaCheck({ root: config, log() {}, formatSpawn: formatNoop }), /supports only "prisma\/schema\.prisma"/);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('a stale client (the source gained a field after generation) is refused; the matching one gets past the schema check', async () => {
|
|
36
|
+
const stale = workspace();
|
|
37
|
+
write(stale, 'biomes/alpha/api/alpha-db-api/prisma/schema.prisma', 'datasource db {}\nmodel A { id Int @id }\n');
|
|
38
|
+
await assert.rejects(prismaCheck({ root: stale, log() {}, formatSpawn: formatNoop }), /differs from the member's prisma\/schema\.prisma .*stale/);
|
|
39
|
+
await assert.rejects(prismaCheck({ root: workspace(), log() {}, formatSpawn: formatNoop }), (e) => !/stale/.test(e.message));
|
|
40
|
+
});
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
// `xema-composed-image stage <dir>`: gathers the build outputs the CI runner made
|
|
2
|
+
// (`turbo run build prisma:generate` over the members' closures) into one build-step
|
|
3
|
+
// directory the `--prebuilt` Dockerfile copies from. Docker compiles nothing; this
|
|
4
|
+
// is the only place outputs enter an image, so it is also where they are refused.
|
|
5
|
+
//
|
|
6
|
+
// <dir>/out/<package dir>/<output>/ every closure package's outputs (default: dist);
|
|
7
|
+
// `--also <pkg>` adds that package's closure (no member checks)
|
|
8
|
+
// <dir>/out/<member>/node_modules/.prisma/ every member's generated Prisma clients
|
|
9
|
+
// <dir>/tool/ this package (package.json + src, no tests):
|
|
10
|
+
// bind-mounted into the build, never copied into a layer
|
|
11
|
+
//
|
|
12
|
+
// Staged outputs must be pure JS/wasm: native code comes only from the arch-native
|
|
13
|
+
// install inside the image. Nothing is written with a timestamp or an unsorted order.
|
|
14
|
+
import { copyFileSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, readlinkSync, symlinkSync } from 'node:fs';
|
|
15
|
+
import { dirname, join, posix } from 'node:path';
|
|
16
|
+
import { fileURLToPath } from 'node:url';
|
|
17
|
+
|
|
18
|
+
import { COMPOSED_IMAGE_DIR } from './composed-image-paths.mjs';
|
|
19
|
+
import { memberClosure, readMembers } from './composed-image-members.mjs';
|
|
20
|
+
import { clientSchemaProblems, memberPrismaSchema } from './composed-migrate.mjs';
|
|
21
|
+
|
|
22
|
+
export const DEFAULT_OUTPUTS = Object.freeze(['dist']);
|
|
23
|
+
const TOOL_PACKAGE = join(dirname(fileURLToPath(import.meta.url)), '..');
|
|
24
|
+
|
|
25
|
+
/** Every file under `dir` (symlinks included, not followed), relative, sorted. */
|
|
26
|
+
function listFiles(dir, prefix = '') {
|
|
27
|
+
const found = [];
|
|
28
|
+
for (const entry of readdirSync(dir, { withFileTypes: true }).sort((a, b) => (a.name < b.name ? -1 : 1))) {
|
|
29
|
+
const rel = prefix === '' ? entry.name : `${prefix}/${entry.name}`;
|
|
30
|
+
if (entry.isDirectory()) found.push(...listFiles(join(dir, entry.name), rel));
|
|
31
|
+
else found.push(rel);
|
|
32
|
+
}
|
|
33
|
+
return found;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function copyFiles(sourceDir, targetDir, files) {
|
|
37
|
+
for (const rel of files) {
|
|
38
|
+
const source = join(sourceDir, rel);
|
|
39
|
+
const target = join(targetDir, rel);
|
|
40
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
41
|
+
if (lstatSync(source).isSymbolicLink()) symlinkSync(readlinkSync(source), target);
|
|
42
|
+
else copyFileSync(source, target);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function renderedDockerfile(root, imageDir) {
|
|
47
|
+
const path = join(root, imageDir, 'Dockerfile');
|
|
48
|
+
if (!existsSync(path)) throw new Error(`${imageDir}/Dockerfile is absent — render it first: xema-composed-image dockerfile --prebuilt --write`);
|
|
49
|
+
return readFileSync(path, 'utf8');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Copy `package.json` + `src/` (no `*.test.mjs`) of this package to `<dir>/tool`. */
|
|
53
|
+
function copyTool(target) {
|
|
54
|
+
const files = ['package.json', ...listFiles(join(TOOL_PACKAGE, 'src')).filter((f) => !f.endsWith('.test.mjs')).map((f) => `src/${f}`)];
|
|
55
|
+
copyFiles(TOOL_PACKAGE, target, files);
|
|
56
|
+
return files.length;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function toolVersion() {
|
|
60
|
+
return JSON.parse(readFileSync(join(TOOL_PACKAGE, 'package.json'), 'utf8')).version;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Stage the closure's outputs under `dir`; returns counts. Throws once, naming every offender. */
|
|
64
|
+
export async function stageBuildOutputs({
|
|
65
|
+
root = process.cwd(),
|
|
66
|
+
imageDir = COMPOSED_IMAGE_DIR,
|
|
67
|
+
dir,
|
|
68
|
+
outputs = DEFAULT_OUTPUTS,
|
|
69
|
+
also = [],
|
|
70
|
+
exec,
|
|
71
|
+
nodeMajor = process.versions.node.split('.')[0],
|
|
72
|
+
formatSpawn,
|
|
73
|
+
concurrency,
|
|
74
|
+
}) {
|
|
75
|
+
if (typeof dir !== 'string' || dir.length === 0) throw new Error('stage needs the build-step directory: stage <dir>');
|
|
76
|
+
if (outputs.length === 0) throw new Error('--output needs at least one directory');
|
|
77
|
+
const target = join(root, dir);
|
|
78
|
+
if (existsSync(target) && readdirSync(target).length > 0) throw new Error(`${dir} already exists and is not empty`);
|
|
79
|
+
|
|
80
|
+
const problems = [];
|
|
81
|
+
const dockerfile = renderedDockerfile(root, imageDir);
|
|
82
|
+
const fromNode = /^FROM\s+\S*node:(\d+)[-@\s]/m.exec(dockerfile);
|
|
83
|
+
if (fromNode === null) problems.push(`${imageDir}/Dockerfile has no base FROM …node:<major>-…`);
|
|
84
|
+
else if (fromNode[1] !== String(nodeMajor)) {
|
|
85
|
+
problems.push(`the runner's Node ${nodeMajor} differs from the image's Node ${fromNode[1]} (${imageDir}/Dockerfile)`);
|
|
86
|
+
}
|
|
87
|
+
const pinned = /@xemahq\/repo-build-tooling@(\d+\.\d+\.\d+)/.exec(dockerfile);
|
|
88
|
+
if (pinned === null) problems.push(`${imageDir}/Dockerfile header pins no @xemahq/repo-build-tooling@<x.y.z>`);
|
|
89
|
+
else if (pinned[1] !== toolVersion()) {
|
|
90
|
+
problems.push(`this tool is ${toolVersion()} but ${imageDir}/Dockerfile pins ${pinned[1]}`);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const members = readMembers({ root, imageDir }).map(([key, directory]) => ({ key, directory }));
|
|
94
|
+
const memberDirectories = new Set(members.map((m) => m.directory));
|
|
95
|
+
const closure = memberClosure({ root, imageDir, exec, also });
|
|
96
|
+
const schemaJobs = [];
|
|
97
|
+
for (const { key, directory } of members) {
|
|
98
|
+
if (!closure.includes(directory)) problems.push(`${key}: ${directory} is not in the closure pnpm selected`);
|
|
99
|
+
if (!existsSync(join(root, directory, 'dist', 'main.js'))) problems.push(`${key}: ${directory}/dist/main.js does not exist — build it first`);
|
|
100
|
+
let schema;
|
|
101
|
+
try {
|
|
102
|
+
schema = memberPrismaSchema(root, directory);
|
|
103
|
+
} catch (error) {
|
|
104
|
+
problems.push(`${key}: ${error.message}`);
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
const clients = join(root, directory, 'node_modules', '.prisma');
|
|
108
|
+
const hasClient = existsSync(clients) && readdirSync(clients, { withFileTypes: true }).some((e) => e.isDirectory());
|
|
109
|
+
if (schema !== undefined && !hasClient) problems.push(`${key}: ${directory} has prisma/schema.prisma but no node_modules/.prisma/<client>/ — run prisma generate`);
|
|
110
|
+
if (schema !== undefined && hasClient) {
|
|
111
|
+
for (const entry of readdirSync(clients, { withFileTypes: true }).filter((e) => e.isDirectory())) {
|
|
112
|
+
schemaJobs.push({ key, root, memberDir: directory, clientDir: join(clients, entry.name) });
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
if (schema === undefined && hasClient) problems.push(`${key}: ${directory} ships node_modules/.prisma/<client>/ but has no prisma/schema.prisma`);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// The formats run concurrently; their problems are reported in member order.
|
|
119
|
+
const verdicts = await clientSchemaProblems(schemaJobs, { spawn: formatSpawn, concurrency });
|
|
120
|
+
verdicts.forEach((problem, i) => {
|
|
121
|
+
if (problem !== undefined) problems.push(`${schemaJobs[i].key}: ${problem}`);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
const plan = []; // { source, destination, files }
|
|
125
|
+
for (const directory of closure) {
|
|
126
|
+
const sources = outputs.map((output) => posix.join(directory, output));
|
|
127
|
+
if (memberDirectories.has(directory)) sources.push(posix.join(directory, 'node_modules', '.prisma'));
|
|
128
|
+
for (const rel of sources) {
|
|
129
|
+
const source = join(root, rel);
|
|
130
|
+
if (!existsSync(source)) continue;
|
|
131
|
+
const files = listFiles(source);
|
|
132
|
+
for (const file of files) if (file.endsWith('.node')) problems.push(`${rel}/${file}: a native addon cannot be staged (the image installs native code itself, per arch)`);
|
|
133
|
+
plan.push({ source, destination: join(target, 'out', rel), files });
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
if (problems.length > 0) {
|
|
137
|
+
throw new Error(`cannot stage ${dir}:\n${problems.map((p) => ` - ${p}`).join('\n')}`);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
mkdirSync(target, { recursive: true });
|
|
141
|
+
let fileCount = 0;
|
|
142
|
+
for (const { source, destination, files } of plan) {
|
|
143
|
+
copyFiles(source, destination, files);
|
|
144
|
+
fileCount += files.length;
|
|
145
|
+
}
|
|
146
|
+
const toolFiles = copyTool(join(target, 'tool'));
|
|
147
|
+
return { packages: closure.length, outputs: plan.length, files: fileCount, toolFiles };
|
|
148
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import test from 'node:test';
|
|
5
|
+
|
|
6
|
+
import { stageBuildOutputs, toolVersion } from './composed-image-stage.mjs';
|
|
7
|
+
import { fakePnpm, formatNoop, workspace, write } from './composed-image-fixture.test.mjs';
|
|
8
|
+
|
|
9
|
+
function ready(extra = {}) {
|
|
10
|
+
const root = workspace(extra);
|
|
11
|
+
write(root, 'infra/composed-process/Dockerfile', `# @generated (@xemahq/repo-build-tooling@${toolVersion()})\nFROM mirror.gcr.io/library/node:22-bookworm-slim@sha256:abc AS base\n`);
|
|
12
|
+
return root;
|
|
13
|
+
}
|
|
14
|
+
const stage = (root, extra = {}) => stageBuildOutputs({ root, dir: '.xema-stage', exec: fakePnpm(root), nodeMajor: '22', formatSpawn: formatNoop, ...extra });
|
|
15
|
+
const files = (dir) => fs.readdirSync(dir, { recursive: true }).map(String).sort();
|
|
16
|
+
|
|
17
|
+
test('stages dist of every closure package, the members prisma clients, and the tool without tests', async () => {
|
|
18
|
+
const root = ready();
|
|
19
|
+
const result = await stage(root);
|
|
20
|
+
assert.equal(result.packages, 3);
|
|
21
|
+
const staged = files(path.join(root, '.xema-stage/out'));
|
|
22
|
+
for (const expected of [
|
|
23
|
+
'biomes/alpha/api/alpha-api/dist/main.js',
|
|
24
|
+
'biomes/alpha/api/alpha-db-api/dist/main.js',
|
|
25
|
+
'biomes/alpha/api/alpha-db-api/node_modules/.prisma/alpha-db/index.js',
|
|
26
|
+
'packages/sdks/lib-a/dist/index.js',
|
|
27
|
+
]) assert.ok(staged.includes(expected), expected);
|
|
28
|
+
const tool = files(path.join(root, '.xema-stage/tool'));
|
|
29
|
+
assert.ok(tool.includes('package.json') && tool.includes('src/composed-image.mjs') && tool.includes('src/composed-migrate.mjs'));
|
|
30
|
+
assert.equal(tool.filter((f) => f.endsWith('.test.mjs')).length, 0);
|
|
31
|
+
assert.deepEqual(files(path.join(root, '.xema-stage')).filter((f) => !/^(out|tool)(\/|$)/.test(f)), []);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('--output replaces the default list', async () => {
|
|
35
|
+
const root = ready();
|
|
36
|
+
write(root, 'packages/sdks/lib-a/generated/x.js', '//\n');
|
|
37
|
+
await stage(root, { outputs: ['generated'] });
|
|
38
|
+
const staged = files(path.join(root, '.xema-stage/out'));
|
|
39
|
+
assert.ok(staged.includes('packages/sdks/lib-a/generated/x.js'));
|
|
40
|
+
assert.ok(!staged.includes('packages/sdks/lib-a/dist/index.js'));
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('refuses a native addon anywhere in the staged outputs, naming it', async () => {
|
|
44
|
+
const root = ready();
|
|
45
|
+
write(root, 'packages/sdks/lib-a/dist/native/addon.node', 'x');
|
|
46
|
+
write(root, 'biomes/alpha/api/alpha-db-api/node_modules/.prisma/alpha-db/libquery_engine-darwin.so.node', 'x');
|
|
47
|
+
await assert.rejects(stage(root), (e) => /lib-a\/dist\/native\/addon\.node/.test(e.message) && /libquery_engine-darwin\.so\.node/.test(e.message));
|
|
48
|
+
assert.equal(fs.existsSync(path.join(root, '.xema-stage')), false);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('refuses a member without dist/main.js and a schema without a generated client', async () => {
|
|
52
|
+
const root = ready();
|
|
53
|
+
fs.rmSync(path.join(root, 'biomes/alpha/api/alpha-api/dist/main.js'));
|
|
54
|
+
fs.rmSync(path.join(root, 'biomes/alpha/api/alpha-db-api/node_modules'), { recursive: true });
|
|
55
|
+
await assert.rejects(stage(root), (e) => /alpha\/alpha-api: .*dist\/main\.js does not exist/.test(e.message) && /alpha-db-api: .*no node_modules\/\.prisma/.test(e.message));
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('refuses a runner Node major that differs from the image base, and an absent Dockerfile or FROM', async () => {
|
|
59
|
+
const root = ready();
|
|
60
|
+
await assert.rejects(stage(root, { nodeMajor: '24' }), /Node 24 differs from the image's Node 22/);
|
|
61
|
+
fs.writeFileSync(path.join(root, 'infra/composed-process/Dockerfile'), `# (@xemahq/repo-build-tooling@${toolVersion()})\nFROM scratch\n`);
|
|
62
|
+
await assert.rejects(stage(root), /no base FROM/);
|
|
63
|
+
fs.rmSync(path.join(root, 'infra/composed-process/Dockerfile'));
|
|
64
|
+
await assert.rejects(stage(root), /Dockerfile is absent/);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('refuses a tool version that differs from the rendered Dockerfile pin', async () => {
|
|
68
|
+
const root = ready();
|
|
69
|
+
write(root, 'infra/composed-process/Dockerfile', '# (@xemahq/repo-build-tooling@0.0.1)\nFROM node:22-slim AS base\n');
|
|
70
|
+
await assert.rejects(stage(root), /pins 0\.0\.1/);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('refuses a non-empty directory and is deterministic', async () => {
|
|
74
|
+
const root = ready();
|
|
75
|
+
write(root, '.xema-stage/old', 'x');
|
|
76
|
+
await assert.rejects(stage(root), /not empty/);
|
|
77
|
+
fs.rmSync(path.join(root, '.xema-stage'), { recursive: true });
|
|
78
|
+
await stage(root);
|
|
79
|
+
const first = files(path.join(root, '.xema-stage'));
|
|
80
|
+
fs.rmSync(path.join(root, '.xema-stage'), { recursive: true });
|
|
81
|
+
await stage(root);
|
|
82
|
+
assert.deepEqual(files(path.join(root, '.xema-stage')), first);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('refuses a shipped client with no schema, a schema directory and a non-default config path', async () => {
|
|
86
|
+
const noSchema = ready();
|
|
87
|
+
write(noSchema, 'biomes/alpha/api/alpha-api/node_modules/.prisma/alpha-api/index.js', '//');
|
|
88
|
+
await assert.rejects(stage(noSchema), /ships node_modules\/\.prisma\/<client>\/ but has no prisma\/schema\.prisma/);
|
|
89
|
+
const dir = ready();
|
|
90
|
+
write(dir, 'biomes/alpha/api/alpha-db-api/prisma/schema/a.prisma', '');
|
|
91
|
+
await assert.rejects(stage(dir), /multi-file schema/);
|
|
92
|
+
const config = ready();
|
|
93
|
+
write(config, 'biomes/alpha/api/alpha-db-api/prisma.config.ts', "export default { schema: 'db/schema.prisma' };\n");
|
|
94
|
+
await assert.rejects(stage(config), /supports only/);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test('refuses a client generated from another schema than the member carries; the matching one stages', async () => {
|
|
98
|
+
const stale = ready();
|
|
99
|
+
write(stale, 'biomes/alpha/api/alpha-db-api/prisma/schema.prisma', 'datasource db {}\nmodel A { id Int @id }\n');
|
|
100
|
+
await assert.rejects(stage(stale), /alpha-db-api: .*differs from the member's prisma\/schema\.prisma .*stale/);
|
|
101
|
+
assert.equal((await stage(ready())).packages, 3);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
/** An `exec` that answers per-name probes (`--filter <name>`) and the closure listing (`--filter <name>...`). */
|
|
105
|
+
function alsoPnpm(root, known) {
|
|
106
|
+
return (_file, args) => {
|
|
107
|
+
const filters = args.flatMap((a, i) => (args[i - 1] === '--filter' ? [a] : []));
|
|
108
|
+
const names = filters.filter((f) => !f.endsWith('...'));
|
|
109
|
+
if (names.length > 0) return JSON.stringify(known.filter((p) => p.name === names[0]).map((p) => ({ name: p.name, path: path.join(root, p.dir) })));
|
|
110
|
+
const wanted = new Set(filters.map((f) => f.slice(0, -3)));
|
|
111
|
+
const dirs = new Set(['biomes/alpha/api/alpha-api', 'biomes/alpha/api/alpha-db-api', 'packages/sdks/lib-a']);
|
|
112
|
+
for (const p of known) if (wanted.has(p.name)) for (const d of p.closure) dirs.add(d);
|
|
113
|
+
return JSON.stringify([...dirs].map((dir) => ({ name: dir, path: path.join(root, dir) })));
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
const EXTRA = [{ name: '@t/extra', dir: 'packages/sdks/extra', closure: ['packages/sdks/extra', 'packages/sdks/extra-dep'] },
|
|
117
|
+
{ name: '@t/lib-a', dir: 'packages/sdks/lib-a', closure: ['packages/sdks/lib-a'] }];
|
|
118
|
+
|
|
119
|
+
function withExtra() {
|
|
120
|
+
const root = ready();
|
|
121
|
+
write(root, 'packages/sdks/extra/package.json', { name: '@t/extra' });
|
|
122
|
+
write(root, 'packages/sdks/extra/dist/index.js', '// extra\n');
|
|
123
|
+
write(root, 'packages/sdks/extra-dep/dist/index.js', '// dep\n');
|
|
124
|
+
return root;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
test('--also stages the named package closure without member checks, and a member named again is idempotent', async () => {
|
|
128
|
+
const root = withExtra();
|
|
129
|
+
const result = await stage(root, { exec: alsoPnpm(root, EXTRA), also: ['@t/extra', '@t/lib-a'] });
|
|
130
|
+
assert.equal(result.packages, 5);
|
|
131
|
+
const staged = files(path.join(root, '.xema-stage/out'));
|
|
132
|
+
for (const expected of ['packages/sdks/extra/dist/index.js', 'packages/sdks/extra-dep/dist/index.js', 'packages/sdks/lib-a/dist/index.js']) {
|
|
133
|
+
assert.ok(staged.includes(expected), expected);
|
|
134
|
+
}
|
|
135
|
+
assert.ok(!staged.some((f) => f.startsWith('packages/sdks/extra/node_modules')));
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test('--also refuses an unknown package and a native addon in the extra closure', async () => {
|
|
139
|
+
const root = withExtra();
|
|
140
|
+
await assert.rejects(stage(root, { exec: alsoPnpm(root, EXTRA), also: ['@t/nope'] }), /--also @t\/nope: no workspace package/);
|
|
141
|
+
assert.equal(fs.existsSync(path.join(root, '.xema-stage')), false);
|
|
142
|
+
write(root, 'packages/sdks/extra-dep/dist/a.node', 'x');
|
|
143
|
+
await assert.rejects(stage(root, { exec: alsoPnpm(root, EXTRA), also: ['@t/extra'] }), /extra-dep\/dist\/a\.node/);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test('filters and the argument parser carry --also', async () => {
|
|
147
|
+
const root = ready();
|
|
148
|
+
const { memberFilters } = await import('./composed-image-members.mjs');
|
|
149
|
+
const { parseArguments } = await import('./composed-image.mjs');
|
|
150
|
+
assert.deepEqual(memberFilters({ root, also: ['@t/extra', '@t/lib-a', '@t/extra'] }).slice(-2), ['--filter @t/extra...', '--filter @t/lib-a...']);
|
|
151
|
+
assert.equal(memberFilters({ root, also: ['@t/alpha-api'] }).filter((f) => f === '--filter @t/alpha-api...').length, 1);
|
|
152
|
+
assert.deepEqual(parseArguments(['stage', 'd', '--also', 'a', '--also', 'b']).options.also, ['a', 'b']);
|
|
153
|
+
});
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// `xema-composed-image sync-injected`, INSIDE the image build, after the source and the
|
|
2
|
+
// staged outputs are copied. The production install ran from manifests only, so each
|
|
3
|
+
// injected workspace copy (`node_modules/.pnpm/<name>@file+…/node_modules/<name>`) holds
|
|
4
|
+
// just what existed then (its package.json). This copies every `files` entry of the
|
|
5
|
+
// workspace package into its injected copy, which is exactly what `pnpm deploy` injects.
|
|
6
|
+
import { cpSync, existsSync, lstatSync, readFileSync, readdirSync, rmSync } from 'node:fs';
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
|
|
9
|
+
import { COMPOSED_IMAGE_DIR } from './composed-image-paths.mjs';
|
|
10
|
+
import { readMembers } from './composed-image-members.mjs';
|
|
11
|
+
|
|
12
|
+
const GLOB = /[*?[\]{}!]/;
|
|
13
|
+
const SKIPPED = new Set(['node_modules', '.git']);
|
|
14
|
+
|
|
15
|
+
/** `name -> repository-relative directory` for every workspace package under `root`. */
|
|
16
|
+
export function workspacePackages(root, prefix = '', found = new Map()) {
|
|
17
|
+
const dir = join(root, prefix);
|
|
18
|
+
const manifestPath = join(dir, 'package.json');
|
|
19
|
+
if (prefix !== '' && existsSync(manifestPath)) {
|
|
20
|
+
const name = JSON.parse(readFileSync(manifestPath, 'utf8')).name;
|
|
21
|
+
if (typeof name === 'string') found.set(name, prefix);
|
|
22
|
+
}
|
|
23
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
24
|
+
if (entry.isDirectory() && !SKIPPED.has(entry.name)) workspacePackages(root, prefix === '' ? entry.name : `${prefix}/${entry.name}`, found);
|
|
25
|
+
}
|
|
26
|
+
return found;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Injected package directories: real directories in a `.pnpm/*@file+*` entry's node_modules. */
|
|
30
|
+
function injectedCopies(root) {
|
|
31
|
+
const store = join(root, 'node_modules', '.pnpm');
|
|
32
|
+
if (!existsSync(store)) return [];
|
|
33
|
+
const copies = [];
|
|
34
|
+
for (const entry of readdirSync(store).sort()) {
|
|
35
|
+
if (!entry.includes('@file+')) continue;
|
|
36
|
+
const modules = join(store, entry, 'node_modules');
|
|
37
|
+
if (!existsSync(modules)) continue;
|
|
38
|
+
for (const name of readdirSync(modules).sort()) {
|
|
39
|
+
const candidates = name.startsWith('@') ? readdirSync(join(modules, name)).sort().map((n) => join(modules, name, n)) : [join(modules, name)];
|
|
40
|
+
for (const candidate of candidates) {
|
|
41
|
+
const stat = lstatSync(candidate);
|
|
42
|
+
if (stat.isDirectory() && existsSync(join(candidate, 'package.json'))) copies.push(candidate);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return copies;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function syncInjected({ root = process.cwd(), imageDir = COMPOSED_IMAGE_DIR, log = console.log } = {}) {
|
|
50
|
+
const workspace = workspacePackages(root);
|
|
51
|
+
const memberDirectories = new Set(readMembers({ root, imageDir }).map(([, directory]) => directory));
|
|
52
|
+
const nonMembers = [...workspace.values()].filter((directory) => !memberDirectories.has(directory));
|
|
53
|
+
const copies = injectedCopies(root);
|
|
54
|
+
const problems = [];
|
|
55
|
+
if (copies.length === 0 && nonMembers.length > 0) {
|
|
56
|
+
problems.push(`no injected workspace copy found under node_modules/.pnpm, but the closure has ${nonMembers.length} non-member package(s)`);
|
|
57
|
+
}
|
|
58
|
+
let synced = 0;
|
|
59
|
+
for (const copy of copies) {
|
|
60
|
+
const name = JSON.parse(readFileSync(join(copy, 'package.json'), 'utf8')).name;
|
|
61
|
+
const directory = workspace.get(name);
|
|
62
|
+
if (directory === undefined) {
|
|
63
|
+
problems.push(`${copy}: injected package "${name}" maps to no workspace package`);
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
const files = JSON.parse(readFileSync(join(root, directory, 'package.json'), 'utf8')).files;
|
|
67
|
+
if (!Array.isArray(files)) {
|
|
68
|
+
problems.push(`${name} (${directory}): no "files" field — nothing says what to inject`);
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
const globs = files.filter((entry) => typeof entry !== 'string' || GLOB.test(entry));
|
|
72
|
+
if (globs.length > 0) {
|
|
73
|
+
problems.push(`${name} (${directory}): "files" has glob or non-string entries (${globs.join(', ')}); list plain paths`);
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
for (const entry of files) {
|
|
77
|
+
const source = join(root, directory, entry);
|
|
78
|
+
if (!existsSync(source)) {
|
|
79
|
+
problems.push(`${name} (${directory}): files entry "${entry}" does not exist`);
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
const target = join(copy, entry);
|
|
83
|
+
rmSync(target, { recursive: true, force: true }); // never write through a hardlink into the store
|
|
84
|
+
cpSync(source, target, { recursive: true, verbatimSymlinks: true });
|
|
85
|
+
}
|
|
86
|
+
synced += 1;
|
|
87
|
+
}
|
|
88
|
+
if (problems.length > 0) throw new Error(`injected workspace copies cannot be synced:\n${problems.map((p) => ` - ${p}`).join('\n')}`);
|
|
89
|
+
log(`composed sync-injected: ${synced} injected workspace cop${synced === 1 ? 'y' : 'ies'} synced`);
|
|
90
|
+
return synced;
|
|
91
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import test from 'node:test';
|
|
5
|
+
|
|
6
|
+
import { syncInjected } from './composed-image-sync-injected.mjs';
|
|
7
|
+
import { workspace, write } from './composed-image-fixture.test.mjs';
|
|
8
|
+
|
|
9
|
+
function injected(root, entry, name, extra = {}) {
|
|
10
|
+
write(root, `node_modules/.pnpm/${entry}/node_modules/${name}/package.json`, { name, ...extra });
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
test('copies every files entry into the injected copy, and nothing else', () => {
|
|
14
|
+
const root = workspace();
|
|
15
|
+
write(root, 'packages/sdks/lib-a/package.json', { name: '@t/lib-a', files: ['dist', 'README.md'] });
|
|
16
|
+
write(root, 'packages/sdks/lib-a/README.md', '# a\n');
|
|
17
|
+
write(root, 'packages/sdks/lib-a/src/secret.ts', '//\n');
|
|
18
|
+
injected(root, '@t+lib-a@file+packages+sdks+lib-a_peer', '@t/lib-a');
|
|
19
|
+
fs.symlinkSync(path.join(root, 'packages'), path.join(root, 'node_modules/.pnpm/@t+lib-a@file+packages+sdks+lib-a_peer/node_modules/dep-link'));
|
|
20
|
+
const messages = [];
|
|
21
|
+
assert.equal(syncInjected({ root, log: (m) => messages.push(m) }), 1);
|
|
22
|
+
const target = path.join(root, 'node_modules/.pnpm/@t+lib-a@file+packages+sdks+lib-a_peer/node_modules/@t/lib-a');
|
|
23
|
+
assert.ok(fs.existsSync(path.join(target, 'dist/index.js')));
|
|
24
|
+
assert.ok(fs.existsSync(path.join(target, 'README.md')));
|
|
25
|
+
assert.ok(!fs.existsSync(path.join(target, 'src')));
|
|
26
|
+
assert.match(messages[0], /1 injected workspace copy synced/);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test('refuses a name with no workspace package, no files, a glob, a missing entry, and zero copies', () => {
|
|
30
|
+
const root = workspace();
|
|
31
|
+
assert.throws(() => syncInjected({ root, log() {} }), /no injected workspace copy found/);
|
|
32
|
+
injected(root, 'ghost@file+x', 'ghost');
|
|
33
|
+
assert.throws(() => syncInjected({ root, log() {} }), /"ghost" maps to no workspace package/);
|
|
34
|
+
fs.rmSync(path.join(root, 'node_modules'), { recursive: true });
|
|
35
|
+
|
|
36
|
+
write(root, 'packages/sdks/lib-a/package.json', { name: '@t/lib-a' });
|
|
37
|
+
injected(root, '@t+lib-a@file+a', '@t/lib-a');
|
|
38
|
+
assert.throws(() => syncInjected({ root, log() {} }), /no "files" field/);
|
|
39
|
+
write(root, 'packages/sdks/lib-a/package.json', { name: '@t/lib-a', files: ['dist/**'] });
|
|
40
|
+
assert.throws(() => syncInjected({ root, log() {} }), /glob/);
|
|
41
|
+
write(root, 'packages/sdks/lib-a/package.json', { name: '@t/lib-a', files: ['dist', 'missing.txt'] });
|
|
42
|
+
assert.throws(() => syncInjected({ root, log() {} }), /"missing\.txt" does not exist/);
|
|
43
|
+
});
|