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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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 ---------------------------------------------------------------
Expand Down
8 changes: 6 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ yarn-error.log*
next-env.d.ts

/src/generated/prisma
/src/generated/prisma-sqlite

# DOSSIER_DATA_DIR in development
/data
Expand Down
18 changes: 15 additions & 3 deletions DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -41,10 +41,21 @@ Define models in `prisma/schema.prisma`, then create and apply a migration:

```bash
docker/bin/yarn prisma migrate dev --name <migration-name>
docker/bin/yarn sqlite-schema
docker compose exec -e DATABASE_URL=file:/tmp/dossier.db app yarn prisma migrate dev --name <migration-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):

Expand Down Expand Up @@ -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

Expand Down
10 changes: 6 additions & 4 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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 ' \
Expand All @@ -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 \
Expand Down
14 changes: 13 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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)).
Expand Down
10 changes: 9 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,17 @@
"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",
"@dnd-kit/core": "^6.3.1",
"@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",
Expand Down Expand Up @@ -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
}
}
}
6 changes: 2 additions & 4 deletions prisma/seed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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.");
Expand Down
5 changes: 3 additions & 2 deletions prisma/seed/seed.db.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
16 changes: 16 additions & 0 deletions prisma/sqlite-schema.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
39 changes: 39 additions & 0 deletions prisma/sqlite-schema.ts
Original file line number Diff line number Diff line change
@@ -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.");
}
Loading