Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Fixed

- **`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.
- **`apps/demo`** — added explicit `@objectstack/spec` and `zod` devDependencies as defense-in-depth for Vercel deployment.
- **`@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.

### Added

- **`apps/demo`** — standalone Vercel-deployable demo application ([#issue](https://github.com/objectstack-ai/objectql/issues)):
Expand Down
4 changes: 3 additions & 1 deletion apps/demo/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
"@objectql/types": "workspace:*",
"@objectstack/cli": "^3.2.8",
"@objectstack/core": "^3.2.8",
"@objectstack/spec": "^3.2.8",
"@objectstack/driver-memory": "^3.2.8",
"@objectstack/objectql": "^3.2.8",
"@objectstack/plugin-auth": "^3.2.8",
Expand All @@ -35,7 +36,8 @@
"@objectstack/studio": "^3.2.8",
"@types/node": "^20.19.37",
"hono": "^4.12.8",
"typescript": "^5.9.3"
"typescript": "^5.9.3",
"zod": "^4.3.6"
},
"engines": {
"node": ">=20"
Expand Down
177 changes: 173 additions & 4 deletions apps/demo/scripts/patch-symlinks.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,16 @@
* Prepares node_modules for Vercel deployment.
*
* pnpm uses symlinks in node_modules which Vercel rejects as
* "invalid deployment package … symlinked directories". This script
* replaces ALL top-level symlinks with real copies of the target
* directories so that Vercel can bundle the serverless function.
* "invalid deployment package … symlinked directories". This script:
*
* 1. Resolves transitive dependencies — walks each package's pnpm
* virtual store context (`.pnpm/<name>@<ver>/node_modules/`) and
* copies any missing dependency into the top-level `node_modules/`.
* This is repeated until the full transitive closure is present.
*
* 2. Dereferences all remaining symlinks — replaces every top-level
* symlink in `node_modules/` with a real copy so Vercel can bundle
* the serverless function.
*
* This script is invoked as a postinstall hook and is a no-op outside
* the Vercel build environment (process.env.VERCEL is not set locally).
Expand All @@ -25,6 +32,159 @@ const path = require('path');

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

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

/**
* List all top-level package names in a node_modules directory.
* Handles scoped packages (@scope/name).
*/
function listTopLevelPackages(nmAbs) {
const packages = [];
if (!fs.existsSync(nmAbs)) return packages;

for (const entry of fs.readdirSync(nmAbs)) {
if (entry === '.pnpm' || entry.startsWith('.')) continue;

const entryPath = path.join(nmAbs, entry);

if (entry.startsWith('@')) {
try {
if (!fs.statSync(entryPath).isDirectory()) continue;
} catch { continue; }
for (const sub of fs.readdirSync(entryPath)) {
packages.push(`${entry}/${sub}`);
}
} else {
packages.push(entry);
}
}
return packages;
}

/**
* Given a resolved real path of a package *inside* the pnpm virtual store,
* return the virtual `node_modules/` directory that contains the package's
* dependencies as siblings.
*
* Example:
* realPath = …/.pnpm/@objectstack+runtime@3.2.8/node_modules/@objectstack/runtime
* pkgName = @objectstack/runtime (2 segments)
* → …/.pnpm/@objectstack+runtime@3.2.8/node_modules
*/
function pnpmContextDir(realPath, pkgName) {
const depth = pkgName.split('/').length; // 1 for unscoped, 2 for scoped
let dir = realPath;
for (let i = 0; i < depth; i++) dir = path.dirname(dir);
return dir;
}

// ---------------------------------------------------------------------------
// Phase 1 — Resolve transitive dependencies
// ---------------------------------------------------------------------------

/**
* Walk the pnpm virtual store context of every package already present in
* `node_modules/`, copying any sibling dependency that is not yet present
* at the top level. Repeat until no new packages are added (transitive
* closure).
*
* MUST run before symlinks are dereferenced — we rely on `fs.realpathSync`
* following pnpm symlinks to discover the `.pnpm/` context directories.
*/
function resolveTransitiveDeps(nmDir) {
const nmAbs = path.resolve(ROOT, nmDir);
if (!fs.existsSync(nmAbs)) return 0;

const processedContexts = new Set();
const contextQueue = [];

// Seed the queue with every symlinked package's pnpm context.
for (const pkgName of listTopLevelPackages(nmAbs)) {
const pkgPath = path.join(nmAbs, pkgName);
try {
if (!fs.lstatSync(pkgPath).isSymbolicLink()) continue;
const realPath = fs.realpathSync(pkgPath);
const ctxDir = pnpmContextDir(realPath, pkgName);
if (ctxDir.includes('.pnpm') && !processedContexts.has(ctxDir)) {
processedContexts.add(ctxDir);
contextQueue.push(ctxDir);
}
} catch { /* skip unresolvable entries */ }
}

let totalAdded = 0;

// Safety limit — prevent runaway iteration in pathological dependency graphs.
const MAX_CONTEXTS = 5000;

while (contextQueue.length > 0) {
if (processedContexts.size > MAX_CONTEXTS) {
console.warn(` ⚠ Reached ${MAX_CONTEXTS} context directories — stopping transitive resolution.`);
break;
}

const ctxDir = contextQueue.shift();
Comment on lines +122 to +128
Copy link

Copilot AI Mar 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

resolveTransitiveDeps() uses contextQueue.shift() in a loop. Array#shift() is O(n) because it re-indexes the array each time, which can make this phase noticeably slower as the queue grows. Use an index pointer (e.g., for (let i=0; i<queue.length; i++)) or a small deque implementation to keep it O(n).

Suggested change
while (contextQueue.length > 0) {
if (processedContexts.size > MAX_CONTEXTS) {
console.warn(` ⚠ Reached ${MAX_CONTEXTS} context directories — stopping transitive resolution.`);
break;
}
const ctxDir = contextQueue.shift();
// Use index-based iteration to avoid O(n) Array#shift() cost.
for (let i = 0; i < contextQueue.length; i++) {
if (processedContexts.size > MAX_CONTEXTS) {
console.warn(` ⚠ Reached ${MAX_CONTEXTS} context directories — stopping transitive resolution.`);
break;
}
const ctxDir = contextQueue[i];

Copilot uses AI. Check for mistakes.

// Iterate siblings in this .pnpm context's node_modules.
let entries;
try { entries = fs.readdirSync(ctxDir); } catch { continue; }
for (const entry of entries) {
if (entry === '.pnpm' || entry.startsWith('.')) continue;

const processEntry = (depName, entryPath) => {
const targetPath = path.join(nmAbs, depName);
if (fs.existsSync(targetPath)) return; // already present

// Resolve the real path of this pnpm-store entry.
let realDepPath;
try {
const stat = fs.lstatSync(entryPath);
realDepPath = stat.isSymbolicLink()
? fs.realpathSync(entryPath)
: entryPath;
} catch { return; }

// Ensure scope directory exists for scoped packages.
if (depName.includes('/')) {
fs.mkdirSync(path.join(nmAbs, depName.split('/')[0]), { recursive: true });
}

console.log(` + ${depName}`);
fs.cpSync(realDepPath, targetPath, { recursive: true, dereference: true });
totalAdded++;
Comment on lines +136 to +156
Copy link

Copilot AI Mar 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

resolveTransitiveDeps() copies packages directly into node_modules via fs.cpSync(). If the copy fails part-way (disk full, permission error, interrupted install), it can leave a partially-copied directory; subsequent runs will skip it due to existsSync(targetPath) and you can end up with a broken/corrupt dependency tree. Consider mirroring derefSymlink()’s safer temp-copy + rename approach (and cleaning up tmp dirs on failure) for each dep copy.

Copilot uses AI. Check for mistakes.

// Enqueue this dep's own pnpm context so its transitive deps
// are also resolved on a subsequent iteration.
const depCtxDir = pnpmContextDir(realDepPath, depName);
if (depCtxDir.includes('.pnpm') && !processedContexts.has(depCtxDir)) {
processedContexts.add(depCtxDir);
contextQueue.push(depCtxDir);
}
};

if (entry.startsWith('@')) {
const scopeDir = path.join(ctxDir, entry);
try {
if (!fs.statSync(scopeDir).isDirectory()) continue;
} catch { continue; }
for (const sub of fs.readdirSync(scopeDir)) {
processEntry(`${entry}/${sub}`, path.join(scopeDir, sub));
}
} else {
processEntry(entry, path.join(ctxDir, entry));
}
}
}

return totalAdded;
}

// ---------------------------------------------------------------------------
// Phase 2 — Dereference symlinks
// ---------------------------------------------------------------------------

/**
* Replace a pnpm symlink with a real copy of the target directory.
*/
Expand Down Expand Up @@ -84,7 +244,16 @@ function derefAllSymlinks(nmDir) {
return count;
}

// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------

console.log('\n🔧 Patching pnpm symlinks for Vercel deployment…\n');

console.log('Phase 1: Resolving transitive dependencies…');
const transCount = resolveTransitiveDeps('node_modules');
console.log(` Added ${transCount} transitive dependencies.\n`);

console.log('Phase 2: Dereferencing symlinks…');
const count = derefAllSymlinks('node_modules');
console.log(`\n✅ Patch complete — processed ${count} packages\n`);
console.log(`\n✅ Patch complete — dereferenced ${count} packages, added ${transCount} transitive deps\n`);
6 changes: 4 additions & 2 deletions packages/foundation/types/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,11 @@
"generate:schemas": "node scripts/generate-schemas.js",
"test": "vitest run"
},
"devDependencies": {
"dependencies": {
"@objectstack/spec": "^3.2.8",
"ts-json-schema-generator": "^2.9.0",
"zod": "^4.3.6"
},
"devDependencies": {
"ts-json-schema-generator": "^2.9.0"
}
}
15 changes: 11 additions & 4 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading