From fd8cac120f4785304965b0125723f407ce7a1ff7 Mon Sep 17 00:00:00 2001 From: Fangore Date: Wed, 28 Oct 2026 07:30:13 +0000 Subject: [PATCH] feat: support SQLite as an alternative to PostgreSQL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DATABASE_URL picks the database: postgresql://… as before, or file:/absolute/path.db for SQLite, so Dossier can run as a single container (#17). PostgreSQL stays the default and is unchanged. - prisma/sqlite/schema.prisma is generated from prisma/schema.prisma (yarn sqlite-schema; a test fails when it's stale), with its own migrations; prisma7.config.ts picks the pair by DATABASE_URL. - createPrismaClient() in src/lib/database.ts picks the client and adapter. On SQLite, queries from outside an open transaction wait for it to end, so a rollback can't undo them. - FOR UPDATE only on PostgreSQL; skipDuplicates replaced by upserts or deduplication; enum order sorted in code where it matters. - Citext's case-insensitive names become COLLATE NOCASE, and the built-in theme trigger is ported to SQLite. - CI runs the database tests on SQLite too; README, DEVELOPMENT.md and .env.example document it. Closes #17 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011GfGRofhBkPHKP9dUZYLr9 --- .env.example | 3 +- .github/workflows/ci.yml | 8 +- .gitignore | 1 + DEVELOPMENT.md | 18 +- Dockerfile | 10 +- README.md | 14 +- package.json | 10 +- prisma/seed.ts | 6 +- prisma/seed/seed.db.test.ts | 5 +- prisma/sqlite-schema.test.ts | 16 + prisma/sqlite-schema.ts | 39 + .../20261028000000_init/migration.sql | 561 +++++++++++ prisma/sqlite/migrations/migration_lock.toml | 3 + prisma/sqlite/schema.prisma | 935 ++++++++++++++++++ prisma7.config.ts | 8 +- src/app/(app)/new/actions.db.test.ts | 6 +- src/app/(app)/profile/actions.ts | 15 +- src/app/(app)/settings/page.tsx | 7 +- src/lib/agent/draft.ts | 3 +- src/lib/auth/login-throttle.ts | 3 +- src/lib/data-import.ts | 20 +- src/lib/database.db.test.ts | 42 + src/lib/database.ts | 104 ++ src/lib/document-units.db.test.ts | 4 +- src/lib/env.test.ts | 7 + src/lib/env.ts | 10 +- src/lib/jobs/zip-pdfs.ts | 7 +- src/lib/prisma.ts | 9 +- src/lib/startups/store.ts | 13 +- src/lib/status-changes.ts | 5 +- src/test/db.ts | 36 +- yarn.lock | 333 ++++++- 32 files changed, 2192 insertions(+), 69 deletions(-) create mode 100644 prisma/sqlite-schema.test.ts create mode 100644 prisma/sqlite-schema.ts create mode 100644 prisma/sqlite/migrations/20261028000000_init/migration.sql create mode 100644 prisma/sqlite/migrations/migration_lock.toml create mode 100644 prisma/sqlite/schema.prisma create mode 100644 src/lib/database.db.test.ts create mode 100644 src/lib/database.ts diff --git a/.env.example b/.env.example index 46e9a931..12f29f37 100644 --- a/.env.example +++ b/.env.example @@ -16,7 +16,8 @@ DOSSIER_SECRET= # PostgreSQL connection string, used by Prisma and the app when run on the host # (e.g. `npx prisma studio`). Inside Docker, compose.yml points it at the `db` -# service instead. +# service instead. For SQLite, file: and an absolute path instead, e.g. +# file:/data/dossier.db. DATABASE_URL="postgresql://postgres:postgres@localhost:5432/dossier" # --- Optional --------------------------------------------------------------- diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 792fcf08..04c3f1d5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -97,11 +97,15 @@ jobs: restore-keys: yarn-${{ runner.os }}- - run: yarn install --immutable - - run: yarn prisma generate + - run: yarn generate - run: yarn lint - run: yarn typecheck # Fails when coverage drops below the thresholds in vitest.config.mts (E11-1). - run: yarn test:coverage + # The database tests again, on SQLite (#17). + - run: yarn vitest run --project db + env: + DATABASE_URL: file:${{ runner.temp }}/dossier.db - name: Coverage summary if: always() run: node .github/scripts/coverage-summary.mjs >> "$GITHUB_STEP_SUMMARY" @@ -150,7 +154,7 @@ jobs: restore-keys: yarn-${{ runner.os }}- - run: yarn install --immutable - - run: yarn prisma generate + - run: yarn generate # Headless Chromium, for the tests and for the app's PDFs. - run: yarn playwright install --with-deps --only-shell chromium - run: yarn build diff --git a/.gitignore b/.gitignore index 7580fe6a..8f4fb64e 100644 --- a/.gitignore +++ b/.gitignore @@ -48,6 +48,7 @@ yarn-error.log* next-env.d.ts /src/generated/prisma +/src/generated/prisma-sqlite # DOSSIER_DATA_DIR in development /data diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 7fe7f609..88290ca3 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -21,7 +21,7 @@ Server code reads configuration from the validated `env` object instead of `proc import { env } from "@/lib/env"; ``` -On every start the `app` container runs `prisma generate`, `prisma migrate deploy`, then `next dev`. +On every start the `app` container runs `yarn generate` (the Prisma Clients for PostgreSQL and SQLite), `prisma migrate deploy`, then `next dev`. Postgres is also exposed on `localhost:5432` for host tools, using `DATABASE_URL` from `.env`. ## Yarn @@ -41,10 +41,21 @@ Define models in `prisma/schema.prisma`, then create and apply a migration: ```bash docker/bin/yarn prisma migrate dev --name +docker/bin/yarn sqlite-schema +docker compose exec -e DATABASE_URL=file:/tmp/dossier.db app yarn prisma migrate dev --name docker compose restart app ``` -The restart regenerates the Prisma Client and picks it up; since Prisma 7, `migrate dev` no longer runs `prisma generate` on its own. +The restart regenerates the Prisma Clients and picks them up; since Prisma 7, `migrate dev` no longer runs `prisma generate` on its own. + +### SQLite + +Dossier also runs on SQLite (#17), when `DATABASE_URL` is `file:` and an absolute path. SQLite has its own schema and migrations: `prisma/sqlite/schema.prisma` is generated from `prisma/schema.prisma` by `yarn sqlite-schema` (a test fails while it's out of date), and its migrations in `prisma/sqlite/migrations` are made by the second `migrate dev` above. `prisma7.config.ts` picks the pair that matches `DATABASE_URL`, and `createPrismaClient()` in `src/lib/database.ts` the client. The app is typed with the PostgreSQL client, so code doesn't change, except where the two databases differ: + +- Raw SQL: lock rows with `${forUpdate()}` instead of `FOR UPDATE`, and keep to SQL both understand. +- `createMany({ skipDuplicates: true })` isn't available on SQLite: use an `upsert` with `update: {}`, or leave out the duplicates first. +- Enums sort by name on SQLite, not in the order they're declared: sort in code when the order matters. +- Skill and language names are unique ignoring case with `COLLATE NOCASE`, and built-in themes are protected by a trigger, both added to the first SQLite migration by hand. When Prisma redefines one of those tables in a later migration, add them again. To browse and edit rows in Prisma Studio, start it on demand (plain `docker compose up` leaves it off) and open [http://localhost:5555](http://localhost:5555): @@ -220,8 +231,9 @@ Put tests next to the code they cover. `*.test.ts` files run in Node (server cod `*.test.tsx` files run in jsdom with [Testing Library](https://testing-library.com) and its `jest-dom` matchers (components and hooks). `*.db.test.ts` files run against a real Postgres database: `dossier_test` on the same server (or `TEST_DATABASE_URL`), which is created and migrated at the start of each run, and which each test empties with `resetDatabase()` from `src/test/db.ts`. They refuse to run against a database whose name doesn't end in `_test`, so your development data is never touched. +To run them on SQLite, point `DATABASE_URL` at a file (`dossier_test.db` next to it is used): `docker compose exec -e DATABASE_URL=file:/tmp/dossier.db app yarn vitest run --project db`. -GitHub Actions (`.github/workflows/ci.yml`) runs lint, type-check and tests with coverage, with a Postgres service for the database tests, on every pull request and push to `main`, and shows the coverage in the job summary. A newer push to the same branch or pull request cancels the run it replaces. On `main`, a pull request's merge commit isn't checked a second time when it holds exactly the code its pull request's run checked (the branch was up to date with `main`); every other push to `main` is. The coverage thresholds in `vitest.config.mts` ratchet: they sit a few points below the coverage when they were set, and CI fails if coverage drops below them. Raise them when you add tests. +GitHub Actions (`.github/workflows/ci.yml`) runs lint, type-check and tests with coverage, with a Postgres service for the database tests, then the database tests again on SQLite, on every pull request and push to `main`, and shows the coverage in the job summary. A newer push to the same branch or pull request cancels the run it replaces. On `main`, a pull request's merge commit isn't checked a second time when it holds exactly the code its pull request's run checked (the branch was up to date with `main`); every other push to `main` is. The coverage thresholds in `vitest.config.mts` ratchet: they sit a few points below the coverage when they were set, and CI fails if coverage drops below them. Raise them when you add tests. ### End-to-end tests diff --git a/Dockerfile b/Dockerfile index 6245e693..c09b150a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -25,7 +25,7 @@ RUN node node_modules/playwright-core/cli.js install --with-deps --only-shell ch USER node EXPOSE 3000 # The seed is idempotent: built-in themes, an empty profile and default settings. -CMD ["sh", "-c", "yarn prisma generate && yarn prisma migrate deploy && yarn prisma db seed && yarn dev"] +CMD ["sh", "-c", "yarn generate && yarn prisma migrate deploy && yarn prisma db seed && yarn dev"] # ── Production (E12-1) ─────────────────────────────────────────────────────── # Dependencies, then the build, then a runtime with only what running needs: @@ -39,7 +39,7 @@ RUN yarn install --immutable FROM deps AS build ENV NEXT_TELEMETRY_DISABLED=1 COPY . . -RUN yarn prisma generate && yarn build +RUN yarn generate && yarn build # Prisma's CLI for `migrate deploy`, at the version in yarn.lock, with the # same resolutions as package.json; not the rest of the dev dependencies. RUN mkdir /tools && node -e ' \ @@ -53,9 +53,11 @@ RUN mkdir /tools && node -e ' \ && cd /tools && npm install --omit=dev --no-audit --no-fund \ && cp -r /app/prisma7.config.ts /app/prisma /tools/ # The seed as one JavaScript file, so it runs without tsx (E12-3). Bundled -# CommonJS packages (pg) need a real require. +# CommonJS packages (pg) need a real require. better-sqlite3 loads its native +# module by path, so it stays out: the seed finds the server's copy in +# /app/node_modules. RUN node_modules/.bin/esbuild prisma/seed.ts --bundle --platform=node --format=esm --target=node24 \ - --external:pg-native --outfile=/tools/seed.mjs \ + --external:pg-native --external:better-sqlite3 --outfile=/tools/seed.mjs \ --banner:js='import { createRequire } from "node:module"; const require = createRequire(import.meta.url);' # `hash-password`, which makes the hash for DOSSIER_PASSWORD, as one file too. RUN node_modules/.bin/esbuild scripts/hash-password.ts --bundle --platform=node --format=esm --target=node24 \ diff --git a/README.md b/README.md index 502e44fc..e7b7a8e4 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ Dossier keeps your career history in one profile and turns it into a clean resume. For every job you apply to, it reads the posting, shows how you match, and drafts a tailored resume and cover letter that only use facts from your profile. You refine them in a chat, download them as PDFs and track each application from draft to offer. -It's for one person on a small server: one password, your data in your own PostgreSQL database, and an AI model of your choice. That can be a local model through Ollama or LM Studio, so your profile and documents stay on your own hardware, or a hosted one (any OpenAI-compatible API, Anthropic or Mistral), which then receives what it writes from. [Privacy](https://github.com/AienTech/dossier/wiki/Privacy) says what goes where. +It's for one person on a small server: one password, your data in your own PostgreSQL database (or a single SQLite file), and an AI model of your choice. That can be a local model through Ollama or LM Studio, so your profile and documents stay on your own hardware, or a hosted one (any OpenAI-compatible API, Anthropic or Mistral), which then receives what it writes from. [Privacy](https://github.com/AienTech/dossier/wiki/Privacy) says what goes where. **Get started:** [run it on your computer](#run-it-on-your-computer) in a few minutes, or [self-host it](#self-hosting) on a server. The [installation guide](https://github.com/AienTech/dossier/wiki) covers every way: Docker with or without Compose, Node.js, a plain Ubuntu server, CapRover, Coolify and Dokploy. @@ -103,6 +103,18 @@ The first start downloads the image (about 1.7 GB) and sets up the database. Ope `docker compose stop` stops it and `docker compose start` starts it again, with your data kept. It's only reachable from your own computer. The [installation guide's Docker Compose page](https://github.com/AienTech/dossier/wiki/Run-locally-with-Docker-Compose) has more: another port, your time zone, upgrades and removing it. +### Without a database server (SQLite) + +Dossier can keep everything in one SQLite file instead of PostgreSQL, so it runs as a single container. Put `DOSSIER_SECRET` and `DOSSIER_PASSWORD` in `.env` as in steps 1 and 2 (skip `POSTGRES_PASSWORD`), then: + +```bash +docker run -d --name dossier --restart unless-stopped --platform linux/amd64 -p 127.0.0.1:3000:3000 \ + --env-file .env -e DATABASE_URL=file:/data/dossier.db -v dossier-data:/data \ + ghcr.io/aientech/dossier +``` + +The database, the nightly backups and the export files all live in the `dossier-data` volume. To copy the database file itself, stop the container first. To move between PostgreSQL and SQLite, export your data in **Settings → Your data** and import it on the other. + ## Self-hosting To put Dossier on a server, on your own domain with HTTPS, follow the [installation guide](https://github.com/AienTech/dossier/wiki#self-host-on-a-server): a plain Ubuntu server with Docker Compose and Caddy (or Node.js, without Docker), CapRover, Coolify or Dokploy. Give the server at least 2 vCPUs and 2 GB of memory (see [Performance and sizing](#performance-and-sizing)). diff --git a/package.json b/package.json index 121561af..081ff834 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,9 @@ "test:watch": "vitest", "test:coverage": "vitest run --coverage", "test:e2e": "playwright test", - "hash-password": "tsx scripts/hash-password.ts" + "hash-password": "tsx scripts/hash-password.ts", + "generate": "prisma generate --schema prisma/schema.prisma && prisma generate --schema prisma/sqlite/schema.prisma", + "sqlite-schema": "tsx prisma/sqlite-schema.ts" }, "dependencies": { "@anthropic-ai/sdk": "^0.128.0", @@ -21,6 +23,7 @@ "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@modelcontextprotocol/server": "^2.3.1", + "@prisma/adapter-better-sqlite3": "^7.10.0", "@prisma/adapter-pg": "^7.10.0", "@prisma/client": "^7.10.0", "fflate": "^0.8.3", @@ -69,5 +72,10 @@ "packageManager": "yarn@4.18.1+sha512.b2e1e7524f654f2749d32b4ebcb4622473cb5bcbc485df2007e12a154e50162a4d795526768bc5f5b8f81717bfd79deb2472813d86fb5ae2eb551fa9c872b08f", "resolutions": { "mysql2@npm:3.15.3": "3.24.4" + }, + "dependenciesMeta": { + "better-sqlite3": { + "built": true + } } } diff --git a/prisma/seed.ts b/prisma/seed.ts index dda347bf..ae139745 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -4,9 +4,7 @@ // docker/bin/yarn prisma db seed -- --demo plus the prototype's sample data // docker/bin/yarn prisma db seed -- --demo --reset replacing an existing profile and applications -import { PrismaPg } from "@prisma/adapter-pg"; - -import { PrismaClient } from "@/generated/prisma/client"; +import { createPrismaClient } from "@/lib/database"; import { seedBuiltIns } from "./seed/built-ins"; import { DemoRefused, seedDemo } from "./seed/demo"; @@ -20,7 +18,7 @@ async function main() { } if (flags.has("--reset") && !flags.has("--demo")) throw new Error("--reset only works together with --demo."); - const db = new PrismaClient({ adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL }) }); + const db = createPrismaClient(process.env.DATABASE_URL ?? ""); try { await seedBuiltIns(db); console.log("Seeded the built-in themes."); diff --git a/prisma/seed/seed.db.test.ts b/prisma/seed/seed.db.test.ts index 3355978f..66a99817 100644 --- a/prisma/seed/seed.db.test.ts +++ b/prisma/seed/seed.db.test.ts @@ -125,11 +125,12 @@ describe("demo seed", () => { await seedDemo(db, { now }); const ledgerly = await db.application.findFirstOrThrow({ where: { company: "Ledgerly" }, - include: { documents: { orderBy: { kind: "asc" } }, messages: true }, + include: { documents: true, messages: true }, }); expect(matchScore(matchSchema.parse(ledgerly.match))).toEqual({ hit: 5, total: 7 }); - const [resume, letter] = ledgerly.documents.map((document) => unitsSchema.parse(document.units)); + const units = (kind: string) => unitsSchema.parse(ledgerly.documents.find((document) => document.kind === kind)?.units); + const [resume, letter] = [units("RESUME"), units("COVER_LETTER")]; expect(resume.map((unit) => unit.label)).toEqual([ "Summary", "Northwind Logistics role", diff --git a/prisma/sqlite-schema.test.ts b/prisma/sqlite-schema.test.ts new file mode 100644 index 00000000..b5be5d10 --- /dev/null +++ b/prisma/sqlite-schema.test.ts @@ -0,0 +1,16 @@ +import { readFileSync } from "node:fs"; + +import { describe, expect, it } from "vitest"; + +import { POSTGRES_SCHEMA, SQLITE_SCHEMA, sqliteSchema } from "./sqlite-schema"; + +describe("the SQLite schema", () => { + it("is up to date with prisma/schema.prisma (run `yarn sqlite-schema` if not)", () => { + expect(readFileSync(SQLITE_SCHEMA, "utf8")).toBe(sqliteSchema(readFileSync(POSTGRES_SCHEMA, "utf8"))); + }); + + it("refuses a native type it doesn't know how to translate", () => { + const schema = 'provider = "postgresql"\noutput = "../src/generated/prisma"\n price Decimal @db.Money\n'; + expect(() => sqliteSchema(schema)).toThrow("@db.Money has no SQLite equivalent"); + }); +}); diff --git a/prisma/sqlite-schema.ts b/prisma/sqlite-schema.ts new file mode 100644 index 00000000..57ec952e --- /dev/null +++ b/prisma/sqlite-schema.ts @@ -0,0 +1,39 @@ +// Writes prisma/sqlite/schema.prisma, the SQLite copy of prisma/schema.prisma +// (#17). Run it after every change to the schema: +// +// docker/bin/yarn sqlite-schema + +import { readFileSync, writeFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; + +export const POSTGRES_SCHEMA = fileURLToPath(new URL("schema.prisma", import.meta.url)); +export const SQLITE_SCHEMA = fileURLToPath(new URL("sqlite/schema.prisma", import.meta.url)); + +const HEADER = `// Generated from prisma/schema.prisma by prisma/sqlite-schema.ts: don't edit, +// run \`docker/bin/yarn sqlite-schema\` instead. + +`; + +// Native types SQLite doesn't have. Citext's case-insensitive uniqueness is +// kept with COLLATE NOCASE in the SQLite migrations. +const DROPPED_TYPES = ["@db.Citext", "@db.Date"]; + +function replaceOnce(source: string, from: string, to: string): string { + if (source.split(from).length !== 2) throw new Error(`Expected exactly one ${from} in prisma/schema.prisma.`); + return source.replace(from, to); +} + +/** The SQLite version of the PostgreSQL schema. */ +export function sqliteSchema(postgres: string): string { + let schema = replaceOnce(postgres, 'provider = "postgresql"', 'provider = "sqlite"'); + schema = replaceOnce(schema, 'output = "../src/generated/prisma"', 'output = "../../src/generated/prisma-sqlite"'); + for (const type of DROPPED_TYPES) schema = schema.replaceAll(` ${type}`, ""); + const unknown = schema.match(/@db\.\w+/); + if (unknown) throw new Error(`${unknown[0]} has no SQLite equivalent yet: handle it in prisma/sqlite-schema.ts.`); + return HEADER + schema; +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + writeFileSync(SQLITE_SCHEMA, sqliteSchema(readFileSync(POSTGRES_SCHEMA, "utf8"))); + console.log("Wrote prisma/sqlite/schema.prisma."); +} diff --git a/prisma/sqlite/migrations/20261028000000_init/migration.sql b/prisma/sqlite/migrations/20261028000000_init/migration.sql new file mode 100644 index 00000000..b1dcade6 --- /dev/null +++ b/prisma/sqlite/migrations/20261028000000_init/migration.sql @@ -0,0 +1,561 @@ +-- The whole schema as of PostgreSQL migration 20261023100000_claude_pastes. +-- CreateTable +CREATE TABLE "ProfileField" ( + "key" TEXT NOT NULL PRIMARY KEY, + "value" TEXT NOT NULL, + "updatedAt" DATETIME NOT NULL +); + +-- CreateTable +CREATE TABLE "Skill" ( + "id" TEXT NOT NULL PRIMARY KEY, + -- Unique ignoring case, as citext in PostgreSQL. Keep it when Prisma + -- redefines the table in a later migration. + "name" TEXT NOT NULL COLLATE NOCASE, + "sort" INTEGER NOT NULL DEFAULT 0, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- CreateTable +CREATE TABLE "Language" ( + "id" TEXT NOT NULL PRIMARY KEY, + -- Unique ignoring case, as citext in PostgreSQL. Keep it when Prisma + -- redefines the table in a later migration. + "name" TEXT NOT NULL COLLATE NOCASE, + "level" TEXT NOT NULL, + "sort" INTEGER NOT NULL DEFAULT 0, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- CreateTable +CREATE TABLE "Experience" ( + "id" TEXT NOT NULL PRIMARY KEY, + "role" TEXT NOT NULL DEFAULT '', + "company" TEXT NOT NULL DEFAULT '', + "location" TEXT NOT NULL DEFAULT '', + "startYear" INTEGER, + "startMonth" INTEGER, + "endYear" INTEGER, + "endMonth" INTEGER, + "current" BOOLEAN NOT NULL DEFAULT false, + "bullets" TEXT NOT NULL DEFAULT '', + "sort" INTEGER NOT NULL DEFAULT 0, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL +); + +-- CreateTable +CREATE TABLE "ExperienceSkill" ( + "experienceId" TEXT NOT NULL, + "skillId" TEXT NOT NULL, + + PRIMARY KEY ("experienceId", "skillId"), + CONSTRAINT "ExperienceSkill_experienceId_fkey" FOREIGN KEY ("experienceId") REFERENCES "Experience" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "ExperienceSkill_skillId_fkey" FOREIGN KEY ("skillId") REFERENCES "Skill" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "Project" ( + "id" TEXT NOT NULL PRIMARY KEY, + "name" TEXT NOT NULL DEFAULT '', + "link" TEXT NOT NULL DEFAULT '', + "startYear" INTEGER, + "startMonth" INTEGER, + "endYear" INTEGER, + "endMonth" INTEGER, + "current" BOOLEAN NOT NULL DEFAULT false, + "bullets" TEXT NOT NULL DEFAULT '', + "sort" INTEGER NOT NULL DEFAULT 0, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL +); + +-- CreateTable +CREATE TABLE "ProjectSkill" ( + "projectId" TEXT NOT NULL, + "skillId" TEXT NOT NULL, + + PRIMARY KEY ("projectId", "skillId"), + CONSTRAINT "ProjectSkill_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "ProjectSkill_skillId_fkey" FOREIGN KEY ("skillId") REFERENCES "Skill" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "Education" ( + "id" TEXT NOT NULL PRIMARY KEY, + "degree" TEXT NOT NULL DEFAULT '', + "school" TEXT NOT NULL DEFAULT '', + "startYear" INTEGER, + "startMonth" INTEGER, + "endYear" INTEGER, + "endMonth" INTEGER, + "current" BOOLEAN NOT NULL DEFAULT false, + "notes" TEXT NOT NULL DEFAULT '', + "sort" INTEGER NOT NULL DEFAULT 0, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL +); + +-- CreateTable +CREATE TABLE "Publication" ( + "id" TEXT NOT NULL PRIMARY KEY, + "citation" TEXT NOT NULL DEFAULT '', + "link" TEXT NOT NULL DEFAULT '', + "onResumes" BOOLEAN NOT NULL DEFAULT true, + "sort" INTEGER NOT NULL DEFAULT 0, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL +); + +-- CreateTable +CREATE TABLE "Reference" ( + "id" TEXT NOT NULL PRIMARY KEY, + "name" TEXT NOT NULL DEFAULT '', + "relation" TEXT NOT NULL DEFAULT '', + "company" TEXT NOT NULL DEFAULT '', + "email" TEXT NOT NULL DEFAULT '', + "phone" TEXT NOT NULL DEFAULT '', + "sort" INTEGER NOT NULL DEFAULT 0, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL +); + +-- CreateTable +CREATE TABLE "Fact" ( + "id" TEXT NOT NULL PRIMARY KEY, + "applicationId" TEXT, + "text" TEXT NOT NULL DEFAULT '', + "sort" INTEGER NOT NULL DEFAULT 0, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL, + CONSTRAINT "Fact_applicationId_fkey" FOREIGN KEY ("applicationId") REFERENCES "Application" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "Theme" ( + "id" TEXT NOT NULL PRIMARY KEY, + "builtIn" BOOLEAN NOT NULL DEFAULT false, + "name" TEXT NOT NULL, + "headFont" TEXT NOT NULL, + "bodyFont" TEXT NOT NULL, + "accent" TEXT NOT NULL, + "layout" TEXT NOT NULL DEFAULT 'SINGLE', + "align" TEXT NOT NULL DEFAULT 'LEFT', + "headingStyle" TEXT NOT NULL DEFAULT 'PLAIN', + "density" TEXT NOT NULL DEFAULT 'REGULAR', + "headerStyle" TEXT NOT NULL DEFAULT 'PLAIN', + "sidebarStyle" TEXT NOT NULL DEFAULT 'PLAIN', + "dateStyle" TEXT NOT NULL DEFAULT 'INLINE', + "photoShape" TEXT NOT NULL DEFAULT 'CIRCLE', + "photoPosition" TEXT NOT NULL DEFAULT 'HEADER_END', + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL +); + +-- CreateTable +CREATE TABLE "Setting" ( + "key" TEXT NOT NULL PRIMARY KEY, + "value" TEXT NOT NULL, + "updatedAt" DATETIME NOT NULL +); + +-- CreateTable +CREATE TABLE "Application" ( + "id" TEXT NOT NULL PRIMARY KEY, + "origin" TEXT NOT NULL DEFAULT 'MANUAL', + "agentScore" INTEGER, + "awaitingReview" BOOLEAN NOT NULL DEFAULT false, + "title" TEXT NOT NULL DEFAULT '', + "company" TEXT NOT NULL DEFAULT '', + "role" TEXT NOT NULL DEFAULT '', + "location" TEXT NOT NULL DEFAULT '', + "mode" TEXT NOT NULL DEFAULT '', + "salary" TEXT NOT NULL DEFAULT '', + "deadline" DATETIME, + "postingUrl" TEXT NOT NULL DEFAULT '', + "postingText" TEXT NOT NULL DEFAULT '', + "extracted" JSONB, + "extractedWith" TEXT, + "match" JSONB, + "matchedWith" TEXT, + "status" TEXT NOT NULL DEFAULT 'DRAFT', + "furthestStage" TEXT, + "sentAt" DATETIME, + "nextInterviewAt" DATETIME, + "tone" TEXT NOT NULL DEFAULT 'WARM', + "language" TEXT NOT NULL DEFAULT '', + "followThemeLayout" BOOLEAN NOT NULL DEFAULT false, + "showPhoto" BOOLEAN, + "archivedAt" DATETIME, + "emailThreadId" TEXT, + "interviewEventId" TEXT, + "interviewCalendarId" TEXT, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL +); + +-- CreateTable +CREATE TABLE "StatusEvent" ( + "id" TEXT NOT NULL PRIMARY KEY, + "applicationId" TEXT NOT NULL, + "from" TEXT NOT NULL, + "to" TEXT NOT NULL, + "at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "previousFurthestStage" TEXT, + "previousSentAt" DATETIME, + CONSTRAINT "StatusEvent_applicationId_fkey" FOREIGN KEY ("applicationId") REFERENCES "Application" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "Document" ( + "id" TEXT NOT NULL PRIMARY KEY, + "applicationId" TEXT NOT NULL, + "kind" TEXT NOT NULL, + "units" JSONB NOT NULL, + "themeId" TEXT, + "writtenWith" TEXT, + "trimmed" JSONB, + "updatedAt" DATETIME NOT NULL, + CONSTRAINT "Document_applicationId_fkey" FOREIGN KEY ("applicationId") REFERENCES "Application" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "Document_themeId_fkey" FOREIGN KEY ("themeId") REFERENCES "Theme" ("id") ON DELETE SET NULL ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "DocumentVersion" ( + "id" TEXT NOT NULL PRIMARY KEY, + "documentId" TEXT NOT NULL, + "units" JSONB NOT NULL, + "cause" TEXT NOT NULL, + "detail" TEXT NOT NULL DEFAULT '', + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "DocumentVersion_documentId_fkey" FOREIGN KEY ("documentId") REFERENCES "Document" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "ChatMessage" ( + "id" TEXT NOT NULL PRIMARY KEY, + "applicationId" TEXT NOT NULL, + "role" TEXT NOT NULL, + "text" TEXT NOT NULL, + "targetUnit" TEXT, + "edits" JSONB, + "undone" BOOLEAN NOT NULL DEFAULT false, + "previousTone" TEXT, + "writtenWith" TEXT, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "ChatMessage_applicationId_fkey" FOREIGN KEY ("applicationId") REFERENCES "Application" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "Note" ( + "id" TEXT NOT NULL PRIMARY KEY, + "applicationId" TEXT NOT NULL, + "body" TEXT NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "Note_applicationId_fkey" FOREIGN KEY ("applicationId") REFERENCES "Application" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "Posting" ( + "id" TEXT NOT NULL PRIMARY KEY, + "source" TEXT NOT NULL, + "externalId" TEXT NOT NULL, + "title" TEXT NOT NULL DEFAULT '', + "company" TEXT NOT NULL DEFAULT '', + "place" TEXT NOT NULL DEFAULT '', + "remote" BOOLEAN, + "salary" TEXT NOT NULL DEFAULT '', + "postedAt" DATETIME, + "url" TEXT NOT NULL DEFAULT '', + "via" TEXT NOT NULL DEFAULT '', + "description" TEXT NOT NULL DEFAULT '', + "complete" BOOLEAN NOT NULL DEFAULT false, + "savedAt" DATETIME, + "dismissedAt" DATETIME, + "applicationId" TEXT, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL, + CONSTRAINT "Posting_applicationId_fkey" FOREIGN KEY ("applicationId") REFERENCES "Application" ("id") ON DELETE SET NULL ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "ClaudePaste" ( + "id" TEXT NOT NULL PRIMARY KEY, + "text" TEXT NOT NULL, + "url" TEXT NOT NULL DEFAULT '', + "expiresAt" DATETIME NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- CreateTable +CREATE TABLE "AgentCandidate" ( + "id" TEXT NOT NULL PRIMARY KEY, + "source" TEXT NOT NULL, + "externalId" TEXT NOT NULL, + "title" TEXT NOT NULL DEFAULT '', + "company" TEXT NOT NULL DEFAULT '', + "place" TEXT NOT NULL DEFAULT '', + "remote" BOOLEAN, + "salary" TEXT NOT NULL DEFAULT '', + "postedAt" DATETIME, + "url" TEXT NOT NULL DEFAULT '', + "via" TEXT NOT NULL DEFAULT '', + "description" TEXT NOT NULL DEFAULT '', + "complete" BOOLEAN NOT NULL DEFAULT false, + "score" INTEGER NOT NULL DEFAULT 0, + "reasons" JSONB, + "redFlags" JSONB, + "status" TEXT NOT NULL DEFAULT 'SCORED', + "error" TEXT, + "applicationId" TEXT, + "dedupeKey" TEXT NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL, + CONSTRAINT "AgentCandidate_applicationId_fkey" FOREIGN KEY ("applicationId") REFERENCES "Application" ("id") ON DELETE SET NULL ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "PostingScore" ( + "source" TEXT NOT NULL, + "externalId" TEXT NOT NULL, + "score" INTEGER NOT NULL, + "reasons" JSONB NOT NULL, + "redFlags" JSONB NOT NULL, + "scoredWith" TEXT NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL, + + PRIMARY KEY ("source", "externalId") +); + +-- CreateTable +CREATE TABLE "PostingSearch" ( + "id" TEXT NOT NULL PRIMARY KEY, + "source" TEXT NOT NULL, + "query" JSONB NOT NULL, + "postings" JSONB NOT NULL, + "found" INTEGER NOT NULL DEFAULT 0, + "next" TEXT, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL +); + +-- CreateTable +CREATE TABLE "Startup" ( + "id" TEXT NOT NULL PRIMARY KEY, + "source" TEXT NOT NULL, + "externalId" TEXT NOT NULL, + "company" TEXT NOT NULL, + "website" TEXT NOT NULL DEFAULT '', + "summary" TEXT NOT NULL DEFAULT '', + "details" TEXT NOT NULL DEFAULT '', + "location" TEXT NOT NULL DEFAULT '', + "remote" BOOLEAN, + "stage" TEXT NOT NULL DEFAULT '', + "roles" JSONB NOT NULL, + "emails" JSONB NOT NULL, + "links" JSONB NOT NULL, + "postedAt" DATETIME, + "score" INTEGER NOT NULL DEFAULT 0, + "reasons" JSONB NOT NULL, + "scoredWith" TEXT NOT NULL DEFAULT '', + "status" TEXT NOT NULL DEFAULT 'NEW', + "noApplicationsFrom" TEXT NOT NULL DEFAULT '', + "emailsCheckedAt" DATETIME, + "peopleCheckedAt" DATETIME, + "notes" TEXT NOT NULL DEFAULT '', + "seenAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "applicationId" TEXT, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL, + CONSTRAINT "Startup_applicationId_fkey" FOREIGN KEY ("applicationId") REFERENCES "Application" ("id") ON DELETE SET NULL ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "StartupBlock" ( + "hash" TEXT NOT NULL PRIMARY KEY, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- CreateTable +CREATE TABLE "GoogleAccount" ( + "id" TEXT NOT NULL PRIMARY KEY DEFAULT 'google', + "email" TEXT NOT NULL, + "refreshTokenEnc" TEXT NOT NULL, + "scopes" TEXT NOT NULL DEFAULT '', + "historyId" TEXT, + "lastSyncAt" DATETIME, + "syncProblem" TEXT, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL +); + +-- CreateTable +CREATE TABLE "EmailMessage" ( + "id" TEXT NOT NULL PRIMARY KEY, + "gmailId" TEXT NOT NULL, + "threadId" TEXT NOT NULL, + "applicationId" TEXT, + "direction" TEXT NOT NULL, + "fromName" TEXT NOT NULL DEFAULT '', + "fromAddress" TEXT NOT NULL DEFAULT '', + "toAddress" TEXT NOT NULL DEFAULT '', + "subject" TEXT NOT NULL DEFAULT '', + "messageId" TEXT NOT NULL DEFAULT '', + "body" TEXT NOT NULL DEFAULT '', + "sentAt" DATETIME NOT NULL, + "kind" TEXT, + "summary" TEXT NOT NULL DEFAULT '', + "questions" JSONB, + "suggestedStatus" TEXT, + "confirmedAt" DATETIME, + "classifiedWith" TEXT, + "replyDraft" TEXT NOT NULL DEFAULT '', + "replyGaps" JSONB, + "draftedWith" TEXT, + "handledAt" DATETIME, + "dismissedAt" DATETIME, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "EmailMessage_applicationId_fkey" FOREIGN KEY ("applicationId") REFERENCES "Application" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "LoginThrottle" ( + "key" TEXT NOT NULL PRIMARY KEY, + "failures" INTEGER NOT NULL DEFAULT 0, + "windowStart" DATETIME NOT NULL, + "blockedUntil" DATETIME, + "updatedAt" DATETIME NOT NULL +); + +-- CreateTable +CREATE TABLE "Job" ( + "id" TEXT NOT NULL PRIMARY KEY, + "type" TEXT NOT NULL, + "key" TEXT, + "applicationId" TEXT, + "status" TEXT NOT NULL DEFAULT 'QUEUED', + "step" TEXT, + "input" JSONB NOT NULL, + "result" JSONB, + "error" TEXT, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "startedAt" DATETIME, + "finishedAt" DATETIME, + "updatedAt" DATETIME NOT NULL, + CONSTRAINT "Job_applicationId_fkey" FOREIGN KEY ("applicationId") REFERENCES "Application" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateIndex +CREATE UNIQUE INDEX "Skill_name_key" ON "Skill"("name"); + +-- CreateIndex +CREATE UNIQUE INDEX "Language_name_key" ON "Language"("name"); + +-- CreateIndex +CREATE INDEX "ExperienceSkill_skillId_idx" ON "ExperienceSkill"("skillId"); + +-- CreateIndex +CREATE INDEX "ProjectSkill_skillId_idx" ON "ProjectSkill"("skillId"); + +-- CreateIndex +CREATE INDEX "Fact_applicationId_sort_idx" ON "Fact"("applicationId", "sort"); + +-- CreateIndex +CREATE INDEX "Application_status_idx" ON "Application"("status"); + +-- CreateIndex +CREATE INDEX "Application_updatedAt_idx" ON "Application"("updatedAt"); + +-- CreateIndex +CREATE INDEX "StatusEvent_applicationId_at_idx" ON "StatusEvent"("applicationId", "at"); + +-- CreateIndex +CREATE UNIQUE INDEX "Document_applicationId_kind_key" ON "Document"("applicationId", "kind"); + +-- CreateIndex +CREATE INDEX "DocumentVersion_documentId_createdAt_idx" ON "DocumentVersion"("documentId", "createdAt"); + +-- CreateIndex +CREATE INDEX "ChatMessage_applicationId_createdAt_idx" ON "ChatMessage"("applicationId", "createdAt"); + +-- CreateIndex +CREATE INDEX "Note_applicationId_createdAt_idx" ON "Note"("applicationId", "createdAt"); + +-- CreateIndex +CREATE UNIQUE INDEX "Posting_applicationId_key" ON "Posting"("applicationId"); + +-- CreateIndex +CREATE INDEX "Posting_savedAt_idx" ON "Posting"("savedAt"); + +-- CreateIndex +CREATE INDEX "Posting_dismissedAt_idx" ON "Posting"("dismissedAt"); + +-- CreateIndex +CREATE UNIQUE INDEX "Posting_source_externalId_key" ON "Posting"("source", "externalId"); + +-- CreateIndex +CREATE INDEX "ClaudePaste_expiresAt_idx" ON "ClaudePaste"("expiresAt"); + +-- CreateIndex +CREATE UNIQUE INDEX "AgentCandidate_applicationId_key" ON "AgentCandidate"("applicationId"); + +-- CreateIndex +CREATE UNIQUE INDEX "AgentCandidate_dedupeKey_key" ON "AgentCandidate"("dedupeKey"); + +-- CreateIndex +CREATE INDEX "AgentCandidate_status_score_idx" ON "AgentCandidate"("status", "score"); + +-- CreateIndex +CREATE INDEX "AgentCandidate_createdAt_idx" ON "AgentCandidate"("createdAt"); + +-- CreateIndex +CREATE UNIQUE INDEX "AgentCandidate_source_externalId_key" ON "AgentCandidate"("source", "externalId"); + +-- CreateIndex +CREATE INDEX "PostingSearch_createdAt_idx" ON "PostingSearch"("createdAt"); + +-- CreateIndex +CREATE UNIQUE INDEX "Startup_applicationId_key" ON "Startup"("applicationId"); + +-- CreateIndex +CREATE INDEX "Startup_status_score_idx" ON "Startup"("status", "score"); + +-- CreateIndex +CREATE UNIQUE INDEX "Startup_source_externalId_key" ON "Startup"("source", "externalId"); + +-- CreateIndex +CREATE UNIQUE INDEX "EmailMessage_gmailId_key" ON "EmailMessage"("gmailId"); + +-- CreateIndex +CREATE INDEX "EmailMessage_threadId_idx" ON "EmailMessage"("threadId"); + +-- CreateIndex +CREATE INDEX "EmailMessage_applicationId_sentAt_idx" ON "EmailMessage"("applicationId", "sentAt"); + +-- CreateIndex +CREATE INDEX "EmailMessage_sentAt_idx" ON "EmailMessage"("sentAt"); + +-- CreateIndex +CREATE INDEX "LoginThrottle_updatedAt_idx" ON "LoginThrottle"("updatedAt"); + +-- CreateIndex +CREATE INDEX "Job_key_createdAt_idx" ON "Job"("key", "createdAt"); + +-- CreateIndex +CREATE INDEX "Job_status_idx" ON "Job"("status"); + +-- CreateIndex +CREATE INDEX "Job_applicationId_idx" ON "Job"("applicationId"); + + +-- Built-in themes can't be deleted: Settings.baseThemeId falls back to +-- "editorial", which must always exist. (As in PostgreSQL's +-- 20260927204812_theme_settings_schema.) +CREATE TRIGGER "Theme_prevent_builtin_delete" +BEFORE DELETE ON "Theme" +FOR EACH ROW WHEN OLD."builtIn" +BEGIN + SELECT RAISE(ABORT, 'Built-in themes can''t be deleted.'); +END; diff --git a/prisma/sqlite/migrations/migration_lock.toml b/prisma/sqlite/migrations/migration_lock.toml new file mode 100644 index 00000000..2a5a4441 --- /dev/null +++ b/prisma/sqlite/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (e.g., Git) +provider = "sqlite" diff --git a/prisma/sqlite/schema.prisma b/prisma/sqlite/schema.prisma new file mode 100644 index 00000000..15ee49e3 --- /dev/null +++ b/prisma/sqlite/schema.prisma @@ -0,0 +1,935 @@ +// Generated from prisma/schema.prisma by prisma/sqlite-schema.ts: don't edit, +// run `docker/bin/yarn sqlite-schema` instead. + +// Dossier's data model (PRD §9). After editing, run +// `docker/bin/yarn prisma migrate dev --name ` and `docker compose restart app`. +// +// Conventions: +// - The profile's basics and the settings are key-value tables (ProfileField, +// Setting): one text row per key, and a missing key means empty or the +// default. Code maps the keys to typed fields (src/lib/profile.ts, +// src/lib/settings.ts), so a new field needs no migration. +// - Text fields the user fills in are non-null and default to "", so forms can +// autosave partial entries without null checks. +// - `sort` orders entries within a list; lower comes first. + +generator client { + provider = "prisma-client" + output = "../../src/generated/prisma-sqlite" +} + +datasource db { + provider = "sqlite" +} + +// ─── Profile (E2-1) ────────────────────────────────────────────────────────── + +/// The profile's basics (name, headline, contact details, summary…), one row +/// per filled-in field. The keys and how each value is written are in +/// src/lib/profile.ts (PROFILE_KEYS); a missing key means empty. +model ProfileField { + key String @id + value String + updatedAt DateTime @updatedAt +} + +model Skill { + id String @id @default(cuid()) + /// Unique ignoring case ("Figma" and "figma" are the same skill). + name String @unique + sort Int @default(0) + experiences ExperienceSkill[] + projects ProjectSkill[] + createdAt DateTime @default(now()) +} + +enum LanguageLevel { + NATIVE + C2 + C1 + B2 + B1 + A2 + A1 +} + +model Language { + id String @id @default(cuid()) + /// Unique ignoring case; adding an existing language updates its level (PRO-4). + name String @unique + level LanguageLevel + sort Int @default(0) + createdAt DateTime @default(now()) +} + +/// A role. Dates are month and year; `current` means "Present" (no end date). +model Experience { + id String @id @default(cuid()) + role String @default("") + company String @default("") + location String @default("") + startYear Int? + /// 1–12, or null when only the year is known. + startMonth Int? + endYear Int? + endMonth Int? + current Boolean @default(false) + /// One point per line. + bullets String @default("") + sort Int @default(0) + skills ExperienceSkill[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +/// A skill used in a role (PRO-6). Deleting either side removes the link (PRO-3). +model ExperienceSkill { + experienceId String + skillId String + experience Experience @relation(fields: [experienceId], references: [id], onDelete: Cascade) + skill Skill @relation(fields: [skillId], references: [id], onDelete: Cascade) + + @@id([experienceId, skillId]) + @@index([skillId]) +} + +/// A side, open-source, course or thesis project (PRO-13). Dates work as in Experience. +model Project { + id String @id @default(cuid()) + name String @default("") + /// Where it can be seen, e.g. its repository or site. + link String @default("") + startYear Int? + startMonth Int? + endYear Int? + endMonth Int? + current Boolean @default(false) + /// One point per line. + bullets String @default("") + sort Int @default(0) + skills ProjectSkill[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +/// A skill used in a project, as ExperienceSkill is for a role. +model ProjectSkill { + projectId String + skillId String + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + skill Skill @relation(fields: [skillId], references: [id], onDelete: Cascade) + + @@id([projectId, skillId]) + @@index([skillId]) +} + +/// A degree, course or certificate. Dates work as in Experience. +model Education { + id String @id @default(cuid()) + degree String @default("") + school String @default("") + startYear Int? + startMonth Int? + endYear Int? + endMonth Int? + current Boolean @default(false) + notes String @default("") + sort Int @default(0) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +/// Private unless included in a document (PRO-8). +/// A publication (#218), cited the way its author wants: no citation style is enforced. +model Publication { + id String @id @default(cuid()) + /// The citation as written, with **bold** and *italic* marked like that. + citation String @default("") + /// Where to read it, e.g. its DOI link. + link String @default("") + /// "Show on resumes": off keeps it in the profile but off every resume. + onResumes Boolean @default(true) + sort Int @default(0) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +model Reference { + id String @id @default(cuid()) + name String @default("") + relation String @default("") + company String @default("") + email String @default("") + phone String @default("") + sort Int @default(0) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +/// Something the user tells the AI about themselves: one of the profile's +/// "Facts about me", or one for a single application. Never shown on a +/// document; given to the AI with the profile when it writes. +model Fact { + id String @id @default(cuid()) + /// The application it's for; null for the profile's own. + applicationId String? + application Application? @relation(fields: [applicationId], references: [id], onDelete: Cascade) + text String @default("") + sort Int @default(0) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([applicationId, sort]) +} + +// ─── Themes and settings (E2-2) ────────────────────────────────────────────── + +/// Serifs, then sans, then mono: the order the theme builder lists them. +enum ThemeFont { + NEWSREADER + GARAMOND + SOURCE_SERIF + LORA + LIBRE_CASLON + PLAYFAIR + FRAUNCES + GEIST + PLEX_SANS + INTER + MANROPE + WORK_SANS + SPACE_GROTESK + PLEX_MONO + JETBRAINS_MONO +} + +/// Sidebars sit at the start of the line: on the left, or on the right in +/// right-to-left documents. +enum ThemeLayout { + SINGLE + /// One column, with each section's title in a narrow column beside it. + SIDE_HEADINGS + /// Two columns, with contact details and skills in a sidebar. + SPLIT + /// The same with the sidebar at the end of the line. + SPLIT_END +} + +enum ThemeAlign { + LEFT + CENTER +} + +/// How section titles look. +enum HeadingStyle { + /// Underlined. + RULE + /// Small caps. + CAPS + PLAIN + /// Small caps after a short accent bar. + BAR + /// Small caps on a pale band of the accent. + BAND +} + +/// The name, headline and contact details: on the page, or on a block of the accent. +enum HeaderStyle { + PLAIN + BANNER +} + +/// The sidebar of sidebar layouts: on the page, or on a pale tint of the accent. +enum SidebarStyle { + PLAIN + TINTED +} + +/// A role's or degree's dates: on its title line, or in a column before it. +enum DateStyle { + INLINE + TIMELINE +} + +/// The profile photo's shape on a resume (#224). The square shapes show the middle of its 3:4 crop. +enum PhotoShape { + CIRCLE + ROUNDED + SQUARE + PORTRAIT +} + +/// Where a resume shows the profile photo (#224). SIDEBAR needs a sidebar +/// layout; other layouts put it at the end of the header. +enum PhotoPosition { + HEADER_END + HEADER_START + ABOVE_NAME + SIDEBAR +} + +enum Density { + COMPACT + REGULAR + AIRY +} + +/// A resume theme (RES-2…RES-4). Built-in themes use their slug as id +/// ("editorial") and are read-only: the app never edits them, the seed keeps +/// them in sync, and a trigger stops them from being deleted. +model Theme { + id String @id @default(cuid()) + builtIn Boolean @default(false) + name String + headFont ThemeFont + bodyFont ThemeFont + /// "#rrggbb" + accent String + layout ThemeLayout @default(SINGLE) + align ThemeAlign @default(LEFT) + headingStyle HeadingStyle @default(PLAIN) + density Density @default(REGULAR) + headerStyle HeaderStyle @default(PLAIN) + sidebarStyle SidebarStyle @default(PLAIN) + dateStyle DateStyle @default(INLINE) + photoShape PhotoShape @default(CIRCLE) + photoPosition PhotoPosition @default(HEADER_END) + documents Document[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +enum AiProvider { + /// Ollama or llama.cpp on the user's own hardware. + LOCAL + /// Any server that speaks the OpenAI chat completions API. + OPENAI + ANTHROPIC + /// Mistral's own API (#206). + MISTRAL + /// LM Studio on the user's own hardware (#210). + LMSTUDIO +} + +enum Creativity { + FAITHFUL + BALANCED + EXPRESSIVE +} + +enum PaperSize { + A4 + LETTER +} + +enum DateFormat { + /// 27 September 2026 + DAY_MONTH_YEAR + /// September 27, 2026 + MONTH_DAY_YEAR + /// 27-09-2026 + NUMERIC +} + +enum Tone { + WARM + DIRECT + FORMAL +} + +/// App settings, one row per saved setting; a missing key means the default. +/// The keys, their types and defaults are in src/lib/settings.ts +/// (settingsSchema); the enums above type its values. +model Setting { + key String @id + value String + updatedAt DateTime @updatedAt +} + +// ─── Applications (E2-3) ───────────────────────────────────────────────────── +// The JSON columns are validated with the zod schemas in src/lib/application-data.ts. + +/// PRD §8. Draft…Offer are "in progress"; Accepted, Rejected and Declined are "closed". +enum ApplicationStatus { + DRAFT + SENT + SCREENING + INTERVIEW + OFFER + ACCEPTED + REJECTED + DECLINED +} + +/// How an application was made: by the user, or by the job agent (E16). +enum ApplicationOrigin { + MANUAL + AGENT +} + +/// Where a job agent candidate stands (E16). +enum AgentCandidateStatus { + /// Scored, and waiting for a draft or a dismissal. + SCORED + /// A draft is being written. + DRAFTING + /// A tailored draft is ready for review. + DRAFTED + /// The draft was approved and is a normal application. + APPROVED + /// The user passed on it; it never comes back. + DISMISSED +} + +/// The stages the funnel counts, in order (PRD §8, DSH-3). +enum FunnelStage { + SENT + SCREENING + INTERVIEW + OFFER + ACCEPTED +} + +model Application { + id String @id @default(cuid()) + /// Whether the user made it, or the job agent prepared it (E16). + origin ApplicationOrigin @default(MANUAL) + /// The agent's score for the posting, when the agent prepared it. + agentScore Int? + /// An agent draft not approved yet: only the Agent screen shows it (#195). + awaitingReview Boolean @default(false) + title String @default("") + company String @default("") + role String @default("") + location String @default("") + /// Work mode as written in the posting, e.g. "Hybrid · 3 days in office". + mode String @default("") + salary String @default("") + deadline DateTime? + /// Saved, not fetched (NEW-1). + postingUrl String @default("") + postingText String @default("") + /// What the AI read from the posting (extractedSchema). + extracted Json? + /// The prompt version that wrote `extracted`, e.g. "extract-posting@1" (E6-5). + extractedWith String? + /// How the posting's requirements match the profile (matchSchema). + match Json? + /// The prompt version that wrote `match`. + matchedWith String? + status ApplicationStatus @default(DRAFT) + /// The furthest stage ever reached; null while never sent. Only the status module changes it. + furthestStage FunnelStage? + sentAt DateTime? + /// When the next interview is, while the application is in Interview (E9-9); shown on the dashboard. + nextInterviewAt DateTime? + tone Tone @default(WARM) + /// The language the documents were written in, e.g. "English". + language String @default("") + /// The tailored resume follows its theme's layout, sidebar included; off + /// means a single column (APP-4, decided in E5-1 #42). + followThemeLayout Boolean @default(false) + /// Whether the tailored resume shows the profile photo; null follows the + /// profile's "Show on resumes" (#224). + showPhoto Boolean? + archivedAt DateTime? + /// The Gmail thread the application was sent in, when it went out through Gmail (E15-2). + emailThreadId String? + /// The Google Calendar event of the next interview, and the calendar it's + /// in, when Dossier added it: kept in step with nextInterviewAt both ways. + interviewEventId String? + interviewCalendarId String? + /// The Find jobs posting it was started from, if any (E14-8). + posting Posting? + /// The agent candidate it was prepared from (E16). + agentCandidate AgentCandidate? + /// The startup it was started from on Startups, if any (#222). + startup Startup? + /// Mail sent and received about it through Gmail (E15-3). + emails EmailMessage[] + events StatusEvent[] + documents Document[] + messages ChatMessage[] + notes Note[] + /// What the user tells the AI for this application; "Anything I should know?" (NEW-6) becomes the first. + facts Fact[] + /// Its background jobs, deleted with it (their inputs and results hold the posting and the documents). + jobs Job[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([status]) + @@index([updatedAt]) +} + +/// One status change. Keeps the application's furthestStage and sentAt from +/// before the change, so the change can be undone (decision in #26). +model StatusEvent { + id String @id @default(cuid()) + applicationId String + application Application @relation(fields: [applicationId], references: [id], onDelete: Cascade) + from ApplicationStatus + to ApplicationStatus + at DateTime @default(now()) + previousFurthestStage FunnelStage? + previousSentAt DateTime? + + @@index([applicationId, at]) +} + +enum DocumentKind { + RESUME + COVER_LETTER + /// The email that goes with the application, written on request. + EMAIL +} + +/// A tailored resume, cover letter or application email, split into editable units (unitsSchema). +model Document { + id String @id @default(cuid()) + applicationId String + application Application @relation(fields: [applicationId], references: [id], onDelete: Cascade) + kind DocumentKind + units Json + /// The theme it was created with; null falls back to the base theme. + themeId String? + theme Theme? @relation(fields: [themeId], references: [id], onDelete: SetNull) + /// The prompt version that last wrote its units, e.g. "tailor-resume@1" (E6-5). + writtenWith String? + /// Parts left out to fit one page, which can be put back (trimmedSchema, E10-10). + trimmed Json? + /// Earlier states, after each AI change and before each restore (E8-10). + versions DocumentVersion[] + updatedAt DateTime @updatedAt + + @@unique([applicationId, kind]) +} + +/// Why a tailored document has a version (E8-10, APP-13). +enum VersionCause { + /// The first draft, when the application was created. + WRITTEN + /// Left parts out to fit one page, right after writing (E10-10). + TRIMMED + /// A part rewritten from its toolbar. + REGENERATED + /// Changed from the refine chat. + REFINED + /// An AI change undone. + UNDONE + /// Saved just before restoring another version, so nothing is lost. + BEFORE_RESTORE + /// An earlier version brought back. + RESTORED +} + +/// A tailored document's units as they were at one point (E8-10). +model DocumentVersion { + id String @id @default(cuid()) + documentId String + document Document @relation(fields: [documentId], references: [id], onDelete: Cascade) + units Json + cause VersionCause + /// What changed, e.g. the part's name or the chat request. + detail String @default("") + createdAt DateTime @default(now()) + + @@index([documentId, createdAt]) +} + +enum ChatRole { + USER + AI +} + +/// A message in an application's "Refine with AI" panel (APP-7). +model ChatMessage { + id String @id @default(cuid()) + applicationId String + application Application @relation(fields: [applicationId], references: [id], onDelete: Cascade) + role ChatRole + text String + /// The unit the message is about ("About: Summary"), if any. + targetUnit String? + /// Unit changes it made, with the previous text for Undo (editsSchema). + edits Json? + undone Boolean @default(false) + /// The letter's tone before this message changed it, for Undo; null when it didn't. + previousTone Tone? + /// For AI messages, the prompt version that wrote it (E6-5). + writtenWith String? + createdAt DateTime @default(now()) + + @@index([applicationId, createdAt]) +} + +/// A private note or activity entry on an application (APP-14, E8-9). +model Note { + id String @id @default(cuid()) + applicationId String + application Application @relation(fields: [applicationId], references: [id], onDelete: Cascade) + body String + createdAt DateTime @default(now()) + /// Shown as "edited" once it differs from createdAt (E8-9). + updatedAt DateTime @default(now()) @updatedAt + + @@index([applicationId, createdAt]) +} + +// ─── Job search (E14) ──────────────────────────────────────────────────────── + +/// The job sources Find jobs can search (E14-1 to E14-3). +enum PostingSource { + JSEARCH + ADZUNA + ARBEITSAGENTUR +} + +/// A posting from Find jobs that the user kept (E14-5): saved, dismissed, or +/// started an application from. Plain search results aren't stored. The +/// fields follow the posting shape in src/lib/job-search/search.ts. +model Posting { + id String @id @default(cuid()) + source PostingSource + /// The source's own id for it. + externalId String + title String @default("") + company String @default("") + place String @default("") + /// Null when the source doesn't say. + remote Boolean? + /// As the source writes it. + salary String @default("") + postedAt DateTime? + /// Where to read it or apply; always http(s). + url String @default("") + /// Where the source found it, e.g. "LinkedIn". + via String @default("") + description String @default("") + /// False while the description is only the start of the posting. + complete Boolean @default(false) + savedAt DateTime? + /// Dismissed postings stay out of later searches. + dismissedAt DateTime? + /// The application made from it; deleting the application keeps the posting. + applicationId String? @unique + application Application? @relation(fields: [applicationId], references: [id], onDelete: SetNull) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([source, externalId]) + @@index([savedAt]) + @@index([dismissedAt]) +} + +/// A posting pasted into New application and handed to Claude chat (#221): +/// the Claude connector reads it by its id. It's kept for 14 days, then the +/// task-data sweep deletes it. +model ClaudePaste { + id String @id @default(uuid()) + text String + /// The posting's link, when one was given; always http(s), or empty. + url String @default("") + expiresAt DateTime + createdAt DateTime @default(now()) + + @@index([expiresAt]) +} + +// ─── Job agent (E16) ───────────────────────────────────────────────────────── + +/// A posting the job agent found and scored, and the draft it prepared from +/// it. Kept after it's dismissed, trimmed to what dedupe needs, so the same +/// posting never comes back. +model AgentCandidate { + id String @id @default(cuid()) + source PostingSource + /// The source's own id for it. + externalId String + title String @default("") + company String @default("") + place String @default("") + remote Boolean? + salary String @default("") + postedAt DateTime? + /// Where to read it or apply; always http(s). + url String @default("") + via String @default("") + /// Plain text, as the source gave it. + description String @default("") + complete Boolean @default(false) + /// 0–100: how well the posting fits the profile (score-posting). + score Int @default(0) + /// Why it fits, as a JSON list of strings. + reasons Json? + /// What gives pause, as a JSON list of strings. + redFlags Json? + status AgentCandidateStatus @default(SCORED) + /// Why preparing a draft failed, if it did. + error String? + /// The tailored draft made from it. + applicationId String? @unique + application Application? @relation(fields: [applicationId], references: [id], onDelete: SetNull) + /// A hash of the link without tracking parameters, else of company, title + /// and place: the same job from another source or a later run matches. + dedupeKey String @unique + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([source, externalId]) + @@index([status, score]) + @@index([createdAt]) +} + +/// How well a posting fits the profile (#208): from Score in Find jobs, or +/// the job agent. Kept by the source's id, for search results that aren't +/// stored as postings, so the score shows again in later searches. +model PostingScore { + source PostingSource + externalId String + /// 0–100 (score-posting). + score Int + /// JSON lists of strings. + reasons Json + redFlags Json + /// The prompt version that scored it, e.g. "score-posting@1". + scoredWith String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@id([source, externalId]) +} + +/// A search on Find jobs, kept with what it found until the user removes it +/// (E14-10), so going back to it costs no searches from the source's plan. +model PostingSearch { + id String @id @default(cuid()) + source PostingSource + /// The search as asked (searchQuerySchema in src/lib/job-search/search.ts). + query Json + /// The postings found, in order (foundPostingSchema[]); "Show more" adds to them. + postings Json + /// How many postings it holds, for the Past searches list. + found Int @default(0) + /// What to ask the source for the next page; null on the last one. + next String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([createdAt]) +} + +// ─── Startups (#222) ───────────────────────────────────────────────────────── + +/// Where a startup stands on the Startups screen. +enum StartupStatus { + NEW + SAVED + CONTACTED + DISMISSED +} + +/// A startup that's hiring (#222): a YC startup's job post on Hacker News, or +/// a post in Hacker News' monthly "Who is hiring?" thread, with the addresses +/// it publishes for applications and how well it fits the profile. Kept +/// with what the user did with it; untouched ones go once no refresh finds +/// them for a while. +model Startup { + id String @id @default(cuid()) + /// Where it was found: "YC" (YC's job posts) or "HN" ("Who is hiring?"). + source String + /// The Hacker News item's id. + externalId String + company String + /// The company's own site, "" when unknown. + website String @default("") + /// The post's title, or its first line. + summary String @default("") + /// The post's text as published. + details String @default("") + location String @default("") + remote Boolean? + /// e.g. "YC W24", or the round a post names. + stage String @default("") + /// JSON list of strings: the roles a post names. + roles Json + /// JSON list of { email, label, from, onRequest, foundAt }: only addresses + /// the startup published, never guessed. People's own addresses are + /// looked up only when asked, and dropped after a while unless used. + emails Json + /// JSON list of { label, url }. + links Json + /// When it was posted. + postedAt DateTime? + /// 0–100: how well it fits the profile, with why (JSON list of strings). + score Int @default(0) + reasons Json + /// The matching's version, e.g. "match@1". + scoredWith String @default("") + status StartupStatus @default(NEW) + /// Where its site says it takes no unsolicited applications; "" when it doesn't. + noApplicationsFrom String @default("") + /// When its site was last checked for role addresses, and for people's. + emailsCheckedAt DateTime? + peopleCheckedAt DateTime? + notes String @default("") + /// When a refresh last found it. + seenAt DateTime @default(now()) + /// The application started from it ("Write to them"); deleting the application keeps the startup. + applicationId String? @unique + application Application? @relation(fields: [applicationId], references: [id], onDelete: SetNull) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([source, externalId]) + @@index([status, score]) +} + +/// A startup removed with "Remove and don't bring back" (#222): only a +/// SHA-256 hash of its post, its site's domain or its name, so a refresh +/// leaves it out without keeping anything about it. +model StartupBlock { + hash String @id + createdAt DateTime @default(now()) +} + +// ─── Google: Gmail and Calendar (E15) ──────────────────────────────────────── + +/// The connected Google account; at most one row, with id "google". It +/// belongs to this install, not its data: exports leave it out. +model GoogleAccount { + id String @id @default("google") + /// The Gmail address it's connected as. + email String + /// The OAuth refresh token, encrypted like the API keys (GOOGLE_TOKEN). + refreshTokenEnc String + /// The scopes Google granted, space-separated. + scopes String @default("") + /// Where the inbox check carries on from (Gmail's history id). + historyId String? + lastSyncAt DateTime? + /// Why the last check failed; null once one works again. + syncProblem String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +enum EmailDirection { + /// Sent from Dossier, or from Gmail in a thread Dossier knows. + OUTGOING + INCOMING +} + +/// What a reply from an employer is, as the AI read it (E15-4). +enum ReplyKind { + REJECTION + INTERVIEW + QUESTION + OFFER + /// An automatic "we got your application". + ACKNOWLEDGEMENT + OTHER +} + +/// A mail in Gmail about an application, or one that may be (E15-3). Only +/// the text is kept, not attachments. +model EmailMessage { + id String @id @default(cuid()) + gmailId String @unique + threadId String + /// Null while it isn't linked to an application ("Needs a look"). + /// Deleting the application deletes its mail, as it does its documents. + applicationId String? + application Application? @relation(fields: [applicationId], references: [id], onDelete: Cascade) + direction EmailDirection + fromName String @default("") + fromAddress String @default("") + toAddress String @default("") + subject String @default("") + /// The RFC 5322 Message-ID, for replying in the thread. + messageId String @default("") + /// The text, without quoted earlier mail, cut to a sensible length. + body String @default("") + sentAt DateTime + kind ReplyKind? + /// One line on what it says. + summary String @default("") + /// The questions it asks (stringList), for the reply to answer. + questions Json? + /// The status it suggests moving to; null once handled or when there's none. + suggestedStatus ApplicationStatus? + /// The interview time it confirms, if any, for the calendar. + confirmedAt DateTime? + /// The prompt version that read it. + classifiedWith String? + /// The drafted answer, while there is one. + replyDraft String @default("") + /// What the draft couldn't answer from the profile (stringList). + replyGaps Json? + draftedWith String? + /// Answered from Dossier, or marked as needing no answer. + handledAt DateTime? + /// "Not job-related": only the ids and date are kept, so the inbox check + /// doesn't fetch it again. + dismissedAt DateTime? + createdAt DateTime @default(now()) + + @@index([threadId]) + @@index([applicationId, sentAt]) + @@index([sentAt]) +} + +// ─── Sign-in (E3-4) ────────────────────────────────────────────────────────── + +/// Failed sign-in attempts, for rate limiting (AUTH-5): one row per client IP, +/// plus the key "*" counting attempts from all IPs together. +model LoginThrottle { + /// The client IP, or "*" for the global counter. + key String @id + failures Int @default(0) + /// When the current run of failures started. + windowStart DateTime + /// Attempts are refused until then. + blockedUntil DateTime? + updatedAt DateTime @updatedAt + + @@index([updatedAt]) +} + +// ─── Background jobs (E6-7) ────────────────────────────────────────────────── + +/// A job goes queued → running → done, or failed. +enum JobStatus { + QUEUED + RUNNING + DONE + FAILED +} + +/// A long AI task run in the background, e.g. reading a posting or writing the +/// documents. Each job type validates its own input and result (src/lib/jobs). +model Job { + id String @id @default(cuid()) + /// Which handler runs it, e.g. "extract-posting". + type String + /// Finds the job again for something on screen, e.g. "application:". + key String? + /// The application it works on, if any: deleting the application deletes the job. + applicationId String? + application Application? @relation(fields: [applicationId], references: [id], onDelete: Cascade) + status JobStatus @default(QUEUED) + /// The named step in progress, e.g. "Reading the posting". + step String? + input Json + result Json? + /// Why it failed, written for the user. + error String? + createdAt DateTime @default(now()) + startedAt DateTime? + finishedAt DateTime? + updatedAt DateTime @updatedAt + + @@index([key, createdAt]) + @@index([status]) + @@index([applicationId]) +} diff --git a/prisma7.config.ts b/prisma7.config.ts index 8ed51518..3f385ce9 100644 --- a/prisma7.config.ts +++ b/prisma7.config.ts @@ -3,10 +3,14 @@ import "dotenv/config"; import { defineConfig } from "prisma/config"; +// A file: DATABASE_URL is SQLite (#17), which has its own schema and +// migrations; see DEVELOPMENT.md → "SQLite". +const sqlite = process.env["DATABASE_URL"]?.startsWith("file:"); + export default defineConfig({ - schema: "prisma/schema.prisma", + schema: sqlite ? "prisma/sqlite/schema.prisma" : "prisma/schema.prisma", migrations: { - path: "prisma/migrations", + path: sqlite ? "prisma/sqlite/migrations" : "prisma/migrations", // `prisma db seed` (pass `-- --demo` for sample data); see prisma/seed.ts. seed: "tsx prisma/seed.ts", }, diff --git a/src/app/(app)/new/actions.db.test.ts b/src/app/(app)/new/actions.db.test.ts index 0b26127d..dff0a17b 100644 --- a/src/app/(app)/new/actions.db.test.ts +++ b/src/app/(app)/new/actions.db.test.ts @@ -110,7 +110,7 @@ describe("createApplication", () => { const result = await createApplication({ writeJobId: "write-job", details, postingUrl: "jobs.ledgerly.example/42" }); expect(result.ok).toBe(true); const id = result.ok ? result.data : ""; - const application = await prisma().application.findUniqueOrThrow({ where: { id }, include: { documents: { orderBy: { kind: "asc" } }, messages: true } }); + const application = await prisma().application.findUniqueOrThrow({ where: { id }, include: { documents: true, messages: true } }); // Each document's history starts with the first draft (E8-10). expect(await prisma().documentVersion.findMany({ where: { document: { applicationId: id } }, select: { cause: true } })).toEqual([{ cause: "WRITTEN" }, { cause: "WRITTEN" }]); expect(application).toMatchObject({ @@ -129,9 +129,9 @@ describe("createApplication", () => { // "Anything I should know?" is the application's first fact (APP-15). expect(await prisma().fact.findMany({ where: { applicationId: id }, select: { text: true } })).toEqual([{ text: "Relocating in January." }]); expect(application.deadline?.toISOString().slice(0, 10)).toBe("2026-10-20"); - expect(application.documents.map((document) => [document.kind, document.themeId, document.writtenWith])).toEqual([ - ["RESUME", "editorial", "tailor-resume@1"], + expect(application.documents.map((document) => [document.kind, document.themeId, document.writtenWith]).sort()).toEqual([ ["COVER_LETTER", "editorial", "write-cover-letter@1"], + ["RESUME", "editorial", "tailor-resume@1"], ]); expect(application.messages).toHaveLength(1); expect(application.messages[0]).toMatchObject({ role: "AI", text: expect.stringMatching(/^Here's what I tailored for Ledgerly B\.V\.:\n\n- /) }); diff --git a/src/app/(app)/profile/actions.ts b/src/app/(app)/profile/actions.ts index c8914ef3..699d1992 100644 --- a/src/app/(app)/profile/actions.ts +++ b/src/app/(app)/profile/actions.ts @@ -208,7 +208,8 @@ export const setExperienceSkill = action( z.object({ experienceId: id, skillId: id, attached: z.boolean() }), async ({ experienceId, skillId, attached }) => { if (attached) { - await prisma.experienceSkill.createMany({ data: [{ experienceId, skillId }], skipDuplicates: true }); + const link = { experienceId, skillId }; + await prisma.experienceSkill.upsert({ where: { experienceId_skillId: link }, create: link, update: {} }); } else { await prisma.experienceSkill.deleteMany({ where: { experienceId, skillId } }); } @@ -221,10 +222,8 @@ export const attachNewSkill = action( z.object({ experienceId: id, name: skillName }), async ({ experienceId, name }) => { const result = await findOrCreateSkill(name); - await prisma.experienceSkill.createMany({ - data: [{ experienceId, skillId: result.skill.id }], - skipDuplicates: true, - }); + const link = { experienceId, skillId: result.skill.id }; + await prisma.experienceSkill.upsert({ where: { experienceId_skillId: link }, create: link, update: {} }); return result; }, ); @@ -291,7 +290,8 @@ export const setProjectSkill = action( z.object({ projectId: id, skillId: id, attached: z.boolean() }), async ({ projectId, skillId, attached }) => { if (attached) { - await prisma.projectSkill.createMany({ data: [{ projectId, skillId }], skipDuplicates: true }); + const link = { projectId, skillId }; + await prisma.projectSkill.upsert({ where: { projectId_skillId: link }, create: link, update: {} }); } else { await prisma.projectSkill.deleteMany({ where: { projectId, skillId } }); } @@ -302,7 +302,8 @@ export const setProjectSkill = action( /** "New skill? Type it and press Enter" on a project: adds it to the profile if it's new and attaches it. */ export const attachNewProjectSkill = action(z.object({ projectId: id, name: skillName }), async ({ projectId, name }) => { const result = await findOrCreateSkill(name); - await prisma.projectSkill.createMany({ data: [{ projectId, skillId: result.skill.id }], skipDuplicates: true }); + const link = { projectId, skillId: result.skill.id }; + await prisma.projectSkill.upsert({ where: { projectId_skillId: link }, create: link, update: {} }); return result; }); diff --git a/src/app/(app)/settings/page.tsx b/src/app/(app)/settings/page.tsx index 5b8b51a0..824a8b3c 100644 --- a/src/app/(app)/settings/page.tsx +++ b/src/app/(app)/settings/page.tsx @@ -10,6 +10,7 @@ import { lastNightlyBackup } from "@/lib/backup-schedule"; import { backupsDir } from "@/lib/backups"; import { mcpUrl } from "@/lib/connector/oauth"; import { exportFileName } from "@/lib/data-export"; +import { usesSqlite } from "@/lib/database"; import { formatShortDate } from "@/lib/dates"; import { env } from "@/lib/env"; import { googleConnection } from "@/lib/google/account"; @@ -198,14 +199,16 @@ async function aboutFacts(): Promise { const requestHeaders = await headers(); const host = requestHeaders.get("x-forwarded-host")?.split(",")[0]?.trim() || requestHeaders.get("host") || "Unknown"; const [[database], ai, applications] = await Promise.all([ - prisma.$queryRaw<{ size: bigint }[]>`SELECT pg_database_size(current_database()) AS size`, + usesSqlite() + ? prisma.$queryRaw<{ size: number }[]>`SELECT page_count * page_size AS size FROM pragma_page_count(), pragma_page_size()` + : prisma.$queryRaw<{ size: bigint }[]>`SELECT pg_database_size(current_database()) AS size`, loadAiConfig(), prisma.application.count({ where: { awaitingReview: false } }), ]); return [ { label: "Version", value: appVersion }, { label: "Server", value: host }, - { label: "Database", value: `PostgreSQL · ${formatBytes(Number(database.size))}` }, + { label: "Database", value: `${usesSqlite() ? "SQLite" : "PostgreSQL"} · ${formatBytes(Number(database.size))}` }, { label: "Sign-in", value: "set via DOSSIER_PASSWORD" }, { label: "AI", value: [providerOption(ai.provider).name, ai.model].filter(Boolean).join(" · ") }, { label: "Applications", value: String(applications) }, diff --git a/src/lib/agent/draft.ts b/src/lib/agent/draft.ts index 65a7f4e8..5e722f36 100644 --- a/src/lib/agent/draft.ts +++ b/src/lib/agent/draft.ts @@ -8,6 +8,7 @@ import { ActionError } from "@/lib/action"; import { AiError } from "@/lib/ai/errors"; import { createApplicationFromDocuments, type ApplicationDetails } from "@/lib/application-create"; import type { Extracted } from "@/lib/application-data"; +import { forUpdate } from "@/lib/database"; import { fullText } from "@/lib/job-search/find"; import { postingText, type FoundPosting } from "@/lib/job-search/search"; import { readPostingJob } from "@/lib/jobs/read-posting"; @@ -167,7 +168,7 @@ export async function dismissCandidate(candidateId: string): Promise<{ undo: Dis await dismissPosting(foundFromCandidate(candidate)); await prisma.$transaction(async (tx) => { // Locked, so a draft that's just being created either lands first and is deleted here, or finds it dismissed and isn't made. - await tx.$queryRaw`SELECT 1 FROM "AgentCandidate" WHERE id = ${candidateId} FOR UPDATE`; + await tx.$queryRaw`SELECT 1 FROM "AgentCandidate" WHERE id = ${candidateId} ${forUpdate()}`; const current = await tx.agentCandidate.findUniqueOrThrow({ where: { id: candidateId }, select: { applicationId: true } }); if (current.applicationId) await tx.application.deleteMany({ where: { id: current.applicationId } }); await tx.agentCandidate.update({ where: { id: candidateId }, data: { status: AgentCandidateStatus.DISMISSED, ...DISMISSED_TRIM } }); diff --git a/src/lib/auth/login-throttle.ts b/src/lib/auth/login-throttle.ts index 3f3d3cc3..82e2a87c 100644 --- a/src/lib/auth/login-throttle.ts +++ b/src/lib/auth/login-throttle.ts @@ -1,6 +1,7 @@ import "server-only"; import type { PrismaClient } from "@/generated/prisma/client"; +import { forUpdate } from "@/lib/database"; import { afterFailure, GLOBAL, PER_IP, waitMs, type ThrottleState } from "./throttle"; @@ -31,7 +32,7 @@ export async function recordLoginFailure(db: PrismaClient, ip: string, now = new ON CONFLICT (key) DO NOTHING`; const [row] = await tx.$queryRaw` SELECT failures, "windowStart", "blockedUntil" FROM "LoginThrottle" - WHERE key = ${key} FOR UPDATE`; + WHERE key = ${key} ${forUpdate()}`; const next = afterFailure(row.failures === 0 ? null : row, now, policy); await tx.loginThrottle.update({ where: { key }, data: { ...next, updatedAt: now } }); } diff --git a/src/lib/data-import.ts b/src/lib/data-import.ts index 0f9b266c..414123f6 100644 --- a/src/lib/data-import.ts +++ b/src/lib/data-import.ts @@ -66,6 +66,12 @@ function omit(value: T, ...keys: K[]): Omit return Object.fromEntries(Object.entries(value).filter(([key]) => !keys.includes(key as K))) as Omit; } +/** `items` without repeats, the first of each `key` kept (createMany's skipDuplicates, which SQLite lacks). */ +function uniqueBy(items: T[], key: (item: T) => string): T[] { + const seen = new Set(); + return items.filter((item) => !seen.has(key(item)) && Boolean(seen.add(key(item)))); +} + const json = (value: unknown) => (value === null || value === undefined ? Prisma.DbNull : (value as Prisma.InputJsonValue)); /** A JSON list for a column that can't be null (#222): anything else reads as empty. */ const jsonList = (value: unknown) => (Array.isArray(value) ? value : []) as Prisma.InputJsonValue; @@ -120,13 +126,17 @@ export async function importExport(db: PrismaClient, data: DossierExport): Promi await tx.language.createMany({ data: profile.languages }); await tx.experience.createMany({ data: profile.experience.map((entry) => omit(entry, "skillIds")) }); await tx.experienceSkill.createMany({ - data: profile.experience.flatMap((entry) => entry.skillIds.map((skillId) => ({ experienceId: entry.id, skillId }))), - skipDuplicates: true, + data: uniqueBy( + profile.experience.flatMap((entry) => entry.skillIds.map((skillId) => ({ experienceId: entry.id, skillId }))), + (link) => `${link.experienceId} ${link.skillId}`, + ), }); await tx.project.createMany({ data: profile.projects.map((entry) => omit(entry, "skillIds")) }); await tx.projectSkill.createMany({ - data: profile.projects.flatMap((entry) => entry.skillIds.map((skillId) => ({ projectId: entry.id, skillId }))), - skipDuplicates: true, + data: uniqueBy( + profile.projects.flatMap((entry) => entry.skillIds.map((skillId) => ({ projectId: entry.id, skillId }))), + (link) => `${link.projectId} ${link.skillId}`, + ), }); await tx.education.createMany({ data: profile.education }); await tx.publication.createMany({ data: profile.publications }); @@ -211,7 +221,7 @@ export async function importExport(db: PrismaClient, data: DossierExport): Promi applicationId: startup.applicationId && applicationIds.has(startup.applicationId) ? startup.applicationId : null, })), }); - await tx.startupBlock.createMany({ data: startupBlocks, skipDuplicates: true }); + await tx.startupBlock.createMany({ data: uniqueBy(startupBlocks, (block) => block.hash) }); }, // A big export takes longer than the default five seconds. { timeout: 120_000, maxWait: 10_000 }, diff --git a/src/lib/database.db.test.ts b/src/lib/database.db.test.ts new file mode 100644 index 00000000..019e07f3 --- /dev/null +++ b/src/lib/database.db.test.ts @@ -0,0 +1,42 @@ +import { afterAll, beforeEach, describe, expect, it } from "vitest"; + +import { createTestClient, resetDatabase } from "@/test/db"; + +const db = createTestClient(); + +beforeEach(() => resetDatabase(db)); +afterAll(() => db.$disconnect()); + +describe("the database client", () => { + it("keeps a write made during another request's transaction when that one is undone", async () => { + let opened!: () => void; + let undo!: () => void; + const isOpen = new Promise((resolve) => (opened = resolve)); + const transaction = db + .$transaction(async (tx) => { + await tx.setting.create({ data: { key: "inside", value: "1" } }); + opened(); + await new Promise((resolve) => (undo = resolve)); + throw new Error("undone"); + }) + .catch(() => "undone"); + await isOpen; + + // Prisma's queries only start when awaited: give this one time to run. + const outside = Promise.resolve(db.setting.create({ data: { key: "outside", value: "2" } })); + await new Promise((resolve) => setTimeout(resolve, 100)); + undo(); + + expect(await transaction).toBe("undone"); + await outside; + expect(await db.setting.findMany({ select: { key: true } })).toEqual([{ key: "outside" }]); + }); + + it("doesn't make a transaction's own callback wait for the transaction to end", async () => { + const count = await db.$transaction(async (tx) => { + await tx.setting.create({ data: { key: "inside", value: "1" } }); + return db.setting.count(); + }); + expect(count).toBeGreaterThanOrEqual(0); + }); +}); diff --git a/src/lib/database.ts b/src/lib/database.ts new file mode 100644 index 00000000..8000f993 --- /dev/null +++ b/src/lib/database.ts @@ -0,0 +1,104 @@ +import { AsyncLocalStorage } from "node:async_hooks"; + +import { PrismaBetterSqlite3 } from "@prisma/adapter-better-sqlite3"; +import { PrismaPg } from "@prisma/adapter-pg"; + +import { Prisma, PrismaClient } from "@/generated/prisma/client"; +import { PrismaClient as SqlitePrismaClient } from "@/generated/prisma-sqlite/client"; + +// PostgreSQL or SQLite (#17), chosen by DATABASE_URL: postgresql://… or +// file:/absolute/path.db. Both clients come from the same models, so the app +// types everything with the PostgreSQL one. + +/** Whether a DATABASE_URL points at SQLite. */ +export const isSqliteUrl = (url: string) => url.startsWith("file:"); + +/** Whether this process uses SQLite. */ +export const usesSqlite = () => isSqliteUrl(process.env.DATABASE_URL ?? ""); + +/** A client for the database at `url`. */ +export function createPrismaClient(url: string): PrismaClient { + return isSqliteUrl(url) ? createSqliteClient(url) : new PrismaClient({ adapter: new PrismaPg({ connectionString: url }) }); +} + +/** + * `FOR UPDATE`, to lock the rows a `$queryRaw` reads until the transaction + * ends. SQLite has no row locks and needs none: its transactions run one at + * a time. + */ +export const forUpdate = () => (usesSqlite() ? Prisma.empty : Prisma.sql`FOR UPDATE`); + +type SqliteAdapter = Awaited>; + +/** A first-come, first-served lock; `acquire` resolves to its release. */ +function createLock() { + let tail = Promise.resolve(); + return { + acquire(): Promise<() => void> { + let release!: () => void; + const released = new Promise((resolve) => (release = resolve)); + const acquired = tail.then(() => release); + tail = tail.then(() => released); + return acquired; + }, + }; +} + +/** + * The SQLite client. better-sqlite3 has one connection, so a query made + * while another request's transaction is open would run inside it, and be + * undone with it. Here such queries wait until the transaction ends; queries + * from inside a `$transaction` callback still run at once, as before. + */ +function createSqliteClient(url: string): PrismaClient { + const lock = createLock(); + const inTransaction = new AsyncLocalStorage(); + const serialized = async (run: () => Promise): Promise => { + if (inTransaction.getStore()) return run(); + const release = await lock.acquire(); + try { + return await run(); + } finally { + release(); + } + }; + + const factory = new PrismaBetterSqlite3({ url }); + const connect = factory.connect.bind(factory); + factory.connect = async () => { + const adapter = await connect(); + return Object.assign(Object.create(adapter) as SqliteAdapter, { + queryRaw: (query: Parameters[0]) => serialized(() => adapter.queryRaw(query)), + executeRaw: (query: Parameters[0]) => serialized(() => adapter.executeRaw(query)), + executeScript: (script: string) => serialized(() => adapter.executeScript(script)), + async startTransaction(isolationLevel?: Parameters[0]) { + const release = await lock.acquire(); + try { + const transaction = await adapter.startTransaction(isolationLevel); + const end = (finish: () => Promise) => () => finish().finally(release); + return Object.assign(transaction, { + commit: end(transaction.commit.bind(transaction)), + rollback: end(transaction.rollback.bind(transaction)), + }); + } catch (error) { + release(); + throw error; + } + }, + }); + }; + + const client = new SqlitePrismaClient({ adapter: factory }); + return new Proxy(client, { + get(target, key) { + const value = Reflect.get(target, key, target); + if (key === "$transaction") { + return (input: unknown, options?: unknown) => + typeof input === "function" + ? value.call(target, (tx: unknown) => inTransaction.run(true, () => input(tx)), options) + : value.call(target, input, options); + } + return typeof value === "function" ? value.bind(target) : value; + }, + }) as unknown as PrismaClient; +} diff --git a/src/lib/document-units.db.test.ts b/src/lib/document-units.db.test.ts index c76bdcb2..b890f031 100644 --- a/src/lib/document-units.db.test.ts +++ b/src/lib/document-units.db.test.ts @@ -1,5 +1,6 @@ import { afterAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { usesSqlite } from "@/lib/database"; import { createTestClient, resetDatabase } from "@/test/db"; import { updateDocuments } from "./document-units"; @@ -44,7 +45,8 @@ describe("updateDocuments", () => { expect(letterAfter.updatedAt).toEqual(letterBefore.updatedAt); }); - it("starts over when a document changed in between, so nothing is overwritten unseen", async () => { + // SQLite runs one transaction at a time, so nobody can save in between. + it.skipIf(usesSqlite())("starts over when a document changed in between, so nothing is overwritten unseen", async () => { const seen: string[] = []; await updateDocuments("app-1", async (documents) => { const summary = documents.RESUME![0]; diff --git a/src/lib/env.test.ts b/src/lib/env.test.ts index 81556854..497c4734 100644 --- a/src/lib/env.test.ts +++ b/src/lib/env.test.ts @@ -103,6 +103,13 @@ describe("parseEnv", () => { ]); }); + it("accepts SQLite at an absolute path, not a relative one (#17)", () => { + expect(parseEnv({ ...valid, DATABASE_URL: "file:/data/dossier.db" }).DATABASE_URL).toBe("file:/data/dossier.db"); + expect(problems({ ...valid, DATABASE_URL: "file:./dossier.db" })).toEqual([ + expect.stringMatching(/^DATABASE_URL must be .* file: and an absolute path for SQLite/), + ]); + }); + it("never echoes secret values", () => { const messages = problems({ DOSSIER_PASSWORD: "hunter2", diff --git a/src/lib/env.ts b/src/lib/env.ts index 7bd9a88e..f7ead836 100644 --- a/src/lib/env.ts +++ b/src/lib/env.ts @@ -47,12 +47,14 @@ const schema = z.object({ ), DATABASE_URL: required( "DATABASE_URL", - "Set it to your PostgreSQL connection string, e.g. postgresql://user:password@host:5432/dossier.", + "Set it to your PostgreSQL connection string, e.g. postgresql://user:password@host:5432/dossier, or to file:/data/dossier.db for SQLite.", ).pipe( - // Never echo the value: it contains the database password. - z.string().regex(/^postgres(ql)?:\/\//, { + // Never echo the value: it contains the database password. SQLite's path + // must be absolute, so the migrations (run from another folder in the + // image) and the app open the same file. + z.string().regex(/^(postgres(ql)?:\/\/|file:\/)/, { error: - "DATABASE_URL must be a PostgreSQL connection string starting with postgresql://.", + "DATABASE_URL must be a PostgreSQL connection string starting with postgresql://, or file: and an absolute path for SQLite (file:/data/dossier.db).", }), ), DOSSIER_DATA_DIR: z diff --git a/src/lib/jobs/zip-pdfs.ts b/src/lib/jobs/zip-pdfs.ts index bd07f81d..4b070c96 100644 --- a/src/lib/jobs/zip-pdfs.ts +++ b/src/lib/jobs/zip-pdfs.ts @@ -89,12 +89,15 @@ export const zipPdfsJob = defineJob({ prisma.application.findMany({ where: { documents: { some: { kind: { in: PRINTED } } } }, orderBy: { createdAt: "asc" }, - select: { id: true, company: true, role: true, documents: { where: { kind: { in: PRINTED } }, select: { kind: true }, orderBy: { kind: "asc" } } }, + select: { id: true, company: true, role: true, documents: { where: { kind: { in: PRINTED } }, select: { kind: true } } }, }), loadProfileName(), loadSettings(), ]); - const documents = applications.flatMap((application) => application.documents.map(({ kind }) => ({ application, kind }))); + // In PRINTED's order: SQLite sorts enums by name, not as declared. + const documents = applications.flatMap((application) => + PRINTED.filter((kind) => application.documents.some((document) => document.kind === kind)).map((kind) => ({ application, kind })), + ); let done = 0; await step(`Rendering ${documents.length} documents`); diff --git a/src/lib/prisma.ts b/src/lib/prisma.ts index b2a5c597..89224e35 100644 --- a/src/lib/prisma.ts +++ b/src/lib/prisma.ts @@ -1,8 +1,7 @@ import "server-only"; -import { PrismaPg } from "@prisma/adapter-pg"; - -import { PrismaClient } from "@/generated/prisma/client"; +import type { PrismaClient } from "@/generated/prisma/client"; +import { createPrismaClient } from "@/lib/database"; import { env } from "@/lib/env"; // Reuse one client across hot reloads in development instead of opening a new @@ -10,9 +9,7 @@ import { env } from "@/lib/env"; const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient }; function client(): PrismaClient { - globalForPrisma.prisma ??= new PrismaClient({ - adapter: new PrismaPg({ connectionString: env.DATABASE_URL }), - }); + globalForPrisma.prisma ??= createPrismaClient(env.DATABASE_URL); return globalForPrisma.prisma; } diff --git a/src/lib/startups/store.ts b/src/lib/startups/store.ts index 74656560..0cd540ec 100644 --- a/src/lib/startups/store.ts +++ b/src/lib/startups/store.ts @@ -4,6 +4,7 @@ import { createHash } from "node:crypto"; import type { Prisma, Startup } from "@/generated/prisma/client"; import { ApplicationStatus, StartupStatus } from "@/generated/prisma/enums"; +import { usesSqlite } from "@/lib/database"; import type { ProfileData } from "@/lib/profile"; import { prisma } from "@/lib/prisma"; import { loadProfile } from "@/lib/profile-data"; @@ -121,7 +122,15 @@ export async function upsertStartups(found: readonly FoundStartup[], now = new D else creates.push({ source: startup.source, externalId: startup.externalId, ...fields }); } for (let index = 0; index < creates.length; index += 200) { - await prisma.startup.createMany({ data: creates.slice(index, index + 200), skipDuplicates: true }); + const batch = creates.slice(index, index + 200); + // SQLite has no skipDuplicates: an upsert that changes nothing instead. + if (usesSqlite()) { + await prisma.$transaction( + batch.map((data) => prisma.startup.upsert({ where: { source_externalId: { source: data.source, externalId: data.externalId } }, create: data, update: {} })), + ); + } else { + await prisma.startup.createMany({ data: batch, skipDuplicates: true }); + } } for (let index = 0; index < updates.length; index += 50) { await prisma.$transaction(updates.slice(index, index + 50)); @@ -236,7 +245,7 @@ export async function removeStartup(id: string): Promise { const row = await prisma.startup.findUnique({ where: { id } }); if (!row) return false; await prisma.$transaction([ - prisma.startupBlock.createMany({ data: blockHashes(startupView(row)).map((entry) => ({ hash: entry })), skipDuplicates: true }), + ...blockHashes(startupView(row)).map((hash) => prisma.startupBlock.upsert({ where: { hash }, create: { hash }, update: {} })), prisma.startup.delete({ where: { id } }), ]); return true; diff --git a/src/lib/status-changes.ts b/src/lib/status-changes.ts index d7dbde70..d53847f2 100644 --- a/src/lib/status-changes.ts +++ b/src/lib/status-changes.ts @@ -2,6 +2,7 @@ import "server-only"; import type { PrismaClient, StatusEvent } from "@/generated/prisma/client"; import type { ApplicationStatus } from "@/generated/prisma/enums"; +import { forUpdate } from "@/lib/database"; import { advance, undoChange } from "./status"; @@ -22,7 +23,7 @@ export async function applyStatusChange( ): Promise { return db.$transaction(async (tx) => { // Lock the row so two changes to the same application can't interleave. - await tx.$queryRaw`SELECT 1 FROM "Application" WHERE id = ${applicationId} FOR UPDATE`; + await tx.$queryRaw`SELECT 1 FROM "Application" WHERE id = ${applicationId} ${forUpdate()}`; const application = await tx.application.findUnique({ where: { id: applicationId }, select: { status: true, furthestStage: true, sentAt: true }, @@ -53,7 +54,7 @@ export async function undoStatusChange(db: PrismaClient, eventId: string): Promi const event = await tx.statusEvent.findUnique({ where: { id: eventId } }); if (!event) return { outcome: "not-found" }; - await tx.$queryRaw`SELECT 1 FROM "Application" WHERE id = ${event.applicationId} FOR UPDATE`; + await tx.$queryRaw`SELECT 1 FROM "Application" WHERE id = ${event.applicationId} ${forUpdate()}`; const latest = await tx.statusEvent.findFirst({ where: { applicationId: event.applicationId }, orderBy: [{ at: "desc" }, { id: "desc" }], diff --git a/src/test/db.ts b/src/test/db.ts index 5dbd025c..48ecd820 100644 --- a/src/test/db.ts +++ b/src/test/db.ts @@ -1,11 +1,16 @@ -import { PrismaPg } from "@prisma/adapter-pg"; +import path from "node:path"; -import { PrismaClient } from "@/generated/prisma/client"; +import type { PrismaClient } from "@/generated/prisma/client"; +import { createPrismaClient, isSqliteUrl } from "@/lib/database"; + +const refuse = (name: string) => + new Error(`Refusing to run database tests against "${name}": its name must end in "_test".`); /** * The database for `*.db.test.ts` files: TEST_DATABASE_URL, or DATABASE_URL - * with "_test" added to the database name. Refuses any database whose name - * doesn't end in "_test", so tests can never wipe development data. + * with "_test" added to the database name (for SQLite, the file name). + * Refuses any database whose name doesn't end in "_test", so tests can never + * wipe development data. */ export function testDatabaseUrl(): string { const explicit = process.env.TEST_DATABASE_URL; @@ -13,20 +18,35 @@ export function testDatabaseUrl(): string { if (!explicit && !base) { throw new Error("Database tests need DATABASE_URL or TEST_DATABASE_URL."); } + if (isSqliteUrl(explicit ?? base!)) { + const file = path.parse((explicit ?? base!).slice("file:".length)); + const name = explicit ? file.name : `${file.name}_test`; + if (!name.endsWith("_test")) throw refuse(file.base); + return `file:${path.join(file.dir, name + file.ext)}`; + } const url = new URL(explicit ?? base!); if (!explicit) url.pathname = `${url.pathname}_test`; - if (!url.pathname.endsWith("_test")) { - throw new Error(`Refusing to run database tests against "${url.pathname.slice(1)}": its name must end in "_test".`); - } + if (!url.pathname.endsWith("_test")) throw refuse(url.pathname.slice(1)); return url.toString(); } export function createTestClient(): PrismaClient { - return new PrismaClient({ adapter: new PrismaPg({ connectionString: testDatabaseUrl() }) }); + return createPrismaClient(testDatabaseUrl()); } /** Empties every table, so each test starts from a blank database. */ export async function resetDatabase(db: PrismaClient): Promise { + if (isSqliteUrl(testDatabaseUrl())) { + const tables = await db.$queryRaw<{ name: string }[]>` + SELECT name FROM sqlite_master + WHERE type = 'table' AND name NOT LIKE 'sqlite_%' AND name <> '_prisma_migrations'`; + await db.$executeRawUnsafe("PRAGMA foreign_keys = OFF"); + // Lets the trigger that keeps built-in themes allow their delete. + await db.$executeRawUnsafe(`UPDATE "Theme" SET "builtIn" = false`); + for (const { name } of tables) await db.$executeRawUnsafe(`DELETE FROM "${name}"`); + await db.$executeRawUnsafe("PRAGMA foreign_keys = ON"); + return; + } const tables = await db.$queryRaw<{ tablename: string }[]>` SELECT tablename FROM pg_tables WHERE schemaname = 'public' AND tablename <> '_prisma_migrations'`; diff --git a/yarn.lock b/yarn.lock index 3f86fa49..9bbe8996 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1288,6 +1288,16 @@ __metadata: languageName: node linkType: hard +"@prisma/adapter-better-sqlite3@npm:^7.10.0": + version: 7.10.1 + resolution: "@prisma/adapter-better-sqlite3@npm:7.10.1" + dependencies: + "@prisma/driver-adapter-utils": "npm:7.10.1" + better-sqlite3: "npm:^12.6.0" + checksum: 10c0/92487b0d7b02781bf26aea194362d49b7bbdc36b256ab60d7bdd0d129982f6498da95048bc07971f9c20c304c190296dce04794817ad1f546382650fef3919e1 + languageName: node + linkType: hard + "@prisma/adapter-pg@npm:^7.10.0": version: 7.10.0 resolution: "@prisma/adapter-pg@npm:7.10.0" @@ -1343,6 +1353,13 @@ __metadata: languageName: node linkType: hard +"@prisma/debug@npm:7.10.1": + version: 7.10.1 + resolution: "@prisma/debug@npm:7.10.1" + checksum: 10c0/a220e5706241257375dc6d59df0863d7855caa09ef548af596436fcd620d37ac155b8443f9cf317947de950f4c34b1f748fa0567404977be84f4e575c1085b20 + languageName: node + linkType: hard + "@prisma/debug@npm:7.2.0": version: 7.2.0 resolution: "@prisma/debug@npm:7.2.0" @@ -1382,6 +1399,15 @@ __metadata: languageName: node linkType: hard +"@prisma/driver-adapter-utils@npm:7.10.1": + version: 7.10.1 + resolution: "@prisma/driver-adapter-utils@npm:7.10.1" + dependencies: + "@prisma/debug": "npm:7.10.1" + checksum: 10c0/faba8c0d820a4a8702f52e3bd985a12463a8aebead5d4fb0480cf0f0d22e6dcd148712c7f4fc28969a56d2c27dba5129ac29225b09c88e3b61b5e3c8d5ce51c4 + languageName: node + linkType: hard + "@prisma/engines-version@npm:7.10.0-4.0edf323efd1d98336f3f0a68684b56f689b900d3": version: 7.10.0-4.0edf323efd1d98336f3f0a68684b56f689b900d3 resolution: "@prisma/engines-version@npm:7.10.0-4.0edf323efd1d98336f3f0a68684b56f689b900d3" @@ -4304,7 +4330,7 @@ __metadata: languageName: node linkType: hard -"base64-js@npm:^1.5.1": +"base64-js@npm:^1.3.1, base64-js@npm:^1.5.1": version: 1.5.1 resolution: "base64-js@npm:1.5.1" checksum: 10c0/f23823513b63173a001030fae4f2dabe283b99a9d324ade3ad3d148e218134676f1ee8568c877cd79ec1c53158dcf2d2ba527a97c606618928ba99dd930102bf @@ -4327,6 +4353,17 @@ __metadata: languageName: node linkType: hard +"better-sqlite3@npm:^12.6.0": + version: 12.11.1 + resolution: "better-sqlite3@npm:12.11.1" + dependencies: + bindings: "npm:^1.5.0" + node-gyp: "npm:latest" + prebuild-install: "npm:^7.1.1" + checksum: 10c0/162d3fa6c19a68191d09537bd113bd54a636c9fb38b0fc0796f4f270032770695ef35a1bca041a0a0fa9bfa47e20b9e17c94484a3899c31f38881af4d12b7fa5 + languageName: node + linkType: hard + "bidi-js@npm:^1.1.0": version: 1.1.0 resolution: "bidi-js@npm:1.1.0" @@ -4336,6 +4373,26 @@ __metadata: languageName: node linkType: hard +"bindings@npm:^1.5.0": + version: 1.5.0 + resolution: "bindings@npm:1.5.0" + dependencies: + file-uri-to-path: "npm:1.0.0" + checksum: 10c0/3dab2491b4bb24124252a91e656803eac24292473e56554e35bbfe3cc1875332cfa77600c3bac7564049dc95075bf6fcc63a4609920ff2d64d0fe405fcf0d4ba + languageName: node + linkType: hard + +"bl@npm:^4.0.3": + version: 4.1.0 + resolution: "bl@npm:4.1.0" + dependencies: + buffer: "npm:^5.5.0" + inherits: "npm:^2.0.4" + readable-stream: "npm:^3.4.0" + checksum: 10c0/02847e1d2cb089c9dc6958add42e3cdeaf07d13f575973963335ac0fdece563a50ac770ac4c8fa06492d2dd276f6cc3b7f08c7cd9c7a7ad0f8d388b2a28def5f + languageName: node + linkType: hard + "boolbase@npm:^1.0.0": version: 1.0.0 resolution: "boolbase@npm:1.0.0" @@ -4386,6 +4443,16 @@ __metadata: languageName: node linkType: hard +"buffer@npm:^5.5.0": + version: 5.7.1 + resolution: "buffer@npm:5.7.1" + dependencies: + base64-js: "npm:^1.3.1" + ieee754: "npm:^1.1.13" + checksum: 10c0/27cac81cff434ed2876058d72e7c4789d11ff1120ef32c9de48f59eab58179b66710c488987d295ae89a228f835fc66d088652dffeb8e3ba8659f80eb091d55e + languageName: node + linkType: hard + "c12@npm:3.3.4": version: 3.3.4 resolution: "c12@npm:3.3.4" @@ -4483,6 +4550,13 @@ __metadata: languageName: node linkType: hard +"chownr@npm:^1.1.1": + version: 1.1.4 + resolution: "chownr@npm:1.1.4" + checksum: 10c0/ed57952a84cc0c802af900cf7136de643d3aba2eecb59d29344bc2f3f9bf703a301b9d84cdc71f82c3ffc9ccde831b0d92f5b45f91727d6c9da62f23aef9d9db + languageName: node + linkType: hard + "chownr@npm:^3.0.0": version: 3.0.0 resolution: "chownr@npm:3.0.0" @@ -4801,6 +4875,22 @@ __metadata: languageName: node linkType: hard +"decompress-response@npm:^6.0.0": + version: 6.0.0 + resolution: "decompress-response@npm:6.0.0" + dependencies: + mimic-response: "npm:^3.1.0" + checksum: 10c0/bd89d23141b96d80577e70c54fb226b2f40e74a6817652b80a116d7befb8758261ad073a8895648a29cc0a5947021ab66705cb542fa9c143c82022b27c5b175e + languageName: node + linkType: hard + +"deep-extend@npm:^0.6.0": + version: 0.6.0 + resolution: "deep-extend@npm:0.6.0" + checksum: 10c0/1c6b0abcdb901e13a44c7d699116d3d4279fdb261983122a3783e7273844d5f2537dc2e1c454a23fcf645917f93fbf8d07101c1d03c015a87faa662755212566 + languageName: node + linkType: hard + "deep-is@npm:^0.1.3": version: 0.1.4 resolution: "deep-is@npm:0.1.4" @@ -4867,7 +4957,7 @@ __metadata: languageName: node linkType: hard -"detect-libc@npm:^2.0.3, detect-libc@npm:^2.1.2": +"detect-libc@npm:^2.0.0, detect-libc@npm:^2.0.3, detect-libc@npm:^2.1.2": version: 2.1.2 resolution: "detect-libc@npm:2.1.2" checksum: 10c0/acc675c29a5649fa1fb6e255f993b8ee829e510b6b56b0910666949c80c364738833417d0edb5f90e4e46be17228b0f2b66a010513984e18b15deeeac49369c4 @@ -4960,6 +5050,7 @@ __metadata: "@dnd-kit/utilities": "npm:^3.2.2" "@modelcontextprotocol/server": "npm:^2.3.1" "@playwright/test": "npm:1.63.0" + "@prisma/adapter-better-sqlite3": "npm:^7.10.0" "@prisma/adapter-pg": "npm:^7.10.0" "@prisma/client": "npm:^7.10.0" "@tailwindcss/postcss": "npm:^4" @@ -5000,6 +5091,9 @@ __metadata: vite: "npm:^8.3.1" vitest: "npm:^5.0.2" zod: "npm:^4.6.5" + dependenciesMeta: + better-sqlite3: + built: true languageName: unknown linkType: soft @@ -5077,6 +5171,15 @@ __metadata: languageName: node linkType: hard +"end-of-stream@npm:^1.1.0, end-of-stream@npm:^1.4.1": + version: 1.4.5 + resolution: "end-of-stream@npm:1.4.5" + dependencies: + once: "npm:^1.4.0" + checksum: 10c0/b0701c92a10b89afb1cb45bf54a5292c6f008d744eb4382fa559d54775ff31617d1d7bc3ef617575f552e24fad2c7c1a1835948c66b3f3a4be0a6c1f35c883d8 + languageName: node + linkType: hard + "enhanced-resolve@npm:^5.24.1": version: 5.25.1 resolution: "enhanced-resolve@npm:5.25.1" @@ -5680,6 +5783,13 @@ __metadata: languageName: node linkType: hard +"expand-template@npm:^2.0.3": + version: 2.0.3 + resolution: "expand-template@npm:2.0.3" + checksum: 10c0/1c9e7afe9acadf9d373301d27f6a47b34e89b3391b1ef38b7471d381812537ef2457e620ae7f819d2642ce9c43b189b3583813ec395e2938319abe356a9b2f51 + languageName: node + linkType: hard + "expect-type@npm:^1.4.0": version: 1.4.0 resolution: "expect-type@npm:1.4.0" @@ -5811,6 +5921,13 @@ __metadata: languageName: node linkType: hard +"file-uri-to-path@npm:1.0.0": + version: 1.0.0 + resolution: "file-uri-to-path@npm:1.0.0" + checksum: 10c0/3b545e3a341d322d368e880e1c204ef55f1d45cdea65f7efc6c6ce9e0c4d22d802d5629320eb779d006fe59624ac17b0e848d83cc5af7cd101f206cb704f5519 + languageName: node + linkType: hard + "fill-range@npm:^7.1.1": version: 7.1.1 resolution: "fill-range@npm:7.1.1" @@ -5877,6 +5994,13 @@ __metadata: languageName: node linkType: hard +"fs-constants@npm:^1.0.0": + version: 1.0.0 + resolution: "fs-constants@npm:1.0.0" + checksum: 10c0/a0cde99085f0872f4d244e83e03a46aa387b74f5a5af750896c6b05e9077fac00e9932fdf5aef84f2f16634cd473c63037d7a512576da7d5c2b9163d1909f3a8 + languageName: node + linkType: hard + "fsevents@npm:~2.3.3": version: 2.3.3 resolution: "fsevents@npm:2.3.3" @@ -6024,6 +6148,13 @@ __metadata: languageName: node linkType: hard +"github-from-package@npm:0.0.0": + version: 0.0.0 + resolution: "github-from-package@npm:0.0.0" + checksum: 10c0/737ee3f52d0a27e26332cde85b533c21fcdc0b09fb716c3f8e522cfaa9c600d4a631dec9fcde179ec9d47cca89017b7848ed4d6ae6b6b78f936c06825b1fcc12 + languageName: node + linkType: hard + "glob-parent@npm:^5.1.2": version: 5.1.2 resolution: "glob-parent@npm:5.1.2" @@ -6185,6 +6316,13 @@ __metadata: languageName: node linkType: hard +"ieee754@npm:^1.1.13": + version: 1.2.1 + resolution: "ieee754@npm:1.2.1" + checksum: 10c0/b0782ef5e0935b9f12883a2e2aa37baa75da6e66ce6515c168697b42160807d9330de9a32ec1ed73149aea02e0d822e572bca6f1e22bdcbd2149e13b050b17bb + languageName: node + linkType: hard + "ignore@npm:^5.2.0": version: 5.3.2 resolution: "ignore@npm:5.3.2" @@ -6230,13 +6368,20 @@ __metadata: languageName: node linkType: hard -"inherits@npm:~2.0.3": +"inherits@npm:^2.0.3, inherits@npm:^2.0.4, inherits@npm:~2.0.3": version: 2.0.4 resolution: "inherits@npm:2.0.4" checksum: 10c0/4e531f648b29039fb7426fb94075e6545faa1eb9fe83c29f0b6d9e7263aceb4289d2d4557db0d428188eeb449cc7c5e77b0a0b2c4e248ff2a65933a0dee49ef2 languageName: node linkType: hard +"ini@npm:~1.3.0": + version: 1.3.8 + resolution: "ini@npm:1.3.8" + checksum: 10c0/ec93838d2328b619532e4f1ff05df7909760b6f66d9c9e2ded11e5c1897d6f2f9980c54dd638f88654b00919ce31e827040631eab0a3969e4d1abefa0719516a + languageName: node + linkType: hard + "internal-slot@npm:^1.1.0": version: 1.1.0 resolution: "internal-slot@npm:1.1.0" @@ -7186,6 +7331,13 @@ __metadata: languageName: node linkType: hard +"mimic-response@npm:^3.1.0": + version: 3.1.0 + resolution: "mimic-response@npm:3.1.0" + checksum: 10c0/0d6f07ce6e03e9e4445bee655202153bdb8a98d67ee8dc965ac140900d7a2688343e6b4c9a72cfc9ef2f7944dfd76eef4ab2482eb7b293a68b84916bac735362 + languageName: node + linkType: hard + "min-indent@npm:^1.0.0": version: 1.0.1 resolution: "min-indent@npm:1.0.1" @@ -7211,7 +7363,7 @@ __metadata: languageName: node linkType: hard -"minimist@npm:^1.2.0, minimist@npm:^1.2.6": +"minimist@npm:^1.2.0, minimist@npm:^1.2.3, minimist@npm:^1.2.6": version: 1.2.8 resolution: "minimist@npm:1.2.8" checksum: 10c0/19d3fcdca050087b84c2029841a093691a91259a47def2f18222f41e7645a0b7c44ef4b40e88a1e58a40c84d2ef0ee6047c55594d298146d0eb3f6b737c20ce6 @@ -7234,6 +7386,13 @@ __metadata: languageName: node linkType: hard +"mkdirp-classic@npm:^0.5.2, mkdirp-classic@npm:^0.5.3": + version: 0.5.3 + resolution: "mkdirp-classic@npm:0.5.3" + checksum: 10c0/95371d831d196960ddc3833cc6907e6b8f67ac5501a6582f47dfae5eb0f092e9f8ce88e0d83afcae95d6e2b61a01741ba03714eeafb6f7a6e9dcc158ac85b168 + languageName: node + linkType: hard + "ms@npm:^2.1.1, ms@npm:^2.1.3": version: 2.1.3 resolution: "ms@npm:2.1.3" @@ -7276,6 +7435,13 @@ __metadata: languageName: node linkType: hard +"napi-build-utils@npm:^2.0.0": + version: 2.0.0 + resolution: "napi-build-utils@npm:2.0.0" + checksum: 10c0/5833aaeb5cc5c173da47a102efa4680a95842c13e0d9cc70428bd3ee8d96bb2172f8860d2811799b5daa5cbeda779933601492a2028a6a5351c6d0fcf6de83db + languageName: node + linkType: hard + "napi-postinstall@npm:^0.3.4": version: 0.3.4 resolution: "napi-postinstall@npm:0.3.4" @@ -7352,6 +7518,15 @@ __metadata: languageName: node linkType: hard +"node-abi@npm:^3.3.0": + version: 3.101.0 + resolution: "node-abi@npm:3.101.0" + dependencies: + semver: "npm:^7.3.5" + checksum: 10c0/485d99c86c5d4ba1701e65a13fadf881bb93cc1636badd5d80f1a3a25d8b1d114aaab47b1cb3856efbeb112b7a52c346060364c33a46328bfcbddd1c81f9297e + languageName: node + linkType: hard + "node-exports-info@npm:^1.6.0": version: 1.6.2 resolution: "node-exports-info@npm:1.6.2" @@ -7526,6 +7701,15 @@ __metadata: languageName: node linkType: hard +"once@npm:^1.3.1, once@npm:^1.4.0": + version: 1.4.0 + resolution: "once@npm:1.4.0" + dependencies: + wrappy: "npm:1" + checksum: 10c0/5d48aca287dfefabd756621c5dfce5c91a549a93e9fdb7b8246bc4c4790aa2ec17b34a260530474635147aeb631a2dcc8b32c613df0675f96041cbb8244517d0 + languageName: node + linkType: hard + "openai@npm:^7.23.0": version: 7.23.0 resolution: "openai@npm:7.23.0" @@ -7870,6 +8054,28 @@ __metadata: languageName: node linkType: hard +"prebuild-install@npm:^7.1.1": + version: 7.1.3 + resolution: "prebuild-install@npm:7.1.3" + dependencies: + detect-libc: "npm:^2.0.0" + expand-template: "npm:^2.0.3" + github-from-package: "npm:0.0.0" + minimist: "npm:^1.2.3" + mkdirp-classic: "npm:^0.5.3" + napi-build-utils: "npm:^2.0.0" + node-abi: "npm:^3.3.0" + pump: "npm:^3.0.0" + rc: "npm:^1.2.7" + simple-get: "npm:^4.0.0" + tar-fs: "npm:^2.0.0" + tunnel-agent: "npm:^0.6.0" + bin: + prebuild-install: bin.js + checksum: 10c0/25919a42b52734606a4036ab492d37cfe8b601273d8dfb1fa3c84e141a0a475e7bad3ab848c741d2f810cef892fcf6059b8c7fe5b29f98d30e0c29ad009bedff + languageName: node + linkType: hard + "prelude-ls@npm:^1.2.1": version: 1.2.1 resolution: "prelude-ls@npm:1.2.1" @@ -7948,6 +8154,16 @@ __metadata: languageName: node linkType: hard +"pump@npm:^3.0.0": + version: 3.0.4 + resolution: "pump@npm:3.0.4" + dependencies: + end-of-stream: "npm:^1.1.0" + once: "npm:^1.3.1" + checksum: 10c0/2780e66b5471c19e3e3e1063b84f3f6a3a08367f24c5ed552f98cd5901e6ada27c7ad6495d4244f553fd03b01884a4561933064f053f47c8994d84fd352768ea + languageName: node + linkType: hard + "punycode@npm:^2.1.0, punycode@npm:^2.3.1": version: 2.3.1 resolution: "punycode@npm:2.3.1" @@ -8052,6 +8268,20 @@ __metadata: languageName: node linkType: hard +"rc@npm:^1.2.7": + version: 1.2.8 + resolution: "rc@npm:1.2.8" + dependencies: + deep-extend: "npm:^0.6.0" + ini: "npm:~1.3.0" + minimist: "npm:^1.2.0" + strip-json-comments: "npm:~2.0.1" + bin: + rc: ./cli.js + checksum: 10c0/24a07653150f0d9ac7168e52943cc3cb4b7a22c0e43c7dff3219977c2fdca5a2760a304a029c20811a0e79d351f57d46c9bde216193a0f73978496afc2b85b15 + languageName: node + linkType: hard + "react-dom@npm:19.3.0": version: 19.3.0 resolution: "react-dom@npm:19.3.0" @@ -8135,6 +8365,17 @@ __metadata: languageName: node linkType: hard +"readable-stream@npm:^3.1.1, readable-stream@npm:^3.4.0": + version: 3.6.2 + resolution: "readable-stream@npm:3.6.2" + dependencies: + inherits: "npm:^2.0.3" + string_decoder: "npm:^1.1.1" + util-deprecate: "npm:^1.0.1" + checksum: 10c0/e37be5c79c376fdd088a45fa31ea2e423e5d48854be7a22a58869b4e84d25047b193f6acb54f1012331e1bcd667ffb569c01b99d36b0bd59658fb33f513511b7 + languageName: node + linkType: hard + "readable-stream@npm:~2.3.6": version: 2.3.8 resolution: "readable-stream@npm:2.3.8" @@ -8365,6 +8606,13 @@ __metadata: languageName: node linkType: hard +"safe-buffer@npm:^5.0.1, safe-buffer@npm:~5.2.0": + version: 5.2.1 + resolution: "safe-buffer@npm:5.2.1" + checksum: 10c0/6501914237c0a86e9675d4e51d89ca3c21ffd6a31642efeba25ad65720bce6921c9e7e974e5be91a786b25aa058b5303285d3c15dbabf983a919f5f630d349f3 + languageName: node + linkType: hard + "safe-buffer@npm:~5.1.0, safe-buffer@npm:~5.1.1": version: 5.1.2 resolution: "safe-buffer@npm:5.1.2" @@ -8657,6 +8905,24 @@ __metadata: languageName: node linkType: hard +"simple-concat@npm:^1.0.0": + version: 1.0.1 + resolution: "simple-concat@npm:1.0.1" + checksum: 10c0/62f7508e674414008910b5397c1811941d457dfa0db4fd5aa7fa0409eb02c3609608dfcd7508cace75b3a0bf67a2a77990711e32cd213d2c76f4fd12ee86d776 + languageName: node + linkType: hard + +"simple-get@npm:^4.0.0": + version: 4.0.1 + resolution: "simple-get@npm:4.0.1" + dependencies: + decompress-response: "npm:^6.0.0" + once: "npm:^1.3.1" + simple-concat: "npm:^1.0.0" + checksum: 10c0/b0649a581dbca741babb960423248899203165769747142033479a7dc5e77d7b0fced0253c731cd57cf21e31e4d77c9157c3069f4448d558ebc96cf9e1eebcf0 + languageName: node + linkType: hard + "source-map-js@npm:^1.2.1": version: 1.2.1 resolution: "source-map-js@npm:1.2.1" @@ -8807,6 +9073,15 @@ __metadata: languageName: node linkType: hard +"string_decoder@npm:^1.1.1": + version: 1.3.0 + resolution: "string_decoder@npm:1.3.0" + dependencies: + safe-buffer: "npm:~5.2.0" + checksum: 10c0/810614ddb030e271cd591935dcd5956b2410dd079d64ff92a1844d6b7588bf992b3e1b69b0f4d34a3e06e0bd73046ac646b5264c1987b20d0601f81ef35d731d + languageName: node + linkType: hard + "string_decoder@npm:~1.1.1": version: 1.1.1 resolution: "string_decoder@npm:1.1.1" @@ -8839,6 +9114,13 @@ __metadata: languageName: node linkType: hard +"strip-json-comments@npm:~2.0.1": + version: 2.0.1 + resolution: "strip-json-comments@npm:2.0.1" + checksum: 10c0/b509231cbdee45064ff4f9fd73609e2bcc4e84a4d508e9dd0f31f70356473fde18abfb5838c17d56fb236f5a06b102ef115438de0600b749e818a35fbbc48c43 + languageName: node + linkType: hard + "styled-jsx@npm:5.1.6": version: 5.1.6 resolution: "styled-jsx@npm:5.1.6" @@ -8885,6 +9167,31 @@ __metadata: languageName: node linkType: hard +"tar-fs@npm:^2.0.0": + version: 2.1.5 + resolution: "tar-fs@npm:2.1.5" + dependencies: + chownr: "npm:^1.1.1" + mkdirp-classic: "npm:^0.5.2" + pump: "npm:^3.0.0" + tar-stream: "npm:^2.1.4" + checksum: 10c0/b987429214c3ab3be1f439b4e097dc310acf449c35f355956d801f59ef4cfde3b9827797ab2d2f087ed024e459eedc9b3cd860c9190b86e356ba348ade928684 + languageName: node + linkType: hard + +"tar-stream@npm:^2.1.4": + version: 2.2.0 + resolution: "tar-stream@npm:2.2.0" + dependencies: + bl: "npm:^4.0.3" + end-of-stream: "npm:^1.4.1" + fs-constants: "npm:^1.0.0" + inherits: "npm:^2.0.3" + readable-stream: "npm:^3.1.1" + checksum: 10c0/2f4c910b3ee7196502e1ff015a7ba321ec6ea837667220d7bcb8d0852d51cb04b87f7ae471008a6fb8f5b1a1b5078f62f3a82d30c706f20ada1238ac797e7692 + languageName: node + linkType: hard + "tar@npm:^7.5.4": version: 7.5.22 resolution: "tar@npm:7.5.22" @@ -9024,6 +9331,15 @@ __metadata: languageName: node linkType: hard +"tunnel-agent@npm:^0.6.0": + version: 0.6.0 + resolution: "tunnel-agent@npm:0.6.0" + dependencies: + safe-buffer: "npm:^5.0.1" + checksum: 10c0/4c7a1b813e7beae66fdbf567a65ec6d46313643753d0beefb3c7973d66fcec3a1e7f39759f0a0b4465883499c6dc8b0750ab8b287399af2e583823e40410a17a + languageName: node + linkType: hard + "type-check@npm:^0.4.0, type-check@npm:~0.4.0": version: 0.4.0 resolution: "type-check@npm:0.4.0" @@ -9309,7 +9625,7 @@ __metadata: languageName: node linkType: hard -"util-deprecate@npm:~1.0.1": +"util-deprecate@npm:^1.0.1, util-deprecate@npm:~1.0.1": version: 1.0.2 resolution: "util-deprecate@npm:1.0.2" checksum: 10c0/41a5bdd214df2f6c3ecf8622745e4a366c4adced864bc3c833739791aeeeb1838119af7daed4ba36428114b5c67dcda034a79c882e97e43c03e66a4dd7389942 @@ -9597,6 +9913,13 @@ __metadata: languageName: node linkType: hard +"wrappy@npm:1": + version: 1.0.2 + resolution: "wrappy@npm:1.0.2" + checksum: 10c0/56fece1a4018c6a6c8e28fbc88c87e0fbf4ea8fd64fc6c63b18f4acc4bd13e0ad2515189786dd2c30d3eec9663d70f4ecf699330002f8ccb547e4a18231fc9f0 + languageName: node + linkType: hard + "xml-name-validator@npm:^5.0.0": version: 5.0.0 resolution: "xml-name-validator@npm:5.0.0"