Skip to content

Commit fba93ff

Browse files
Copilothotlong
andcommitted
fix: enhance patch-symlinks.cjs to auto-resolve all transitive dependencies
Instead of manually listing each transitive dep in apps/demo/package.json, the script now walks each package's pnpm virtual store context (.pnpm/<name>@<ver>/node_modules/) and copies any missing dependency into the top-level node_modules/ before dereferencing symlinks. This handles @objectstack/rest, pino, better-auth, mingo, and all other transitive deps automatically (~364 packages). Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> Agent-Logs-Url: https://github.com/objectstack-ai/objectql/sessions/41d33284-6232-4795-a3e5-60ed24fddd69
1 parent 997426e commit fba93ff

File tree

2 files changed

+175
-5
lines changed

2 files changed

+175
-5
lines changed

CHANGELOG.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Fixed
1111

12-
- **`apps/demo`** — added explicit `@objectstack/spec` and `zod` devDependencies. The Vercel serverless function failed at runtime with `ERR_MODULE_NOT_FOUND` because transitive dependencies could not be resolved after pnpm symlinks were dereferenced by `patch-symlinks.cjs`. Adding them as explicit dependencies ensures they are present at the top level of `node_modules/` for Vercel bundling.
12+
- **`apps/demo/scripts/patch-symlinks.cjs`** — enhanced to automatically resolve and copy ALL transitive dependencies before dereferencing symlinks. Previously, only direct dependencies listed in `apps/demo/package.json` were available after symlink dereferencing, causing `ERR_MODULE_NOT_FOUND` for transitive deps like `@objectstack/rest`, `zod`, `pino`, `better-auth`, etc. The script now walks each package's pnpm virtual store context (`.pnpm/<name>@<ver>/node_modules/`) and copies any missing sibling dependency into the top-level `node_modules/`, repeating until the full transitive closure is present.
13+
- **`apps/demo`** — added explicit `@objectstack/spec` and `zod` devDependencies as defense-in-depth for Vercel deployment.
1314
- **`@objectql/types`** — moved `@objectstack/spec` and `zod` from `devDependencies` to `dependencies`. The compiled JS output contains runtime imports of `@objectstack/spec` (via `z.infer<typeof Data.X>` patterns), so they must be declared as production dependencies.
1415

1516
### Added

apps/demo/scripts/patch-symlinks.cjs

Lines changed: 173 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,16 @@
55
* Prepares node_modules for Vercel deployment.
66
*
77
* pnpm uses symlinks in node_modules which Vercel rejects as
8-
* "invalid deployment package … symlinked directories". This script
9-
* replaces ALL top-level symlinks with real copies of the target
10-
* directories so that Vercel can bundle the serverless function.
8+
* "invalid deployment package … symlinked directories". This script:
9+
*
10+
* 1. Resolves transitive dependencies — walks each package's pnpm
11+
* virtual store context (`.pnpm/<name>@<ver>/node_modules/`) and
12+
* copies any missing dependency into the top-level `node_modules/`.
13+
* This is repeated until the full transitive closure is present.
14+
*
15+
* 2. Dereferences all remaining symlinks — replaces every top-level
16+
* symlink in `node_modules/` with a real copy so Vercel can bundle
17+
* the serverless function.
1118
*
1219
* This script is invoked as a postinstall hook and is a no-op outside
1320
* the Vercel build environment (process.env.VERCEL is not set locally).
@@ -25,6 +32,159 @@ const path = require('path');
2532

2633
const ROOT = path.resolve(__dirname, '..');
2734

35+
// ---------------------------------------------------------------------------
36+
// Helpers
37+
// ---------------------------------------------------------------------------
38+
39+
/**
40+
* List all top-level package names in a node_modules directory.
41+
* Handles scoped packages (@scope/name).
42+
*/
43+
function listTopLevelPackages(nmAbs) {
44+
const packages = [];
45+
if (!fs.existsSync(nmAbs)) return packages;
46+
47+
for (const entry of fs.readdirSync(nmAbs)) {
48+
if (entry === '.pnpm' || entry.startsWith('.')) continue;
49+
50+
const entryPath = path.join(nmAbs, entry);
51+
52+
if (entry.startsWith('@')) {
53+
try {
54+
if (!fs.statSync(entryPath).isDirectory()) continue;
55+
} catch { continue; }
56+
for (const sub of fs.readdirSync(entryPath)) {
57+
packages.push(`${entry}/${sub}`);
58+
}
59+
} else {
60+
packages.push(entry);
61+
}
62+
}
63+
return packages;
64+
}
65+
66+
/**
67+
* Given a resolved real path of a package *inside* the pnpm virtual store,
68+
* return the virtual `node_modules/` directory that contains the package's
69+
* dependencies as siblings.
70+
*
71+
* Example:
72+
* realPath = …/.pnpm/@objectstack+runtime@3.2.8/node_modules/@objectstack/runtime
73+
* pkgName = @objectstack/runtime (2 segments)
74+
* → …/.pnpm/@objectstack+runtime@3.2.8/node_modules
75+
*/
76+
function pnpmContextDir(realPath, pkgName) {
77+
const depth = pkgName.split('/').length; // 1 for unscoped, 2 for scoped
78+
let dir = realPath;
79+
for (let i = 0; i < depth; i++) dir = path.dirname(dir);
80+
return dir;
81+
}
82+
83+
// ---------------------------------------------------------------------------
84+
// Phase 1 — Resolve transitive dependencies
85+
// ---------------------------------------------------------------------------
86+
87+
/**
88+
* Walk the pnpm virtual store context of every package already present in
89+
* `node_modules/`, copying any sibling dependency that is not yet present
90+
* at the top level. Repeat until no new packages are added (transitive
91+
* closure).
92+
*
93+
* MUST run before symlinks are dereferenced — we rely on `fs.realpathSync`
94+
* following pnpm symlinks to discover the `.pnpm/` context directories.
95+
*/
96+
function resolveTransitiveDeps(nmDir) {
97+
const nmAbs = path.resolve(ROOT, nmDir);
98+
if (!fs.existsSync(nmAbs)) return 0;
99+
100+
const processedContexts = new Set();
101+
const contextQueue = [];
102+
103+
// Seed the queue with every symlinked package's pnpm context.
104+
for (const pkgName of listTopLevelPackages(nmAbs)) {
105+
const pkgPath = path.join(nmAbs, pkgName);
106+
try {
107+
if (!fs.lstatSync(pkgPath).isSymbolicLink()) continue;
108+
const realPath = fs.realpathSync(pkgPath);
109+
const ctxDir = pnpmContextDir(realPath, pkgName);
110+
if (ctxDir.includes('.pnpm') && !processedContexts.has(ctxDir)) {
111+
processedContexts.add(ctxDir);
112+
contextQueue.push(ctxDir);
113+
}
114+
} catch { /* skip unresolvable entries */ }
115+
}
116+
117+
let totalAdded = 0;
118+
119+
// Safety limit — prevent runaway iteration in pathological dependency graphs.
120+
const MAX_CONTEXTS = 5000;
121+
122+
while (contextQueue.length > 0) {
123+
if (processedContexts.size > MAX_CONTEXTS) {
124+
console.warn(` ⚠ Reached ${MAX_CONTEXTS} context directories — stopping transitive resolution.`);
125+
break;
126+
}
127+
128+
const ctxDir = contextQueue.shift();
129+
130+
// Iterate siblings in this .pnpm context's node_modules.
131+
let entries;
132+
try { entries = fs.readdirSync(ctxDir); } catch { continue; }
133+
for (const entry of entries) {
134+
if (entry === '.pnpm' || entry.startsWith('.')) continue;
135+
136+
const processEntry = (depName, entryPath) => {
137+
const targetPath = path.join(nmAbs, depName);
138+
if (fs.existsSync(targetPath)) return; // already present
139+
140+
// Resolve the real path of this pnpm-store entry.
141+
let realDepPath;
142+
try {
143+
const stat = fs.lstatSync(entryPath);
144+
realDepPath = stat.isSymbolicLink()
145+
? fs.realpathSync(entryPath)
146+
: entryPath;
147+
} catch { return; }
148+
149+
// Ensure scope directory exists for scoped packages.
150+
if (depName.includes('/')) {
151+
fs.mkdirSync(path.join(nmAbs, depName.split('/')[0]), { recursive: true });
152+
}
153+
154+
console.log(` + ${depName}`);
155+
fs.cpSync(realDepPath, targetPath, { recursive: true, dereference: true });
156+
totalAdded++;
157+
158+
// Enqueue this dep's own pnpm context so its transitive deps
159+
// are also resolved on a subsequent iteration.
160+
const depCtxDir = pnpmContextDir(realDepPath, depName);
161+
if (depCtxDir.includes('.pnpm') && !processedContexts.has(depCtxDir)) {
162+
processedContexts.add(depCtxDir);
163+
contextQueue.push(depCtxDir);
164+
}
165+
};
166+
167+
if (entry.startsWith('@')) {
168+
const scopeDir = path.join(ctxDir, entry);
169+
try {
170+
if (!fs.statSync(scopeDir).isDirectory()) continue;
171+
} catch { continue; }
172+
for (const sub of fs.readdirSync(scopeDir)) {
173+
processEntry(`${entry}/${sub}`, path.join(scopeDir, sub));
174+
}
175+
} else {
176+
processEntry(entry, path.join(ctxDir, entry));
177+
}
178+
}
179+
}
180+
181+
return totalAdded;
182+
}
183+
184+
// ---------------------------------------------------------------------------
185+
// Phase 2 — Dereference symlinks
186+
// ---------------------------------------------------------------------------
187+
28188
/**
29189
* Replace a pnpm symlink with a real copy of the target directory.
30190
*/
@@ -84,7 +244,16 @@ function derefAllSymlinks(nmDir) {
84244
return count;
85245
}
86246

247+
// ---------------------------------------------------------------------------
248+
// Main
249+
// ---------------------------------------------------------------------------
250+
87251
console.log('\n🔧 Patching pnpm symlinks for Vercel deployment…\n');
88252

253+
console.log('Phase 1: Resolving transitive dependencies…');
254+
const transCount = resolveTransitiveDeps('node_modules');
255+
console.log(` Added ${transCount} transitive dependencies.\n`);
256+
257+
console.log('Phase 2: Dereferencing symlinks…');
89258
const count = derefAllSymlinks('node_modules');
90-
console.log(`\n✅ Patch complete — processed ${count} packages\n`);
259+
console.log(`\n✅ Patch complete — dereferenced ${count} packages, added ${transCount} transitive deps\n`);

0 commit comments

Comments
 (0)