diff --git a/app/lib/apply-schema.test.ts b/app/lib/apply-schema.test.ts new file mode 100644 index 0000000..9f808ef --- /dev/null +++ b/app/lib/apply-schema.test.ts @@ -0,0 +1,101 @@ +import { describe, it, expect } from "vitest"; +import fs from "fs"; +import path from "path"; +import { parseSqlStatements, makeIdempotent, loadSchemaStatements } from "./apply-schema"; + +// #484 (EPIC #465): the installed app applies app/prisma/schema.sql at startup +// through the Prisma client's library query engine instead of running +// `prisma db push` (whose native schema-engine fails to spawn in some packed +// prod-only installs). These guard the parse/idempotency logic and keep the +// committed DDL in sync with the Prisma schema. +const SCHEMA_SQL = path.join(process.cwd(), "app", "prisma", "schema.sql"); +const SCHEMA_PRISMA = path.join(process.cwd(), "app", "prisma", "schema.prisma"); + +describe("parseSqlStatements (#484)", () => { + it("splits statements and strips comment/blank lines", () => { + const sql = [ + "-- a comment", + 'CREATE TABLE "A" (', + ' "id" TEXT NOT NULL PRIMARY KEY', + ");", + "", + "-- another", + 'CREATE TABLE "B" ("k" TEXT NOT NULL PRIMARY KEY);', + ].join("\n"); + const out = parseSqlStatements(sql); + expect(out).toHaveLength(2); + expect(out[0]).toContain('CREATE TABLE "A"'); + expect(out[0]).not.toContain("-- a comment"); + expect(out[1]).toContain('CREATE TABLE "B"'); + }); + + it("ignores a trailing empty statement after the last semicolon", () => { + expect(parseSqlStatements('CREATE TABLE "A" ("k" TEXT);\n')).toHaveLength(1); + }); +}); + +describe("makeIdempotent (#484)", () => { + it("adds IF NOT EXISTS to CREATE TABLE", () => { + expect(makeIdempotent('CREATE TABLE "Session" ("id" TEXT)')).toBe( + 'CREATE TABLE IF NOT EXISTS "Session" ("id" TEXT)', + ); + }); + + it("adds IF NOT EXISTS to CREATE UNIQUE INDEX (preserving UNIQUE)", () => { + expect(makeIdempotent('CREATE UNIQUE INDEX "Session_token_key" ON "Session"("token")')).toBe( + 'CREATE UNIQUE INDEX IF NOT EXISTS "Session_token_key" ON "Session"("token")', + ); + }); + + it("adds IF NOT EXISTS to a plain CREATE INDEX", () => { + expect(makeIdempotent('CREATE INDEX "i" ON "A"("c")')).toBe( + 'CREATE INDEX IF NOT EXISTS "i" ON "A"("c")', + ); + }); + + it("is a no-op when IF NOT EXISTS is already present", () => { + const stmt = 'CREATE TABLE IF NOT EXISTS "A" ("k" TEXT)'; + expect(makeIdempotent(stmt)).toBe(stmt); + }); +}); + +describe("loadSchemaStatements over the committed schema.sql (#484)", () => { + it("returns only idempotent CREATE statements", () => { + const statements = loadSchemaStatements(SCHEMA_SQL); + expect(statements.length).toBeGreaterThan(0); + for (const stmt of statements) { + expect(stmt).toMatch(/^CREATE (TABLE|UNIQUE INDEX|INDEX) IF NOT EXISTS/i); + } + }); + + it("covers every model in schema.prisma (committed DDL is in sync)", () => { + // Catches a schema change that forgot `npm run prisma:sql`. + const prisma = fs.readFileSync(SCHEMA_PRISMA, "utf8"); + const models = [...prisma.matchAll(/^model\s+(\w+)\s*\{/gm)].map((m) => m[1]); + expect(models.length).toBeGreaterThan(0); + const sql = fs.readFileSync(SCHEMA_SQL, "utf8"); + for (const model of models) { + expect(sql).toMatch(new RegExp(`CREATE TABLE "${model}"`)); + } + }); +}); + +describe("server startup avoids the native Prisma schema-engine (#484)", () => { + it("applies schema.sql at boot and never runs `prisma db push`", () => { + // Locks the fix: the Linux start smoke passes either way (its schema-engine + // works), so this source contract is what stops a regression back to the + // schema-engine path that fails on the operator's macOS arm64. + const src = fs.readFileSync(path.join(process.cwd(), "app", "server.ts"), "utf8"); + // Ignore comment lines (which legitimately *mention* `prisma db push`). + const code = src + .split("\n") + .filter((l) => { + const t = l.trim(); + return !t.startsWith("//") && !t.startsWith("*") && !t.startsWith("/*"); + }) + .join("\n"); + expect(code).toContain("loadSchemaStatements"); + expect(code).not.toMatch(/db\s+push/); + expect(code).not.toMatch(/execFileSync|execSync/); // no child-process shell-out for DB setup + }); +}); diff --git a/app/lib/apply-schema.ts b/app/lib/apply-schema.ts new file mode 100644 index 0000000..08156ec --- /dev/null +++ b/app/lib/apply-schema.ts @@ -0,0 +1,55 @@ +import fs from "fs"; + +/** + * Local SQLite schema setup WITHOUT the native Prisma schema-engine (#484). + * + * The installed `plotlink-ows` package must bring its SQLite schema up at + * startup, but `prisma db push` spawns a platform-specific schema-engine binary + * that fails to start in some packed prod-only installs (an empty + * "Schema engine error:" on macOS arm64). The Prisma *client* the app already + * uses runs on the library query engine — a different, reliably-present engine — + * and can execute the DDL directly via `$executeRawUnsafe`. + * + * So we ship the canonical DDL as `app/prisma/schema.sql` (generated from + * `schema.prisma` with `npm run prisma:sql`) and apply it through the client. + */ + +/** + * Split a committed `.sql` file into individual executable statements, dropping + * `-- ...` comment lines and blanks. Our DDL is a small, controlled grammar + * (CREATE TABLE / CREATE [UNIQUE] INDEX) with no semicolons inside values, so a + * `;`-split is safe here. + */ +export function parseSqlStatements(sql: string): string[] { + return sql + .split(";") + .map((chunk) => + chunk + .split("\n") + .filter((line) => !line.trim().startsWith("--")) + .join("\n") + .trim(), + ) + .filter((stmt) => stmt.length > 0); +} + +/** + * Rewrite a CREATE statement to be idempotent so applying the schema on an + * already-initialized database is a no-op (the app applies it on every startup). + * Only the CREATE TABLE / CREATE [UNIQUE] INDEX forms our schema emits are + * rewritten; anything else is returned unchanged. + */ +export function makeIdempotent(statement: string): string { + return statement + .replace(/^CREATE TABLE\s+(?!IF NOT EXISTS)/i, "CREATE TABLE IF NOT EXISTS ") + .replace( + /^CREATE\s+(UNIQUE\s+)?INDEX\s+(?!IF NOT EXISTS)/i, + (_match, unique) => `CREATE ${unique ? "UNIQUE " : ""}INDEX IF NOT EXISTS `, + ); +} + +/** Read the committed schema DDL and return idempotent, ready-to-execute statements. */ +export function loadSchemaStatements(schemaSqlPath: string): string[] { + const sql = fs.readFileSync(schemaSqlPath, "utf8"); + return parseSqlStatements(sql).map(makeIdempotent); +} diff --git a/app/lib/prisma-cli.test.ts b/app/lib/prisma-cli.test.ts deleted file mode 100644 index 2cb8292..0000000 --- a/app/lib/prisma-cli.test.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { describe, it, expect } from "vitest"; -import fs from "fs"; -import { resolvePrismaCli } from "./prisma-cli"; - -// #479: startup runs `db push` via the locally-resolved Prisma CLI invoked with -// `node`, never `npx prisma` (which can hit the network / depends on cwd). The -// resolver must return a real, existing CLI path from the package's node_modules. -describe("resolvePrismaCli (#479)", () => { - it("resolves to an existing local prisma CLI entry", () => { - // vitest runs from the repo root, which has prisma installed in node_modules. - const cli = resolvePrismaCli(process.cwd()); - expect(cli).toMatch(/prisma[\\/]build[\\/]index\.js$/); - expect(fs.existsSync(cli)).toBe(true); - }); - - it("throws a clear error when prisma cannot be resolved from the base dir", () => { - // A directory with no reachable node_modules/prisma (filesystem root). - expect(() => resolvePrismaCli("/")).toThrow(); - }); -}); diff --git a/app/lib/prisma-cli.ts b/app/lib/prisma-cli.ts deleted file mode 100644 index 8fa3652..0000000 --- a/app/lib/prisma-cli.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { createRequire } from "module"; -import fs from "fs"; -import path from "path"; - -/** - * Absolute path to the locally-installed Prisma CLI entry (`prisma/build/index.js`), - * resolved from `baseDir` via Node module resolution — it walks up `node_modules`, - * so it finds the CLI whether deps are nested (source checkout) or hoisted to a - * sibling `node_modules` (a packed prod-only / global install). - * - * Startup runs `db push` by invoking this with `node ` instead of - * `npx prisma` (#479). `npx prisma` resolves relative to the process cwd and, if - * it can't find the bin there, tries to DOWNLOAD prisma from the registry — so a - * packed prod-only install started from an unexpected cwd, or any offline/sealed - * environment, would hang or exit during `db push`. Resolving the local CLI - * explicitly removes both the network dependency and the cwd ambiguity. - * - * Throws a clear error (caught and surfaced by the caller) if the CLI can't be - * found — that means a corrupted install missing the `prisma` runtime dependency. - */ -export function resolvePrismaCli(baseDir: string): string { - // The referrer file need not exist; createRequire only uses its directory as - // the module-resolution base. - const requireFrom = createRequire(path.join(baseDir, "__prisma-cli-resolver__.js")); - const pkgJsonPath = requireFrom.resolve("prisma/package.json"); - const pkg = JSON.parse(fs.readFileSync(pkgJsonPath, "utf8")) as { bin?: string | Record }; - const binRel = typeof pkg.bin === "string" ? pkg.bin : pkg.bin?.prisma; - if (!binRel) { - throw new Error(`the installed 'prisma' package has no bin entry (resolved ${pkgJsonPath})`); - } - return path.join(path.dirname(pkgJsonPath), binRel); -} diff --git a/app/prisma/schema.sql b/app/prisma/schema.sql new file mode 100644 index 0000000..9535154 --- /dev/null +++ b/app/prisma/schema.sql @@ -0,0 +1,25 @@ +-- Canonical SQLite DDL for the local writer database. +-- GENERATED from app/prisma/schema.prisma — do not edit by hand. +-- Regenerate after any schema change: npm run prisma:sql +-- +-- Applied idempotently at startup via the Prisma client's library query engine +-- (app/lib/apply-schema.ts) so the installed package never invokes the native +-- Prisma schema-engine (`prisma db push`), which fails to spawn in some packed +-- prod-only environments (#484, EPIC #465). + +-- CreateTable +CREATE TABLE "Session" ( + "id" TEXT NOT NULL PRIMARY KEY, + "token" TEXT NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "expiresAt" DATETIME NOT NULL +); + +-- CreateTable +CREATE TABLE "Setting" ( + "key" TEXT NOT NULL PRIMARY KEY, + "value" TEXT NOT NULL +); + +-- CreateIndex +CREATE UNIQUE INDEX "Session_token_key" ON "Session"("token"); diff --git a/app/server.ts b/app/server.ts index faf454d..0f0de08 100644 --- a/app/server.ts +++ b/app/server.ts @@ -24,10 +24,9 @@ import { storiesRoutes } from "./routes/stories"; import { settingsRoutes } from "./routes/settings"; import { agentRoutes } from "./routes/agent"; import { codexImagesRoutes } from "./routes/codex-images"; -import { initDb } from "./db"; +import { db, initDb } from "./db"; import { generateClaudeMd } from "./lib/generate-claude-md"; -import { execFileSync } from "child_process"; -import { resolvePrismaCli } from "./lib/prisma-cli"; +import { loadSchemaStatements } from "./lib/apply-schema"; import fs from "fs"; const __dirname = __dirnamePre; @@ -135,39 +134,37 @@ async function start() { // Generate/update ~/.plotlink-ows/CLAUDE.md for agent discovery generateClaudeMd(); - // Bring the local SQLite schema up to date. SQLite creates the DB file but NOT - // its parent directory, so ensure ~/.plotlink-ows/data exists first (#479) — - // a fresh prod-only install has no data dir yet, and `db push` would fail with - // an opaque "unable to open database file" otherwise. (paths.ts also mkdirs it - // on import; this is the explicit guarantee right before SQLite setup.) + // Bring the local SQLite schema up to date WITHOUT the native Prisma + // schema-engine. `prisma db push` spawns a platform-specific schema-engine + // binary that fails to start in some packed prod-only installs (#484, EPIC + // #465: an empty "Schema engine error:" on macOS arm64). Instead we apply the + // committed DDL (app/prisma/schema.sql, generated from schema.prisma) through + // the Prisma client's library query engine — the same engine the app already + // uses for every query, so if the app runs at all, schema setup runs too. + // SQLite creates the DB file but NOT its parent dir, so ensure + // ~/.plotlink-ows/data exists first (a fresh prod-only install has none). fs.mkdirSync(DATA_DIR, { recursive: true }); - const schemaPath = path.join(__dirname, "prisma", "schema.prisma"); + const schemaSqlPath = path.join(__dirname, "prisma", "schema.sql"); try { - // Invoke the locally-resolved Prisma CLI via `node` (NOT `npx prisma`, which - // resolves against the cwd and would try to download prisma from the network - // in a packed prod-only install started from an unexpected cwd, or offline). - const prismaCli = resolvePrismaCli(__dirname); - execFileSync(process.execPath, [prismaCli, "db", "push", "--schema", schemaPath, "--skip-generate"], { - stdio: "inherit", - cwd: path.dirname(__dirname), // package root, so relative resolutions are stable - env: { ...process.env, DATABASE_URL }, - }); + await initDb(); // connect the client (library query engine; no schema-engine) + // Statements are CREATE TABLE/INDEX IF NOT EXISTS, so this is idempotent and + // safe to run on every startup against an existing database. + for (const statement of loadSchemaStatements(schemaSqlPath)) { + await db.$executeRawUnsafe(statement); + } } catch (err) { - // Surface a useful diagnostic instead of a raw execFileSync stack (#479). + // Surface a useful diagnostic instead of a raw stack (#479/#484). const home = os.homedir(); const redact = (s: string) => s.split(home).join("~"); - console.error("\n ✗ Database setup failed (prisma db push)."); - console.error(` schema: ${redact(schemaPath)}`); + console.error("\n ✗ Database setup failed (applying schema.sql)."); + console.error(` schema: ${redact(schemaSqlPath)}`); console.error(` database: ${redact(DATABASE_URL)}`); console.error(` reason: ${err instanceof Error ? err.message : String(err)}`); - console.error(" This usually means a corrupted install (missing the 'prisma' runtime"); - console.error(" dependency or its query engine). Reinstall with: npx plotlink-ows@latest\n"); + console.error(" This usually means a corrupted install (missing the generated Prisma"); + console.error(" client/query engine or schema.sql). Reinstall with: npx plotlink-ows@latest\n"); process.exit(1); } - // Initialize database connection - await initDb(); - const port = Number(process.env.APP_PORT) || 7777; const server = serve({ fetch: app.fetch, port }, (info) => { console.log(`\n PlotLink OWS running at http://localhost:${info.port}\n`); diff --git a/package-lock.json b/package-lock.json index d8e2b62..a7d7402 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "plotlink-ows", - "version": "1.2.93", + "version": "1.2.94", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "plotlink-ows", - "version": "1.2.93", + "version": "1.2.94", "hasInstallScript": true, "workspaces": [ "packages/*" diff --git a/package.json b/package.json index ab7e77b..a87571f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "plotlink-ows", - "version": "1.2.93", + "version": "1.2.94", "packageManager": "npm@10.9.8", "engines": { "node": "20.x", @@ -68,6 +68,7 @@ "prepublishOnly": "npm run app:build", "postinstall": "prisma generate --schema app/prisma/schema.prisma", "prisma:local": "prisma generate --schema app/prisma/schema.prisma && prisma db push --schema app/prisma/schema.prisma", + "prisma:sql": "node scripts/gen-schema-sql.mjs", "release:patch": "npm version patch && git push origin main --follow-tags && VERSION=$(node -p 'require(\"./package.json\").version') && gh release create \"v$VERSION\" --generate-notes --latest && npm publish", "release:minor": "npm version minor && git push origin main --follow-tags && VERSION=$(node -p 'require(\"./package.json\").version') && gh release create \"v$VERSION\" --generate-notes --latest && npm publish", "release:major": "npm version major && git push origin main --follow-tags && VERSION=$(node -p 'require(\"./package.json\").version') && gh release create \"v$VERSION\" --generate-notes --latest && npm publish" diff --git a/scripts/gen-schema-sql.mjs b/scripts/gen-schema-sql.mjs new file mode 100644 index 0000000..2463c9e --- /dev/null +++ b/scripts/gen-schema-sql.mjs @@ -0,0 +1,49 @@ +#!/usr/bin/env node +// Regenerate app/prisma/schema.sql from app/prisma/schema.prisma (#484). +// +// The installed app applies this committed DDL at startup via the Prisma client +// (app/lib/apply-schema.ts) instead of running `prisma db push`, so the native +// Prisma schema-engine is never needed at runtime. Run this (and commit the +// result) after ANY change to schema.prisma: npm run prisma:sql +// +// This uses the schema-engine via `prisma migrate diff` — that's fine here +// because it runs at DEV/build time on a developer machine, not at user runtime. + +import { execFileSync } from "node:child_process"; +import { writeFileSync, readFileSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { createRequire } from "node:module"; + +const root = join(dirname(fileURLToPath(import.meta.url)), ".."); +const schemaPath = join(root, "app", "prisma", "schema.prisma"); +const outPath = join(root, "app", "prisma", "schema.sql"); + +// Resolve the local Prisma CLI (same robust resolution the runtime once used). +const requireFrom = createRequire(join(root, "__resolver__.js")); +const prismaPkg = requireFrom.resolve("prisma/package.json"); +const pkg = JSON.parse(readFileSync(prismaPkg, "utf8")); +const binRel = typeof pkg.bin === "string" ? pkg.bin : pkg.bin.prisma; +const prismaCli = join(dirname(prismaPkg), binRel); + +const ddl = execFileSync( + process.execPath, + [prismaCli, "migrate", "diff", "--from-empty", "--to-schema-datamodel", schemaPath, "--script"], + { encoding: "utf8" }, +).trim(); + +const header = [ + "-- Canonical SQLite DDL for the local writer database.", + "-- GENERATED from app/prisma/schema.prisma — do not edit by hand.", + "-- Regenerate after any schema change: npm run prisma:sql", + "--", + "-- Applied idempotently at startup via the Prisma client's library query engine", + "-- (app/lib/apply-schema.ts) so the installed package never invokes the native", + "-- Prisma schema-engine (`prisma db push`), which fails to spawn in some packed", + "-- prod-only environments (#484, EPIC #465).", + "", + "", +].join("\n"); + +writeFileSync(outPath, header + ddl + "\n"); +console.log(`Wrote ${outPath}`); diff --git a/scripts/package-hygiene.mjs b/scripts/package-hygiene.mjs index 3a69987..b1501a3 100644 --- a/scripts/package-hygiene.mjs +++ b/scripts/package-hygiene.mjs @@ -31,9 +31,12 @@ export const REQUIRED_PACK_FILES = [ // if a future exclusion drops it (#470). "bin/startup-plan.cjs", "app/server.ts", - // Imported by app/server.ts at boot to locate the local Prisma CLI for the - // startup `db push` (#479). - "app/lib/prisma-cli.ts", + // Imported by app/server.ts at boot to apply the local SQLite schema without + // the native Prisma schema-engine (#484). + "app/lib/apply-schema.ts", + // The committed DDL apply-schema reads at startup — the installed app applies + // this instead of running `prisma db push` (#484). + "app/prisma/schema.sql", "app/prisma/schema.prisma", "app/web/dist/index.html", // Root-lib file the server runtime imports at boot (publish route → diff --git a/scripts/package-hygiene.test.ts b/scripts/package-hygiene.test.ts index a1003e1..e1f6af2 100644 --- a/scripts/package-hygiene.test.ts +++ b/scripts/package-hygiene.test.ts @@ -97,8 +97,9 @@ describe("package hygiene suspicious-file detection (#466)", () => { expect(REQUIRED_PACK_FILES).toContain("lib/genres.ts"); // #470: the bin requires this start-path helper at runtime. expect(REQUIRED_PACK_FILES).toContain("bin/startup-plan.cjs"); - // #479: server.ts imports this at boot to locate the Prisma CLI. - expect(REQUIRED_PACK_FILES).toContain("app/lib/prisma-cli.ts"); + // #484: server.ts applies this committed DDL at boot (no schema-engine). + expect(REQUIRED_PACK_FILES).toContain("app/lib/apply-schema.ts"); + expect(REQUIRED_PACK_FILES).toContain("app/prisma/schema.sql"); }); it("exposes a stable, non-empty rule set", () => {