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
101 changes: 101 additions & 0 deletions app/lib/apply-schema.test.ts
Original file line number Diff line number Diff line change
@@ -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
});
});
55 changes: 55 additions & 0 deletions app/lib/apply-schema.ts
Original file line number Diff line number Diff line change
@@ -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);
}
20 changes: 0 additions & 20 deletions app/lib/prisma-cli.test.ts

This file was deleted.

32 changes: 0 additions & 32 deletions app/lib/prisma-cli.ts

This file was deleted.

25 changes: 25 additions & 0 deletions app/prisma/schema.sql
Original file line number Diff line number Diff line change
@@ -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");
49 changes: 23 additions & 26 deletions app/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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`);
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

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

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "plotlink-ows",
"version": "1.2.93",
"version": "1.2.94",
"packageManager": "npm@10.9.8",
"engines": {
"node": "20.x",
Expand Down Expand Up @@ -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"
Expand Down
49 changes: 49 additions & 0 deletions scripts/gen-schema-sql.mjs
Original file line number Diff line number Diff line change
@@ -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}`);
Loading
Loading