1import * as Effect from "effect/Effect";
2import * as FileSystem from "effect/FileSystem";
3import type { PlatformError } from "effect/PlatformError";
4import * as Path from "effect/Path";
5import { dotAlchemyDirectory } from "../AlchemyContext.ts";
6import { isPathWithin } from "../Util/isPathWithin.ts";
7import {
8 inspectArtifactFile,
9 inspectVerifiedFile,
10 readArtifactFile,
11 type ArtifactFile,
12} from "./Internal/ArtifactFile.ts";
13import {
14 isArchivedRegularFile,
15 readDirectoryEntriesSecure,
16 writeCompressedArchiveSecure,
17 type ArchiveTarEntry,
18} from "./Internal/ArchivePlatform.ts";
19
20export const COMPUTE_MANIFEST_VERSION = "1";
21
22const MAX_UNCOMPRESSED_BYTES = 256 * 1024 * 1024;
23const MAX_FILE_BYTES = 128 * 1024 * 1024;
24const MAX_ENTRIES = 50_000;
25const MAX_COMPRESSED_BYTES = 256 * 1024 * 1024;
26
27export interface ComputeArchiveOptions {
28 /**
29 * Directory whose files should be uploaded as the compute bundle.
30 */
31 directory: string;
32 /**
33 * Entrypoint relative to `directory`.
34 */
35 entrypoint: string;
36 /**
37 * Additional artifact-relative paths to exclude. Patterns support `*` and
38 * `**`; directory matches exclude their complete subtree. Absolute paths,
39 * parent (`..`) segments, and negated patterns are rejected. Sensitive
40 * Alchemy, Git, and dotenv files are always excluded.
41 */
42 ignore?: readonly string[];
43 /**
44 * Artifact-relative prefix applied to custom ignore patterns after they are
45 * validated.
46 */
47 ignorePrefix?: string;
48 /**
49 * Artifact-relative files that must remain present after exclusions.
50 */
51 requiredFiles?: readonly string[];
52 /**
53 * Maximum uncompressed bytes accepted across all archived files. Values
54 * above the provider's 256 MiB hard ceiling are rejected.
55 *
56 * @default 268435456 (256 MiB)
57 */
58 maxUncompressedBytes?: number;
59 /**
60 * Maximum bytes accepted for one archived file. Values above the provider's
61 * 128 MiB hard ceiling are rejected.
62 *
63 * @default 134217728 (128 MiB)
64 */
65 maxFileBytes?: number;
66 /**
67 * Maximum number of filesystem entries accepted in an artifact. Values
68 * above the provider's 50,000-entry hard ceiling are rejected.
69 *
70 * @default 50000
71 */
72 maxEntries?: number;
73 /**
74 * Return a verified, file-backed archive instead of materializing the final
75 * compressed bytes. The caller must run the returned `cleanup` Effect.
76 *
77 * @default "bytes"
78 */
79 output?: "bytes" | "file";
80}
81
82const ALWAYS_IGNORED_PATTERNS = [
83 ".git",
84 "**/.git",
85 ".alchemy",
86 "**/.alchemy",
87 ".env",
88 "**/.env",
89 ".env.*",
90 "**/.env.*",
91 ".envrc",
92 "**/.envrc",
93 ".npmrc",
94 "**/.npmrc",
95 ".yarnrc*",
96 "**/.yarnrc*",
97 ".netrc",
98 "**/.netrc",
99 ".pypirc",
100 "**/.pypirc",
101 ".aws",
102 "**/.aws",
103 ".ssh",
104 "**/.ssh",
105] as const;
106
107interface ArchiveBudget {
108 readonly ignore: readonly RegExp[];
109 readonly maxUncompressedBytes: number;
110 readonly maxFileBytes: number;
111 readonly maxEntries: number;
112 bytes: number;
113 entries: number;
114}
115
116type ArchiveError = PlatformError | Error;
117type ArchiveRequirements = FileSystem.FileSystem | Path.Path;
118
119export function createComputeArchive(
120 options: ComputeArchiveOptions & { readonly output: "file" },
121): Effect.Effect<ArtifactFile, ArchiveError, ArchiveRequirements>;
122export function createComputeArchive(
123 options: ComputeArchiveOptions & { readonly output?: "bytes" },
124): Effect.Effect<Uint8Array, ArchiveError, ArchiveRequirements>;
125export function createComputeArchive(
126 options: ComputeArchiveOptions,
127): Effect.Effect<Uint8Array | ArtifactFile, ArchiveError, ArchiveRequirements>;
128/**
129 * Create the `tar.gz` artifact consumed by Prisma Compute.
130 *
131 * Files are added under the `bundle/` prefix, with a synthetic
132 * `compute.manifest.json` at the archive root. Tar construction and gzip
133 * compression stream through a private temporary file, so the uncompressed
134 * artifact is never retained in memory.
135 */
136export function createComputeArchive(
137 options: ComputeArchiveOptions,
138): Effect.Effect<Uint8Array | ArtifactFile, ArchiveError, ArchiveRequirements> {
139 const effect = createComputeArchiveFile(options);
140 if (options.output === "file") return effect;
141 return Effect.flatMap(effect, (artifact) =>
142 readArtifactFile(artifact).pipe(Effect.ensuring(artifact.cleanup)),
143 );
144}
145
146const createComputeArchiveFile = Effect.fn(function* (
147 options: ComputeArchiveOptions,
148): Effect.fn.Return<ArtifactFile, ArchiveError, ArchiveRequirements> {
149 const {
150 directory,
151 entrypoint,
152 ignore = [],
153 ignorePrefix,
154 requiredFiles = [],
155 maxUncompressedBytes = MAX_UNCOMPRESSED_BYTES,
156 maxFileBytes = MAX_FILE_BYTES,
157 maxEntries = MAX_ENTRIES,
158 } = options;
159 const fs = yield* FileSystem.FileSystem;
160 const path = yield* Path.Path;
161 const runtimeBase = process.cwd();
162 const root = path.resolve(runtimeBase, directory);
163 const realRoot = yield* fs.realPath(root);
164 const dotAlchemy = yield* dotAlchemyDirectory;
165 const runtimeRelative = path
166 .relative(root, path.resolve(runtimeBase, dotAlchemy))
167 .replaceAll("\\", "/");
168 const normalizedEntrypoint = yield* normalizeEntrypoint(entrypoint);
169 const validated = yield* Effect.try({
170 try: () => ({
171 ignore: [
172 ...(isPathWithin(root, dotAlchemy, runtimeBase)
173 ? [
174 // Keep runtime state out of the archive. Escape regex characters
175 // in the literal directory name (e.g. "cache[private]"); the
176 // suffix matches that directory and its descendants, not siblings
177 // whose names merely share its prefix (e.g. "cache-backup").
178 new RegExp(
179 `^${runtimeRelative.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?:/.*)?$`,
180 ),
181 ]
182 : []),
183 ...ALWAYS_IGNORED_PATTERNS.map((pattern) =>
184 compileIgnorePattern(pattern),
185 ),
186 ...ignore.map((pattern) => compileIgnorePattern(pattern, ignorePrefix)),
187 ],
188 maxUncompressedBytes: boundedLimit(
189 "maxUncompressedBytes",
190 maxUncompressedBytes,
191 MAX_UNCOMPRESSED_BYTES,
192 ),
193 maxFileBytes: boundedLimit("maxFileBytes", maxFileBytes, MAX_FILE_BYTES),
194 maxEntries: boundedLimit("maxEntries", maxEntries, MAX_ENTRIES),
195 }),
196 catch: (error) =>
197 error instanceof Error ? error : new Error(String(error)),
198 });
199 const budget: ArchiveBudget = {
200 ...validated,
201 bytes: 0,
202 entries: 0,
203 };
204 if (budget.maxFileBytes > budget.maxUncompressedBytes) {
205 return yield* Effect.fail(
206 new Error("maxFileBytes must not exceed maxUncompressedBytes"),
207 );
208 }
209 if (isIgnored(normalizedEntrypoint, budget.ignore)) {
210 return yield* Effect.fail(
211 new Error(
212 `Entrypoint is excluded from the compute artifact: ${normalizedEntrypoint}`,
213 ),
214 );
215 }
216 const resolvedEntrypoint = yield* resolvePathWithinRoot(
217 realRoot,
218 path.join(realRoot, normalizedEntrypoint),
219 );
220 const entrypointStat = yield* fs.stat(resolvedEntrypoint);
221 if (entrypointStat.type !== "File") {
222 return yield* Effect.fail(
223 new Error(
224 `Entrypoint must be a file in compute artifact: ${normalizedEntrypoint}`,
225 ),
226 );
227 }
228
229 const entries: ArchiveTarEntry[] = [];
230 yield* addDirectoryEntries(entries, realRoot, realRoot, "bundle", "", budget);
231 const entrypointArchiveName = `bundle/${normalizedEntrypoint}`;
232 if (!isArchivedRegularFile(entries, entrypointArchiveName)) {
233 return yield* Effect.fail(
234 new Error(
235 `Entrypoint not found in compute artifact: ${normalizedEntrypoint}`,
236 ),
237 );
238 }
239
240 for (const file of requiredFiles) {
241 const normalized = yield* normalizeEntrypoint(file);
242 if (!isArchivedRegularFile(entries, `bundle/${normalized}`)) {
243 return yield* Effect.fail(
244 new Error(`Required file not found in compute artifact: ${normalized}`),
245 );
246 }
247 }
248
249 const manifest = new TextEncoder().encode(
250 JSON.stringify(
251 {
252 manifestVersion: COMPUTE_MANIFEST_VERSION,
253 entrypoint: entrypointArchiveName,
254 },
255 null,
256 2,
257 ),
258 );
259 const archivePath = yield* fs.makeTempFile({
260 prefix: "alchemy-prisma-compute-",
261 suffix: ".tar.gz",
262 });
263 const cleanup = fs
264 .remove(archivePath, { force: true })
265 .pipe(Effect.catch(() => Effect.void));
266
267 yield* writeCompressedArchiveSecure(
268 archivePath,
269 entries,
270 manifest,
271 MAX_COMPRESSED_BYTES,
272 ).pipe(
273 Effect.catch((error) => cleanup.pipe(Effect.andThen(Effect.fail(error)))),
274 );
275 return yield* inspectArtifactFile(archivePath, MAX_COMPRESSED_BYTES, {
276 cleanup,
277 description: "Prisma compute archive",
278 }).pipe(
279 Effect.catch((error) => cleanup.pipe(Effect.andThen(Effect.fail(error)))),
280 );
281});
282
283const addDirectoryEntries: (
284 entries: ArchiveTarEntry[],
285 realRoot: string,
286 directory: string,
287 tarPrefix: string,
288 relativePrefix: string,
289 budget: ArchiveBudget,
290) => Effect.Effect<void, ArchiveError, ArchiveRequirements> = Effect.fn(
291 function* (entries, realRoot, directory, tarPrefix, relativePrefix, budget) {
292 const fs = yield* FileSystem.FileSystem;
293 const path = yield* Path.Path;
294 const directoryResult = yield* readDirectoryEntriesSecure({
295 directory,
296 relativePrefix,
297 ignore: budget.ignore,
298 entriesAlreadyObserved: budget.entries,
299 maxEntries: budget.maxEntries,
300 });
301 budget.entries += directoryResult.observedEntries;
302
303 for (const entry of directoryResult.entries) {
304 const name = entry.name;
305 const relativeName = relativePrefix ? `${relativePrefix}/${name}` : name;
306
307 const filePath = path.join(directory, name);
308 const tarName = `${tarPrefix}/${name}`;
309 if (entry.type === "SymbolicLink") {
310 const symlinkTarget = yield* fs.readLink(filePath);
311 const linkname = yield* resolveArchiveSymlinkTarget(
312 realRoot,
313 filePath,
314 symlinkTarget,
315 );
316 entries.push({
317 name: tarName,
318 mode: 0o777,
319 type: "symlink",
320 linkname,
321 });
322 continue;
323 }
324
325 if (entry.type === "Directory") {
326 const realDirectory = yield* resolvePathWithinRoot(realRoot, filePath);
327 yield* addDirectoryEntries(
328 entries,
329 realRoot,
330 realDirectory,
331 tarName,
332 relativeName,
333 budget,
334 );
335 continue;
336 }
337
338 if (entry.type !== "File") {
339 return yield* Effect.fail(
340 new Error(
341 `Unsupported filesystem entry in compute artifact: ${relativeName}`,
342 ),
343 );
344 }
345
346 const verified = yield* inspectVerifiedFile(
347 filePath,
348 budget.maxFileBytes,
349 {
350 allowEmpty: true,
351 description: `Compute artifact file '${relativeName}'`,
352 },
353 );
354 yield* ensureResolvedPathWithinRoot(realRoot, verified.path);
355 if (budget.bytes + verified.size > budget.maxUncompressedBytes) {
356 return yield* Effect.fail(
357 new Error(
358 `Compute artifact exceeds the ${budget.maxUncompressedBytes} byte uncompressed safety limit. Narrow the artifact directory or add ignore patterns.`,
359 ),
360 );
361 }
362 budget.bytes += verified.size;
363 entries.push({
364 name: tarName,
365 mode: verified.mode & 0o777,
366 type: "file",
367 file: verified,
368 });
369 }
370 },
371);
372
373export const normalizeEntrypoint = (entrypoint: string) =>
374 Effect.gen(function* () {
375 const normalized = entrypoint.replaceAll("\\", "/");
376 if (normalized.trim().length === 0) {
377 return yield* Effect.fail(new Error("entrypoint must be non-empty"));
378 }
379 if (normalized.startsWith("/") || /^[a-zA-Z]:\//.test(normalized)) {
380 return yield* Effect.fail(new Error("entrypoint must be relative"));
381 }
382 const parts = normalized.split("/").filter((part) => part !== ".");
383 if (parts.some((part) => part === ".." || part.length === 0)) {
384 return yield* Effect.fail(
385 new Error("entrypoint must not contain empty or parent segments"),
386 );
387 }
388 return parts.join("/");
389 });
390
391const resolvePathWithinRoot = Effect.fn(function* (
392 realRoot: string,
393 candidate: string,
394) {
395 const fs = yield* FileSystem.FileSystem;
396 const realCandidate = yield* fs.realPath(candidate);
397 yield* ensureResolvedPathWithinRoot(realRoot, realCandidate);
398 return realCandidate;
399});
400
401const ensureResolvedPathWithinRoot = Effect.fn(function* (
402 realRoot: string,
403 resolvedCandidate: string,
404) {
405 const path = yield* Path.Path;
406 const relative = path.relative(realRoot, resolvedCandidate);
407 if (
408 relative === ".." ||
409 relative.startsWith(`..${path.sep}`) ||
410 path.isAbsolute(relative)
411 ) {
412 return yield* Effect.fail(
413 new Error(
414 `Archive path escapes compute artifact root: ${resolvedCandidate}`,
415 ),
416 );
417 }
418});
419
420const resolveArchiveSymlinkTarget = Effect.fn(function* (
421 realRoot: string,
422 symlinkPath: string,
423 target: string,
424) {
425 const path = yield* Path.Path;
426 if (path.sep === "/" && target.includes("\\")) {
427 return yield* Effect.fail(
428 new Error(
429 `Archive symlink target contains an unsupported backslash: ${target}`,
430 ),
431 );
432 }
433 const symlinkDir = path.dirname(symlinkPath);
434 const targetPath = path.isAbsolute(target)
435 ? target
436 : path.resolve(symlinkDir, target);
437 const realTarget = yield* resolvePathWithinRoot(realRoot, targetPath);
438
439 if (!path.isAbsolute(target)) return target.replaceAll("\\", "/");
440 const realSymlinkDir = yield* resolvePathWithinRoot(realRoot, symlinkDir);
441 const relative = path.relative(realSymlinkDir, realTarget);
442 return (relative.length === 0 ? "." : relative).replaceAll("\\", "/");
443});
444
445const boundedLimit = (name: string, value: number, hardLimit: number) => {
446 if (!Number.isSafeInteger(value) || value <= 0) {
447 throw new Error(`${name} must be a positive safe integer`);
448 }
449 if (value > hardLimit) {
450 throw new Error(`${name} must not exceed the hard limit of ${hardLimit}`);
451 }
452 return value;
453};
454
455const compileIgnorePattern = (input: string, prefix?: string) => {
456 const normalized = input.replaceAll("\\", "/").replace(/^\.\//, "");
457 if (
458 normalized.length === 0 ||
459 normalized.startsWith("!") ||
460 normalized.startsWith("/") ||
461 /^[a-zA-Z]:\//.test(normalized) ||
462 normalized.split("/").some((segment) => segment === "..")
463 ) {
464 throw new Error(`Invalid compute archive ignore pattern: ${input}`);
465 }
466 const scoped = prefix === undefined ? normalized : `${prefix}/${normalized}`;
467 const escaped = scoped
468 .replace(/[.+^${}()|[\]\\]/g, "\\$&")
469 .replaceAll("**", "\0")
470 .replaceAll("*", "[^/]*")
471 .replaceAll("\0", ".*");
472 return new RegExp(`^${escaped}(?:/.*)?$`);
473};
474
475const isIgnored = (relativeName: string, patterns: readonly RegExp[]) =>
476 patterns.some((pattern) => pattern.test(relativeName));