diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e84ac62..b1721edd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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/@/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` 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)): diff --git a/apps/demo/package.json b/apps/demo/package.json index dee47252..fd9ef345 100644 --- a/apps/demo/package.json +++ b/apps/demo/package.json @@ -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", @@ -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" diff --git a/apps/demo/scripts/patch-symlinks.cjs b/apps/demo/scripts/patch-symlinks.cjs index b632ef39..4df0c775 100644 --- a/apps/demo/scripts/patch-symlinks.cjs +++ b/apps/demo/scripts/patch-symlinks.cjs @@ -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/@/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). @@ -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(); + + // 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++; + + // 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. */ @@ -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`); diff --git a/packages/foundation/types/package.json b/packages/foundation/types/package.json index 2a818862..1d9a2750 100644 --- a/packages/foundation/types/package.json +++ b/packages/foundation/types/package.json @@ -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" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f1a18012..3a09523a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -204,6 +204,9 @@ importers: '@objectstack/runtime': specifier: ^3.2.8 version: 3.2.8 + '@objectstack/spec': + specifier: ^3.2.8 + version: 3.2.8 '@objectstack/studio': specifier: ^3.2.8 version: 3.2.8(@types/node@20.19.37)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(mongodb@7.1.0(socks@2.8.7))(next@16.1.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(pg@8.20.0)(typescript@5.9.3)(vitest@1.6.1)(vue@3.5.30(typescript@5.9.3)) @@ -216,6 +219,9 @@ importers: typescript: specifier: ^5.9.3 version: 5.9.3 + zod: + specifier: ^4.3.6 + version: 4.3.6 apps/site: dependencies: @@ -897,16 +903,17 @@ importers: version: 5.9.3 packages/foundation/types: - devDependencies: + dependencies: '@objectstack/spec': specifier: ^3.2.8 version: 3.2.8 - ts-json-schema-generator: - specifier: ^2.9.0 - version: 2.9.0 zod: specifier: ^4.3.6 version: 4.3.6 + devDependencies: + ts-json-schema-generator: + specifier: ^2.9.0 + version: 2.9.0 packages/protocols/graphql: dependencies: