From e8fbdfd7f25ae4e62ebf5242ad015f31eef3283d Mon Sep 17 00:00:00 2001 From: Khuram Niaz Date: Tue, 13 Oct 2026 08:23:41 -0600 Subject: [PATCH 01/14] chore(ci): let the Jenkins deploy stage defer to COMPOSE_FILE in .env Co-Authored-By: Claude Sonnet 5.5 --- Jenkinsfile | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/Jenkinsfile b/Jenkinsfile index 14cd2dc1e..f8bc46c7d 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -114,12 +114,18 @@ pipeline { sh ''' set -e - docker compose \ - -f docker-compose.yml \ - -f docker-compose.otel.yml \ - up -d \ - --force-recreate \ - --remove-orphans + # An explicit -f overrides COMPOSE_FILE, so pass none + # when .env already names the files. + if grep -qE '^[[:space:]]*COMPOSE_FILE=' .env 2>/dev/null; then + docker compose up -d --force-recreate --remove-orphans + else + docker compose \ + -f docker-compose.yml \ + -f docker-compose.otel.yml \ + up -d \ + --force-recreate \ + --remove-orphans + fi ''' } } From 90146fb81207b628195d76185c446cf02ecdde9a Mon Sep 17 00:00:00 2001 From: Khuram Niaz Date: Tue, 13 Oct 2026 08:49:54 -0600 Subject: [PATCH 02/14] feat(db): switch the datasource to Postgres with a squashed baseline, retire the SQLite migration history and make the real-engine backup round-trip test opt-in Co-Authored-By: Claude Sonnet 5.5 --- __tests__/backupRoundTrip.spec.ts | 19 +- __tests__/helpers/backupTestDb.ts | 18 +- .../20240524022540_init/migration.sql | 0 .../migration.sql | 0 .../migration.sql | 0 .../migration.sql | 0 .../migration.sql | 0 .../migration.sql | 0 .../migration.sql | 0 .../migration.sql | 0 .../migration.sql | 0 .../migration.sql | 0 .../migration.sql | 0 .../migration.sql | 0 .../migration.sql | 0 .../migration.sql | 0 .../20260219201822_apply/migration.sql | 0 .../migration.sql | 0 .../migration.sql | 0 .../migration.sql | 0 .../migration.sql | 0 .../migration.sql | 0 .../migration.sql | 0 .../migration.sql | 0 .../migration.sql | 0 .../migration.sql | 0 .../migration.sql | 0 .../migration.sql | 0 .../migration.sql | 0 .../migration.sql | 0 .../migration.sql | 0 .../migration.sql | 0 .../migration.sql | 0 .../migration.sql | 0 .../migration.sql | 0 .../migration.sql | 0 .../migration.sql | 0 .../migration.sql | 0 .../migration.sql | 0 .../20260912131248_contacts/migration.sql | 0 .../migration.sql | 0 .../migration.sql | 0 .../migration.sql | 0 .../migration.sql | 0 .../20260929012120_ai_usage/migration.sql | 0 .../migration.sql | 0 .../migration_lock.toml | 3 + .../migration.sql | 988 ++++++++++++++++++ prisma/migrations/migration_lock.toml | 2 +- prisma/schema.prisma | 2 +- 50 files changed, 1022 insertions(+), 10 deletions(-) rename prisma/{migrations => migrations-sqlite-legacy}/20240524022540_init/migration.sql (100%) rename prisma/{migrations => migrations-sqlite-legacy}/20240528002543_add_string_id_to_user/migration.sql (100%) rename prisma/{migrations => migrations-sqlite-legacy}/20240604110546_add_label_and_value/migration.sql (100%) rename prisma/{migrations => migrations-sqlite-legacy}/20240612084946_add_joburl_and_applied_fields/migration.sql (100%) rename prisma/{migrations => migrations-sqlite-legacy}/20240624230528_add_profile_resume_models/migration.sql (100%) rename prisma/{migrations => migrations-sqlite-legacy}/20240627061730_update_resume_section_and_summary_relationship/migration.sql (100%) rename prisma/{migrations => migrations-sqlite-legacy}/20241103213832_resume_upload/migration.sql (100%) rename prisma/{migrations => migrations-sqlite-legacy}/20241210004820_add_activity_and_activty_type_table/migration.sql (100%) rename prisma/{migrations => migrations-sqlite-legacy}/20260113163354_add_task_model/migration.sql (100%) rename prisma/{migrations => migrations-sqlite-legacy}/20260130042742_add_automations/migration.sql (100%) rename prisma/{migrations => migrations-sqlite-legacy}/20260131063928_add_user_settings/migration.sql (100%) rename prisma/{migrations => migrations-sqlite-legacy}/20260212202940_add_createdby_to_jobsource/migration.sql (100%) rename prisma/{migrations => migrations-sqlite-legacy}/20260216_multi_user_unique_constraints/migration.sql (100%) rename prisma/{migrations => migrations-sqlite-legacy}/20260217_add_location_unique_constraint/migration.sql (100%) rename prisma/{migrations => migrations-sqlite-legacy}/20260219201822_apply/migration.sql (100%) rename prisma/{migrations => migrations-sqlite-legacy}/20260302223041_add_note_model/migration.sql (100%) rename prisma/{migrations => migrations-sqlite-legacy}/20260303014226_add_tag_model/migration.sql (100%) rename prisma/{migrations => migrations-sqlite-legacy}/20260305175020_add_question_model/migration.sql (100%) rename prisma/{migrations => migrations-sqlite-legacy}/20260326034736_add_cover_letter/migration.sql (100%) rename prisma/{migrations => migrations-sqlite-legacy}/20260626144202_add_mcp_access_token/migration.sql (100%) rename prisma/{migrations => migrations-sqlite-legacy}/20260627071346_add_skill_section/migration.sql (100%) rename prisma/{migrations => migrations-sqlite-legacy}/20260628080148_add_automation_source_config/migration.sql (100%) rename prisma/{migrations => migrations-sqlite-legacy}/20260628161416_add_contact_info_urls/migration.sql (100%) rename prisma/{migrations => migrations-sqlite-legacy}/20260628161718_add_contact_info_url_labels/migration.sql (100%) rename prisma/{migrations => migrations-sqlite-legacy}/20260701071535_add_question_created_via/migration.sql (100%) rename prisma/{migrations => migrations-sqlite-legacy}/20260702153647_add_user_default_resume/migration.sql (100%) rename prisma/{migrations => migrations-sqlite-legacy}/20260707151433_add_workplace_type_to_job/migration.sql (100%) rename prisma/{migrations => migrations-sqlite-legacy}/20260710000001_automation_run_single_active/migration.sql (100%) rename prisma/{migrations => migrations-sqlite-legacy}/20260710000002_job_automation_url_unique/migration.sql (100%) rename prisma/{migrations => migrations-sqlite-legacy}/20260711003153_activity_task_one_to_many/migration.sql (100%) rename prisma/{migrations => migrations-sqlite-legacy}/20260712184644_add_resume_review_data/migration.sql (100%) rename prisma/{migrations => migrations-sqlite-legacy}/20260722065252_job_description_completeness/migration.sql (100%) rename prisma/{migrations => migrations-sqlite-legacy}/20260802221904_chat_conversation/migration.sql (100%) rename prisma/{migrations => migrations-sqlite-legacy}/20260813075758_add_activity_breaks/migration.sql (100%) rename prisma/{migrations => migrations-sqlite-legacy}/20260904000000_job_status_offer_outcomes/migration.sql (100%) rename prisma/{migrations => migrations-sqlite-legacy}/20260904120000_remove_jsearch_rapidapi/migration.sql (100%) rename prisma/{migrations => migrations-sqlite-legacy}/20260907153300_company_watchlist/migration.sql (100%) rename prisma/{migrations => migrations-sqlite-legacy}/20260912131248_contacts/migration.sql (100%) rename prisma/{migrations => migrations-sqlite-legacy}/20260912170952_contact_default_role/migration.sql (100%) rename prisma/{migrations => migrations-sqlite-legacy}/20260919144828_job_stages_timeline/migration.sql (100%) rename prisma/{migrations => migrations-sqlite-legacy}/20260926120000_backfill_job_applied/migration.sql (100%) rename prisma/{migrations => migrations-sqlite-legacy}/20260927052959_automation_notifications/migration.sql (100%) rename prisma/{migrations => migrations-sqlite-legacy}/20260929012120_ai_usage/migration.sql (100%) rename prisma/{migrations => migrations-sqlite-legacy}/20261002141429_company_notes_size_linkedin/migration.sql (100%) create mode 100644 prisma/migrations-sqlite-legacy/migration_lock.toml create mode 100644 prisma/migrations/00000000000000_init_postgres/migration.sql diff --git a/__tests__/backupRoundTrip.spec.ts b/__tests__/backupRoundTrip.spec.ts index 133c21ed7..8d622ba05 100644 --- a/__tests__/backupRoundTrip.spec.ts +++ b/__tests__/backupRoundTrip.spec.ts @@ -6,18 +6,19 @@ import { APP_CONSTANTS, CONTACT_ROLES, JOB_STAGES } from "@/lib/constants"; import { buildBackupZip } from "@/lib/backup/export"; import { importBackup } from "@/lib/backup/import"; import { listSnapshots, readSnapshot } from "@/lib/backup/snapshot"; -import { seedAccount } from "./helpers/backupTestDb"; +import { dropTestSchema, seedAccount } from "./helpers/backupTestDb"; // Async so the helper can be pulled in with a dynamic import — `require` is // not defined in a Vite-transformed ESM test file. const ctx = await vi.hoisted(async () => { const helpers = await import("./helpers/backupTestDb"); - const { url, dir } = helpers.makeTestDbUrl(); - helpers.pushSchema(url); - return { url, dir }; + const db = helpers.makeTestDb(); + if (db) helpers.pushSchema(db.url); + return db; }); vi.mock("@/lib/db", async () => { + if (!ctx) return { default: {} }; const { PrismaClient } = await import("@prisma/client"); return { default: new PrismaClient({ datasources: { db: { url: ctx.url } } }), @@ -27,13 +28,17 @@ vi.mock("@/lib/db", async () => { // Never start the cron in a test run. vi.mock("@/lib/scheduler", () => ({ syncSchedulerState: vi.fn() })); -const prisma = new PrismaClient({ datasources: { db: { url: ctx.url } } }); +// Only dereferenced inside the skipped-when-absent describe below +const prisma = ctx + ? new PrismaClient({ datasources: { db: { url: ctx.url } } }) + : (undefined as unknown as PrismaClient); let userId: string; let uploadsDir: string; const originalUploads = APP_CONSTANTS.UPLOADS_DIR; beforeAll(async () => { + if (!ctx) return; uploadsDir = path.join(ctx.dir, "uploads"); fs.mkdirSync(path.join(uploadsDir, "files", "resumes"), { recursive: true }); (APP_CONSTANTS as { UPLOADS_DIR: string }).UPLOADS_DIR = uploadsDir; @@ -43,7 +48,9 @@ beforeAll(async () => { }, 120_000); afterAll(async () => { + if (!ctx) return; (APP_CONSTANTS as { UPLOADS_DIR: string }).UPLOADS_DIR = originalUploads; + await dropTestSchema(prisma, ctx.schema); await prisma.$disconnect(); fs.rmSync(ctx.dir, { recursive: true, force: true }); }); @@ -229,7 +236,7 @@ async function seedFullAccount() { }); } -describe("backup round trip", () => { +describe.skipIf(!ctx)("backup round trip", () => { it("restores every row, relation and file over an existing account", async () => { const { buffer } = await buildBackupZip(userId, "owner@example.com"); diff --git a/__tests__/helpers/backupTestDb.ts b/__tests__/helpers/backupTestDb.ts index 4838c1ff0..f6f9070f1 100644 --- a/__tests__/helpers/backupTestDb.ts +++ b/__tests__/helpers/backupTestDb.ts @@ -11,9 +11,16 @@ import { JOB_STATUSES, } from "@/lib/constants"; -export function makeTestDbUrl(): { url: string; dir: string } { +// Opt-in: CI has no database, so without this variable the spec is skipped +export function makeTestDb(): { url: string; schema: string; dir: string } | null { + const base = process.env.BACKUP_TEST_DATABASE_URL; + if (!base) return null; + const schema = `backup_test_${randomUUID().replace(/-/g, "").slice(0, 12)}`; + const url = new URL(base); + // A schema of its own, so the run never touches the database's real tables + url.searchParams.set("schema", schema); const dir = fs.mkdtempSync(path.join(os.tmpdir(), "jobsync-backup-")); - return { url: `file:${path.join(dir, "test.db")}`, dir }; + return { url: url.toString(), schema, dir }; } // db push rather than migrate deploy: the round-trip asserts against the @@ -26,6 +33,13 @@ export function pushSchema(url: string): void { ); } +export async function dropTestSchema( + prisma: PrismaClient, + schema: string, +): Promise { + await prisma.$executeRawUnsafe(`DROP SCHEMA IF EXISTS "${schema}" CASCADE`); +} + // Mirrors what signup() seeds, so "empty account" means the same thing here as // it does in the app. export async function seedAccount( diff --git a/prisma/migrations/20240524022540_init/migration.sql b/prisma/migrations-sqlite-legacy/20240524022540_init/migration.sql similarity index 100% rename from prisma/migrations/20240524022540_init/migration.sql rename to prisma/migrations-sqlite-legacy/20240524022540_init/migration.sql diff --git a/prisma/migrations/20240528002543_add_string_id_to_user/migration.sql b/prisma/migrations-sqlite-legacy/20240528002543_add_string_id_to_user/migration.sql similarity index 100% rename from prisma/migrations/20240528002543_add_string_id_to_user/migration.sql rename to prisma/migrations-sqlite-legacy/20240528002543_add_string_id_to_user/migration.sql diff --git a/prisma/migrations/20240604110546_add_label_and_value/migration.sql b/prisma/migrations-sqlite-legacy/20240604110546_add_label_and_value/migration.sql similarity index 100% rename from prisma/migrations/20240604110546_add_label_and_value/migration.sql rename to prisma/migrations-sqlite-legacy/20240604110546_add_label_and_value/migration.sql diff --git a/prisma/migrations/20240612084946_add_joburl_and_applied_fields/migration.sql b/prisma/migrations-sqlite-legacy/20240612084946_add_joburl_and_applied_fields/migration.sql similarity index 100% rename from prisma/migrations/20240612084946_add_joburl_and_applied_fields/migration.sql rename to prisma/migrations-sqlite-legacy/20240612084946_add_joburl_and_applied_fields/migration.sql diff --git a/prisma/migrations/20240624230528_add_profile_resume_models/migration.sql b/prisma/migrations-sqlite-legacy/20240624230528_add_profile_resume_models/migration.sql similarity index 100% rename from prisma/migrations/20240624230528_add_profile_resume_models/migration.sql rename to prisma/migrations-sqlite-legacy/20240624230528_add_profile_resume_models/migration.sql diff --git a/prisma/migrations/20240627061730_update_resume_section_and_summary_relationship/migration.sql b/prisma/migrations-sqlite-legacy/20240627061730_update_resume_section_and_summary_relationship/migration.sql similarity index 100% rename from prisma/migrations/20240627061730_update_resume_section_and_summary_relationship/migration.sql rename to prisma/migrations-sqlite-legacy/20240627061730_update_resume_section_and_summary_relationship/migration.sql diff --git a/prisma/migrations/20241103213832_resume_upload/migration.sql b/prisma/migrations-sqlite-legacy/20241103213832_resume_upload/migration.sql similarity index 100% rename from prisma/migrations/20241103213832_resume_upload/migration.sql rename to prisma/migrations-sqlite-legacy/20241103213832_resume_upload/migration.sql diff --git a/prisma/migrations/20241210004820_add_activity_and_activty_type_table/migration.sql b/prisma/migrations-sqlite-legacy/20241210004820_add_activity_and_activty_type_table/migration.sql similarity index 100% rename from prisma/migrations/20241210004820_add_activity_and_activty_type_table/migration.sql rename to prisma/migrations-sqlite-legacy/20241210004820_add_activity_and_activty_type_table/migration.sql diff --git a/prisma/migrations/20260113163354_add_task_model/migration.sql b/prisma/migrations-sqlite-legacy/20260113163354_add_task_model/migration.sql similarity index 100% rename from prisma/migrations/20260113163354_add_task_model/migration.sql rename to prisma/migrations-sqlite-legacy/20260113163354_add_task_model/migration.sql diff --git a/prisma/migrations/20260130042742_add_automations/migration.sql b/prisma/migrations-sqlite-legacy/20260130042742_add_automations/migration.sql similarity index 100% rename from prisma/migrations/20260130042742_add_automations/migration.sql rename to prisma/migrations-sqlite-legacy/20260130042742_add_automations/migration.sql diff --git a/prisma/migrations/20260131063928_add_user_settings/migration.sql b/prisma/migrations-sqlite-legacy/20260131063928_add_user_settings/migration.sql similarity index 100% rename from prisma/migrations/20260131063928_add_user_settings/migration.sql rename to prisma/migrations-sqlite-legacy/20260131063928_add_user_settings/migration.sql diff --git a/prisma/migrations/20260212202940_add_createdby_to_jobsource/migration.sql b/prisma/migrations-sqlite-legacy/20260212202940_add_createdby_to_jobsource/migration.sql similarity index 100% rename from prisma/migrations/20260212202940_add_createdby_to_jobsource/migration.sql rename to prisma/migrations-sqlite-legacy/20260212202940_add_createdby_to_jobsource/migration.sql diff --git a/prisma/migrations/20260216_multi_user_unique_constraints/migration.sql b/prisma/migrations-sqlite-legacy/20260216_multi_user_unique_constraints/migration.sql similarity index 100% rename from prisma/migrations/20260216_multi_user_unique_constraints/migration.sql rename to prisma/migrations-sqlite-legacy/20260216_multi_user_unique_constraints/migration.sql diff --git a/prisma/migrations/20260217_add_location_unique_constraint/migration.sql b/prisma/migrations-sqlite-legacy/20260217_add_location_unique_constraint/migration.sql similarity index 100% rename from prisma/migrations/20260217_add_location_unique_constraint/migration.sql rename to prisma/migrations-sqlite-legacy/20260217_add_location_unique_constraint/migration.sql diff --git a/prisma/migrations/20260219201822_apply/migration.sql b/prisma/migrations-sqlite-legacy/20260219201822_apply/migration.sql similarity index 100% rename from prisma/migrations/20260219201822_apply/migration.sql rename to prisma/migrations-sqlite-legacy/20260219201822_apply/migration.sql diff --git a/prisma/migrations/20260302223041_add_note_model/migration.sql b/prisma/migrations-sqlite-legacy/20260302223041_add_note_model/migration.sql similarity index 100% rename from prisma/migrations/20260302223041_add_note_model/migration.sql rename to prisma/migrations-sqlite-legacy/20260302223041_add_note_model/migration.sql diff --git a/prisma/migrations/20260303014226_add_tag_model/migration.sql b/prisma/migrations-sqlite-legacy/20260303014226_add_tag_model/migration.sql similarity index 100% rename from prisma/migrations/20260303014226_add_tag_model/migration.sql rename to prisma/migrations-sqlite-legacy/20260303014226_add_tag_model/migration.sql diff --git a/prisma/migrations/20260305175020_add_question_model/migration.sql b/prisma/migrations-sqlite-legacy/20260305175020_add_question_model/migration.sql similarity index 100% rename from prisma/migrations/20260305175020_add_question_model/migration.sql rename to prisma/migrations-sqlite-legacy/20260305175020_add_question_model/migration.sql diff --git a/prisma/migrations/20260326034736_add_cover_letter/migration.sql b/prisma/migrations-sqlite-legacy/20260326034736_add_cover_letter/migration.sql similarity index 100% rename from prisma/migrations/20260326034736_add_cover_letter/migration.sql rename to prisma/migrations-sqlite-legacy/20260326034736_add_cover_letter/migration.sql diff --git a/prisma/migrations/20260626144202_add_mcp_access_token/migration.sql b/prisma/migrations-sqlite-legacy/20260626144202_add_mcp_access_token/migration.sql similarity index 100% rename from prisma/migrations/20260626144202_add_mcp_access_token/migration.sql rename to prisma/migrations-sqlite-legacy/20260626144202_add_mcp_access_token/migration.sql diff --git a/prisma/migrations/20260627071346_add_skill_section/migration.sql b/prisma/migrations-sqlite-legacy/20260627071346_add_skill_section/migration.sql similarity index 100% rename from prisma/migrations/20260627071346_add_skill_section/migration.sql rename to prisma/migrations-sqlite-legacy/20260627071346_add_skill_section/migration.sql diff --git a/prisma/migrations/20260628080148_add_automation_source_config/migration.sql b/prisma/migrations-sqlite-legacy/20260628080148_add_automation_source_config/migration.sql similarity index 100% rename from prisma/migrations/20260628080148_add_automation_source_config/migration.sql rename to prisma/migrations-sqlite-legacy/20260628080148_add_automation_source_config/migration.sql diff --git a/prisma/migrations/20260628161416_add_contact_info_urls/migration.sql b/prisma/migrations-sqlite-legacy/20260628161416_add_contact_info_urls/migration.sql similarity index 100% rename from prisma/migrations/20260628161416_add_contact_info_urls/migration.sql rename to prisma/migrations-sqlite-legacy/20260628161416_add_contact_info_urls/migration.sql diff --git a/prisma/migrations/20260628161718_add_contact_info_url_labels/migration.sql b/prisma/migrations-sqlite-legacy/20260628161718_add_contact_info_url_labels/migration.sql similarity index 100% rename from prisma/migrations/20260628161718_add_contact_info_url_labels/migration.sql rename to prisma/migrations-sqlite-legacy/20260628161718_add_contact_info_url_labels/migration.sql diff --git a/prisma/migrations/20260701071535_add_question_created_via/migration.sql b/prisma/migrations-sqlite-legacy/20260701071535_add_question_created_via/migration.sql similarity index 100% rename from prisma/migrations/20260701071535_add_question_created_via/migration.sql rename to prisma/migrations-sqlite-legacy/20260701071535_add_question_created_via/migration.sql diff --git a/prisma/migrations/20260702153647_add_user_default_resume/migration.sql b/prisma/migrations-sqlite-legacy/20260702153647_add_user_default_resume/migration.sql similarity index 100% rename from prisma/migrations/20260702153647_add_user_default_resume/migration.sql rename to prisma/migrations-sqlite-legacy/20260702153647_add_user_default_resume/migration.sql diff --git a/prisma/migrations/20260707151433_add_workplace_type_to_job/migration.sql b/prisma/migrations-sqlite-legacy/20260707151433_add_workplace_type_to_job/migration.sql similarity index 100% rename from prisma/migrations/20260707151433_add_workplace_type_to_job/migration.sql rename to prisma/migrations-sqlite-legacy/20260707151433_add_workplace_type_to_job/migration.sql diff --git a/prisma/migrations/20260710000001_automation_run_single_active/migration.sql b/prisma/migrations-sqlite-legacy/20260710000001_automation_run_single_active/migration.sql similarity index 100% rename from prisma/migrations/20260710000001_automation_run_single_active/migration.sql rename to prisma/migrations-sqlite-legacy/20260710000001_automation_run_single_active/migration.sql diff --git a/prisma/migrations/20260710000002_job_automation_url_unique/migration.sql b/prisma/migrations-sqlite-legacy/20260710000002_job_automation_url_unique/migration.sql similarity index 100% rename from prisma/migrations/20260710000002_job_automation_url_unique/migration.sql rename to prisma/migrations-sqlite-legacy/20260710000002_job_automation_url_unique/migration.sql diff --git a/prisma/migrations/20260711003153_activity_task_one_to_many/migration.sql b/prisma/migrations-sqlite-legacy/20260711003153_activity_task_one_to_many/migration.sql similarity index 100% rename from prisma/migrations/20260711003153_activity_task_one_to_many/migration.sql rename to prisma/migrations-sqlite-legacy/20260711003153_activity_task_one_to_many/migration.sql diff --git a/prisma/migrations/20260712184644_add_resume_review_data/migration.sql b/prisma/migrations-sqlite-legacy/20260712184644_add_resume_review_data/migration.sql similarity index 100% rename from prisma/migrations/20260712184644_add_resume_review_data/migration.sql rename to prisma/migrations-sqlite-legacy/20260712184644_add_resume_review_data/migration.sql diff --git a/prisma/migrations/20260722065252_job_description_completeness/migration.sql b/prisma/migrations-sqlite-legacy/20260722065252_job_description_completeness/migration.sql similarity index 100% rename from prisma/migrations/20260722065252_job_description_completeness/migration.sql rename to prisma/migrations-sqlite-legacy/20260722065252_job_description_completeness/migration.sql diff --git a/prisma/migrations/20260802221904_chat_conversation/migration.sql b/prisma/migrations-sqlite-legacy/20260802221904_chat_conversation/migration.sql similarity index 100% rename from prisma/migrations/20260802221904_chat_conversation/migration.sql rename to prisma/migrations-sqlite-legacy/20260802221904_chat_conversation/migration.sql diff --git a/prisma/migrations/20260813075758_add_activity_breaks/migration.sql b/prisma/migrations-sqlite-legacy/20260813075758_add_activity_breaks/migration.sql similarity index 100% rename from prisma/migrations/20260813075758_add_activity_breaks/migration.sql rename to prisma/migrations-sqlite-legacy/20260813075758_add_activity_breaks/migration.sql diff --git a/prisma/migrations/20260904000000_job_status_offer_outcomes/migration.sql b/prisma/migrations-sqlite-legacy/20260904000000_job_status_offer_outcomes/migration.sql similarity index 100% rename from prisma/migrations/20260904000000_job_status_offer_outcomes/migration.sql rename to prisma/migrations-sqlite-legacy/20260904000000_job_status_offer_outcomes/migration.sql diff --git a/prisma/migrations/20260904120000_remove_jsearch_rapidapi/migration.sql b/prisma/migrations-sqlite-legacy/20260904120000_remove_jsearch_rapidapi/migration.sql similarity index 100% rename from prisma/migrations/20260904120000_remove_jsearch_rapidapi/migration.sql rename to prisma/migrations-sqlite-legacy/20260904120000_remove_jsearch_rapidapi/migration.sql diff --git a/prisma/migrations/20260907153300_company_watchlist/migration.sql b/prisma/migrations-sqlite-legacy/20260907153300_company_watchlist/migration.sql similarity index 100% rename from prisma/migrations/20260907153300_company_watchlist/migration.sql rename to prisma/migrations-sqlite-legacy/20260907153300_company_watchlist/migration.sql diff --git a/prisma/migrations/20260912131248_contacts/migration.sql b/prisma/migrations-sqlite-legacy/20260912131248_contacts/migration.sql similarity index 100% rename from prisma/migrations/20260912131248_contacts/migration.sql rename to prisma/migrations-sqlite-legacy/20260912131248_contacts/migration.sql diff --git a/prisma/migrations/20260912170952_contact_default_role/migration.sql b/prisma/migrations-sqlite-legacy/20260912170952_contact_default_role/migration.sql similarity index 100% rename from prisma/migrations/20260912170952_contact_default_role/migration.sql rename to prisma/migrations-sqlite-legacy/20260912170952_contact_default_role/migration.sql diff --git a/prisma/migrations/20260919144828_job_stages_timeline/migration.sql b/prisma/migrations-sqlite-legacy/20260919144828_job_stages_timeline/migration.sql similarity index 100% rename from prisma/migrations/20260919144828_job_stages_timeline/migration.sql rename to prisma/migrations-sqlite-legacy/20260919144828_job_stages_timeline/migration.sql diff --git a/prisma/migrations/20260926120000_backfill_job_applied/migration.sql b/prisma/migrations-sqlite-legacy/20260926120000_backfill_job_applied/migration.sql similarity index 100% rename from prisma/migrations/20260926120000_backfill_job_applied/migration.sql rename to prisma/migrations-sqlite-legacy/20260926120000_backfill_job_applied/migration.sql diff --git a/prisma/migrations/20260927052959_automation_notifications/migration.sql b/prisma/migrations-sqlite-legacy/20260927052959_automation_notifications/migration.sql similarity index 100% rename from prisma/migrations/20260927052959_automation_notifications/migration.sql rename to prisma/migrations-sqlite-legacy/20260927052959_automation_notifications/migration.sql diff --git a/prisma/migrations/20260929012120_ai_usage/migration.sql b/prisma/migrations-sqlite-legacy/20260929012120_ai_usage/migration.sql similarity index 100% rename from prisma/migrations/20260929012120_ai_usage/migration.sql rename to prisma/migrations-sqlite-legacy/20260929012120_ai_usage/migration.sql diff --git a/prisma/migrations/20261002141429_company_notes_size_linkedin/migration.sql b/prisma/migrations-sqlite-legacy/20261002141429_company_notes_size_linkedin/migration.sql similarity index 100% rename from prisma/migrations/20261002141429_company_notes_size_linkedin/migration.sql rename to prisma/migrations-sqlite-legacy/20261002141429_company_notes_size_linkedin/migration.sql diff --git a/prisma/migrations-sqlite-legacy/migration_lock.toml b/prisma/migrations-sqlite-legacy/migration_lock.toml new file mode 100644 index 000000000..2a5a44419 --- /dev/null +++ b/prisma/migrations-sqlite-legacy/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/migrations/00000000000000_init_postgres/migration.sql b/prisma/migrations/00000000000000_init_postgres/migration.sql new file mode 100644 index 000000000..12baeaf33 --- /dev/null +++ b/prisma/migrations/00000000000000_init_postgres/migration.sql @@ -0,0 +1,988 @@ +-- CreateTable +CREATE TABLE "User" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "email" TEXT NOT NULL, + "password" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "defaultResumeId" TEXT, + + CONSTRAINT "User_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "ApiKey" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "provider" TEXT NOT NULL, + "encryptedKey" TEXT NOT NULL, + "iv" TEXT NOT NULL, + "last4" TEXT NOT NULL, + "label" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "lastUsedAt" TIMESTAMP(3), + + CONSTRAINT "ApiKey_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "UserSettings" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "settings" TEXT NOT NULL DEFAULT '{}', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "UserSettings_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Profile" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + + CONSTRAINT "Profile_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Resume" ( + "id" TEXT NOT NULL, + "profileId" TEXT NOT NULL, + "title" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "FileId" TEXT, + "reviewData" TEXT, + + CONSTRAINT "Resume_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "CoverLetter" ( + "id" TEXT NOT NULL, + "profileId" TEXT NOT NULL, + "title" TEXT NOT NULL, + "content" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "CoverLetter_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "File" ( + "id" TEXT NOT NULL, + "fileName" TEXT NOT NULL, + "filePath" TEXT NOT NULL, + "fileType" TEXT NOT NULL, + "uploadedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "File_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "ResumeSection" ( + "id" TEXT NOT NULL, + "resumeId" TEXT NOT NULL, + "sectionTitle" TEXT NOT NULL, + "sectionType" TEXT NOT NULL, + "summaryId" TEXT, + + CONSTRAINT "ResumeSection_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "ContactInfo" ( + "id" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "resumeId" TEXT NOT NULL, + "firstName" TEXT NOT NULL, + "lastName" TEXT NOT NULL, + "headline" TEXT NOT NULL, + "email" TEXT NOT NULL, + "phone" TEXT NOT NULL, + "address" TEXT, + "url1" TEXT, + "url1Label" TEXT, + "url2" TEXT, + "url2Label" TEXT, + + CONSTRAINT "ContactInfo_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Summary" ( + "id" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "content" TEXT NOT NULL, + + CONSTRAINT "Summary_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "WorkExperience" ( + "id" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "companyId" TEXT NOT NULL, + "jobTitleId" TEXT NOT NULL, + "startDate" TIMESTAMP(3) NOT NULL, + "endDate" TIMESTAMP(3), + "description" TEXT NOT NULL, + "locationId" TEXT NOT NULL, + "resumeSectionId" TEXT, + + CONSTRAINT "WorkExperience_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Education" ( + "id" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "institution" TEXT NOT NULL, + "degree" TEXT NOT NULL, + "fieldOfStudy" TEXT NOT NULL, + "startDate" TIMESTAMP(3) NOT NULL, + "endDate" TIMESTAMP(3), + "description" TEXT, + "locationId" TEXT NOT NULL, + "resumeSectionId" TEXT, + + CONSTRAINT "Education_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "LicenseOrCertification" ( + "id" TEXT NOT NULL, + "title" TEXT NOT NULL, + "organization" TEXT NOT NULL, + "issueDate" TIMESTAMP(3), + "expirationDate" TIMESTAMP(3), + "credentialUrl" TEXT, + "resumeSectionId" TEXT, + + CONSTRAINT "LicenseOrCertification_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "OtherSection" ( + "id" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "title" TEXT NOT NULL, + "content" TEXT NOT NULL, + "resumeSectionId" TEXT, + + CONSTRAINT "OtherSection_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Contact" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "title" TEXT, + "email" TEXT, + "phone" TEXT, + "linkedinUrl" TEXT, + "companyId" TEXT, + "locationId" TEXT, + "relationship" TEXT, + "workedAtCompanyId" TEXT, + "workedFrom" TIMESTAMP(3), + "workedTo" TIMESTAMP(3), + "roleId" TEXT, + "notes" TEXT, + "lastContactedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "createdBy" TEXT NOT NULL, + + CONSTRAINT "Contact_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "ContactRole" ( + "id" TEXT NOT NULL, + "label" TEXT NOT NULL, + "value" TEXT NOT NULL, + "createdBy" TEXT NOT NULL, + + CONSTRAINT "ContactRole_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "JobContact" ( + "id" TEXT NOT NULL, + "jobId" TEXT NOT NULL, + "contactId" TEXT NOT NULL, + "roleId" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "JobContact_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "JobTitle" ( + "id" TEXT NOT NULL, + "label" TEXT NOT NULL, + "value" TEXT NOT NULL, + "createdBy" TEXT NOT NULL, + + CONSTRAINT "JobTitle_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "JobStatus" ( + "id" TEXT NOT NULL, + "label" TEXT NOT NULL, + "value" TEXT NOT NULL, + + CONSTRAINT "JobStatus_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Location" ( + "id" TEXT NOT NULL, + "label" TEXT NOT NULL, + "value" TEXT NOT NULL, + "stateProv" TEXT, + "country" TEXT, + "createdBy" TEXT NOT NULL, + + CONSTRAINT "Location_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Company" ( + "id" TEXT NOT NULL, + "label" TEXT NOT NULL, + "value" TEXT NOT NULL, + "logoUrl" TEXT, + "createdBy" TEXT NOT NULL, + "watched" BOOLEAN NOT NULL DEFAULT false, + "watchedAt" TIMESTAMP(3), + "atsProvider" TEXT, + "atsToken" TEXT, + "atsHost" TEXT, + "websiteUrl" TEXT, + "careersUrl" TEXT, + "industry" TEXT, + "linkedinUrl" TEXT, + "size" TEXT, + "notes" TEXT, + + CONSTRAINT "Company_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "JobSource" ( + "id" TEXT NOT NULL, + "label" TEXT NOT NULL, + "value" TEXT NOT NULL, + "createdBy" TEXT NOT NULL, + + CONSTRAINT "JobSource_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "JobStageType" ( + "id" TEXT NOT NULL, + "label" TEXT NOT NULL, + "value" TEXT NOT NULL, + "statusId" TEXT NOT NULL, + "sortOrder" INTEGER NOT NULL DEFAULT 0, + "createdBy" TEXT NOT NULL, + + CONSTRAINT "JobStageType_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "JobStage" ( + "id" TEXT NOT NULL, + "jobId" TEXT NOT NULL, + "stageTypeId" TEXT NOT NULL, + "occurredAt" TIMESTAMP(3), + "isCurrent" BOOLEAN NOT NULL DEFAULT false, + "outcome" TEXT, + "notes" TEXT, + "durationMins" INTEGER, + "format" TEXT, + "location" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "JobStage_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "JobStageInterviewer" ( + "id" TEXT NOT NULL, + "stageId" TEXT NOT NULL, + "contactId" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "JobStageInterviewer_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "JobStagePrepQuestion" ( + "id" TEXT NOT NULL, + "stageId" TEXT NOT NULL, + "questionId" TEXT NOT NULL, + "asked" BOOLEAN NOT NULL DEFAULT false, + "askedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "JobStagePrepQuestion_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Job" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "jobUrl" TEXT, + "description" TEXT NOT NULL, + "jobType" TEXT NOT NULL, + "workplaceType" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL, + "applied" BOOLEAN NOT NULL DEFAULT false, + "appliedDate" TIMESTAMP(3), + "dueDate" TIMESTAMP(3), + "statusId" TEXT NOT NULL, + "jobTitleId" TEXT NOT NULL, + "companyId" TEXT NOT NULL, + "jobSourceId" TEXT, + "salaryRange" TEXT, + "locationId" TEXT, + "resumeId" TEXT, + "coverLetterId" TEXT, + "automationId" TEXT, + "matchScore" INTEGER, + "matchData" TEXT, + "discoveryStatus" TEXT, + "discoveredAt" TIMESTAMP(3), + "createdVia" TEXT, + "descriptionCompleteness" TEXT, + + CONSTRAINT "Job_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "ActivityType" ( + "id" TEXT NOT NULL, + "label" TEXT NOT NULL, + "value" TEXT NOT NULL, + "description" TEXT, + "createdBy" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "ActivityType_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Activity" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "activityName" TEXT NOT NULL, + "startTime" TIMESTAMP(3) NOT NULL, + "endTime" TIMESTAMP(3), + "duration" INTEGER, + "breakMinutes" INTEGER NOT NULL DEFAULT 0, + "breakStartedAt" TIMESTAMP(3), + "breakPlannedMins" INTEGER, + "description" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "activityTypeId" TEXT NOT NULL, + "taskId" TEXT, + + CONSTRAINT "Activity_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Task" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "title" TEXT NOT NULL, + "description" TEXT, + "status" TEXT NOT NULL DEFAULT 'in-progress', + "priority" INTEGER NOT NULL DEFAULT 5, + "percentComplete" INTEGER NOT NULL DEFAULT 0, + "dueDate" TIMESTAMP(3), + "activityTypeId" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Task_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Automation" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "name" TEXT NOT NULL, + "jobBoard" TEXT NOT NULL, + "keywords" TEXT NOT NULL, + "location" TEXT NOT NULL, + "sourceConfig" TEXT, + "resumeId" TEXT NOT NULL, + "matchThreshold" INTEGER NOT NULL DEFAULT 80, + "scheduleHour" INTEGER NOT NULL, + "nextRunAt" TIMESTAMP(3), + "lastRunAt" TIMESTAMP(3), + "status" TEXT NOT NULL DEFAULT 'active', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Automation_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "AutomationRun" ( + "id" TEXT NOT NULL, + "automationId" TEXT NOT NULL, + "jobsSearched" INTEGER NOT NULL DEFAULT 0, + "jobsDeduplicated" INTEGER NOT NULL DEFAULT 0, + "jobsProcessed" INTEGER NOT NULL DEFAULT 0, + "jobsMatched" INTEGER NOT NULL DEFAULT 0, + "jobsSaved" INTEGER NOT NULL DEFAULT 0, + "status" TEXT NOT NULL DEFAULT 'running', + "errorMessage" TEXT, + "blockedReason" TEXT, + "funnelStats" TEXT, + "startedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "completedAt" TIMESTAMP(3), + + CONSTRAINT "AutomationRun_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Notification" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "automationId" TEXT NOT NULL, + "runId" TEXT, + "kind" TEXT NOT NULL, + "boardToken" TEXT, + "payload" TEXT NOT NULL, + "occurrences" INTEGER NOT NULL DEFAULT 1, + "occurredAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "readAt" TIMESTAMP(3), + "dismissedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "Notification_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "AiCall" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "feature" TEXT NOT NULL, + "provider" TEXT NOT NULL, + "model" TEXT NOT NULL, + "status" TEXT NOT NULL, + "inputTokens" INTEGER, + "outputTokens" INTEGER, + "durationMs" INTEGER NOT NULL, + "firstTokenMs" INTEGER, + "numCtx" INTEGER, + "startedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "AiCall_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Note" ( + "id" TEXT NOT NULL, + "jobId" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "content" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Note_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Tag" ( + "id" TEXT NOT NULL, + "label" TEXT NOT NULL, + "value" TEXT NOT NULL, + "createdBy" TEXT NOT NULL, + + CONSTRAINT "Tag_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Skill" ( + "id" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "category" TEXT, + "order" INTEGER NOT NULL DEFAULT 0, + "tagId" TEXT NOT NULL, + "resumeSectionId" TEXT NOT NULL, + + CONSTRAINT "Skill_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Question" ( + "id" TEXT NOT NULL, + "question" TEXT NOT NULL, + "answer" TEXT, + "createdBy" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "createdVia" TEXT, + + CONSTRAINT "Question_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "McpAccessToken" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "name" TEXT NOT NULL, + "tokenHash" TEXT NOT NULL, + "tokenPrefix" TEXT NOT NULL, + "scopes" TEXT NOT NULL, + "expiresAt" TIMESTAMP(3) NOT NULL, + "lastUsedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "McpAccessToken_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "ChatConversation" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "messages" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "ChatConversation_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "_JobToTag" ( + "A" TEXT NOT NULL, + "B" TEXT NOT NULL, + + CONSTRAINT "_JobToTag_AB_pkey" PRIMARY KEY ("A","B") +); + +-- CreateTable +CREATE TABLE "_QuestionToTag" ( + "A" TEXT NOT NULL, + "B" TEXT NOT NULL, + + CONSTRAINT "_QuestionToTag_AB_pkey" PRIMARY KEY ("A","B") +); + +-- CreateIndex +CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); + +-- CreateIndex +CREATE INDEX "ApiKey_userId_idx" ON "ApiKey"("userId"); + +-- CreateIndex +CREATE UNIQUE INDEX "ApiKey_userId_provider_key" ON "ApiKey"("userId", "provider"); + +-- CreateIndex +CREATE UNIQUE INDEX "UserSettings_userId_key" ON "UserSettings"("userId"); + +-- CreateIndex +CREATE UNIQUE INDEX "Resume_FileId_key" ON "Resume"("FileId"); + +-- CreateIndex +CREATE UNIQUE INDEX "ResumeSection_summaryId_key" ON "ResumeSection"("summaryId"); + +-- CreateIndex +CREATE UNIQUE INDEX "ContactInfo_resumeId_key" ON "ContactInfo"("resumeId"); + +-- CreateIndex +CREATE INDEX "Contact_createdBy_idx" ON "Contact"("createdBy"); + +-- CreateIndex +CREATE INDEX "Contact_companyId_idx" ON "Contact"("companyId"); + +-- CreateIndex +CREATE INDEX "Contact_workedAtCompanyId_idx" ON "Contact"("workedAtCompanyId"); + +-- CreateIndex +CREATE INDEX "Contact_roleId_idx" ON "Contact"("roleId"); + +-- CreateIndex +CREATE UNIQUE INDEX "ContactRole_value_createdBy_key" ON "ContactRole"("value", "createdBy"); + +-- CreateIndex +CREATE INDEX "JobContact_contactId_idx" ON "JobContact"("contactId"); + +-- CreateIndex +CREATE UNIQUE INDEX "JobContact_jobId_contactId_roleId_key" ON "JobContact"("jobId", "contactId", "roleId"); + +-- CreateIndex +CREATE UNIQUE INDEX "JobTitle_value_createdBy_key" ON "JobTitle"("value", "createdBy"); + +-- CreateIndex +CREATE UNIQUE INDEX "JobStatus_value_key" ON "JobStatus"("value"); + +-- CreateIndex +CREATE UNIQUE INDEX "Location_value_createdBy_key" ON "Location"("value", "createdBy"); + +-- CreateIndex +CREATE INDEX "Company_createdBy_watched_idx" ON "Company"("createdBy", "watched"); + +-- CreateIndex +CREATE UNIQUE INDEX "Company_value_createdBy_key" ON "Company"("value", "createdBy"); + +-- CreateIndex +CREATE UNIQUE INDEX "Company_createdBy_atsProvider_atsToken_key" ON "Company"("createdBy", "atsProvider", "atsToken"); + +-- CreateIndex +CREATE UNIQUE INDEX "JobSource_value_createdBy_key" ON "JobSource"("value", "createdBy"); + +-- CreateIndex +CREATE INDEX "JobStageType_createdBy_idx" ON "JobStageType"("createdBy"); + +-- CreateIndex +CREATE UNIQUE INDEX "JobStageType_value_createdBy_key" ON "JobStageType"("value", "createdBy"); + +-- CreateIndex +CREATE INDEX "JobStage_jobId_idx" ON "JobStage"("jobId"); + +-- CreateIndex +CREATE INDEX "JobStage_stageTypeId_idx" ON "JobStage"("stageTypeId"); + +-- CreateIndex +CREATE INDEX "JobStageInterviewer_contactId_idx" ON "JobStageInterviewer"("contactId"); + +-- CreateIndex +CREATE UNIQUE INDEX "JobStageInterviewer_stageId_contactId_key" ON "JobStageInterviewer"("stageId", "contactId"); + +-- CreateIndex +CREATE INDEX "JobStagePrepQuestion_questionId_idx" ON "JobStagePrepQuestion"("questionId"); + +-- CreateIndex +CREATE UNIQUE INDEX "JobStagePrepQuestion_stageId_questionId_key" ON "JobStagePrepQuestion"("stageId", "questionId"); + +-- CreateIndex +CREATE INDEX "Job_userId_automationId_idx" ON "Job"("userId", "automationId"); + +-- CreateIndex +CREATE INDEX "Job_userId_discoveryStatus_idx" ON "Job"("userId", "discoveryStatus"); + +-- CreateIndex +CREATE UNIQUE INDEX "ActivityType_value_createdBy_key" ON "ActivityType"("value", "createdBy"); + +-- CreateIndex +CREATE INDEX "Task_userId_idx" ON "Task"("userId"); + +-- CreateIndex +CREATE INDEX "Task_userId_status_idx" ON "Task"("userId", "status"); + +-- CreateIndex +CREATE INDEX "Task_userId_activityTypeId_idx" ON "Task"("userId", "activityTypeId"); + +-- CreateIndex +CREATE INDEX "Task_userId_dueDate_idx" ON "Task"("userId", "dueDate"); + +-- CreateIndex +CREATE INDEX "Automation_userId_idx" ON "Automation"("userId"); + +-- CreateIndex +CREATE INDEX "Automation_status_nextRunAt_idx" ON "Automation"("status", "nextRunAt"); + +-- CreateIndex +CREATE INDEX "AutomationRun_automationId_idx" ON "AutomationRun"("automationId"); + +-- CreateIndex +CREATE INDEX "AutomationRun_startedAt_idx" ON "AutomationRun"("startedAt"); + +-- CreateIndex +CREATE INDEX "Notification_userId_occurredAt_idx" ON "Notification"("userId", "occurredAt"); + +-- CreateIndex +CREATE INDEX "Notification_userId_readAt_idx" ON "Notification"("userId", "readAt"); + +-- CreateIndex +CREATE INDEX "Notification_automationId_kind_boardToken_idx" ON "Notification"("automationId", "kind", "boardToken"); + +-- CreateIndex +CREATE INDEX "AiCall_userId_startedAt_idx" ON "AiCall"("userId", "startedAt"); + +-- CreateIndex +CREATE INDEX "Note_jobId_idx" ON "Note"("jobId"); + +-- CreateIndex +CREATE INDEX "Note_userId_idx" ON "Note"("userId"); + +-- CreateIndex +CREATE UNIQUE INDEX "Tag_value_createdBy_key" ON "Tag"("value", "createdBy"); + +-- CreateIndex +CREATE INDEX "Skill_resumeSectionId_idx" ON "Skill"("resumeSectionId"); + +-- CreateIndex +CREATE INDEX "Question_createdBy_idx" ON "Question"("createdBy"); + +-- CreateIndex +CREATE UNIQUE INDEX "McpAccessToken_tokenHash_key" ON "McpAccessToken"("tokenHash"); + +-- CreateIndex +CREATE INDEX "McpAccessToken_userId_idx" ON "McpAccessToken"("userId"); + +-- CreateIndex +CREATE UNIQUE INDEX "McpAccessToken_userId_name_key" ON "McpAccessToken"("userId", "name"); + +-- CreateIndex +CREATE UNIQUE INDEX "ChatConversation_userId_key" ON "ChatConversation"("userId"); + +-- CreateIndex +CREATE INDEX "_JobToTag_B_index" ON "_JobToTag"("B"); + +-- CreateIndex +CREATE INDEX "_QuestionToTag_B_index" ON "_QuestionToTag"("B"); + +-- AddForeignKey +ALTER TABLE "User" ADD CONSTRAINT "User_defaultResumeId_fkey" FOREIGN KEY ("defaultResumeId") REFERENCES "Resume"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ApiKey" ADD CONSTRAINT "ApiKey_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "UserSettings" ADD CONSTRAINT "UserSettings_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Profile" ADD CONSTRAINT "Profile_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Resume" ADD CONSTRAINT "Resume_profileId_fkey" FOREIGN KEY ("profileId") REFERENCES "Profile"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Resume" ADD CONSTRAINT "Resume_FileId_fkey" FOREIGN KEY ("FileId") REFERENCES "File"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "CoverLetter" ADD CONSTRAINT "CoverLetter_profileId_fkey" FOREIGN KEY ("profileId") REFERENCES "Profile"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ResumeSection" ADD CONSTRAINT "ResumeSection_summaryId_fkey" FOREIGN KEY ("summaryId") REFERENCES "Summary"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ResumeSection" ADD CONSTRAINT "ResumeSection_resumeId_fkey" FOREIGN KEY ("resumeId") REFERENCES "Resume"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ContactInfo" ADD CONSTRAINT "ContactInfo_resumeId_fkey" FOREIGN KEY ("resumeId") REFERENCES "Resume"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "WorkExperience" ADD CONSTRAINT "WorkExperience_companyId_fkey" FOREIGN KEY ("companyId") REFERENCES "Company"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "WorkExperience" ADD CONSTRAINT "WorkExperience_jobTitleId_fkey" FOREIGN KEY ("jobTitleId") REFERENCES "JobTitle"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "WorkExperience" ADD CONSTRAINT "WorkExperience_locationId_fkey" FOREIGN KEY ("locationId") REFERENCES "Location"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "WorkExperience" ADD CONSTRAINT "WorkExperience_resumeSectionId_fkey" FOREIGN KEY ("resumeSectionId") REFERENCES "ResumeSection"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Education" ADD CONSTRAINT "Education_locationId_fkey" FOREIGN KEY ("locationId") REFERENCES "Location"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Education" ADD CONSTRAINT "Education_resumeSectionId_fkey" FOREIGN KEY ("resumeSectionId") REFERENCES "ResumeSection"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "LicenseOrCertification" ADD CONSTRAINT "LicenseOrCertification_resumeSectionId_fkey" FOREIGN KEY ("resumeSectionId") REFERENCES "ResumeSection"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "OtherSection" ADD CONSTRAINT "OtherSection_resumeSectionId_fkey" FOREIGN KEY ("resumeSectionId") REFERENCES "ResumeSection"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Contact" ADD CONSTRAINT "Contact_companyId_fkey" FOREIGN KEY ("companyId") REFERENCES "Company"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Contact" ADD CONSTRAINT "Contact_locationId_fkey" FOREIGN KEY ("locationId") REFERENCES "Location"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Contact" ADD CONSTRAINT "Contact_workedAtCompanyId_fkey" FOREIGN KEY ("workedAtCompanyId") REFERENCES "Company"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Contact" ADD CONSTRAINT "Contact_roleId_fkey" FOREIGN KEY ("roleId") REFERENCES "ContactRole"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Contact" ADD CONSTRAINT "Contact_createdBy_fkey" FOREIGN KEY ("createdBy") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ContactRole" ADD CONSTRAINT "ContactRole_createdBy_fkey" FOREIGN KEY ("createdBy") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "JobContact" ADD CONSTRAINT "JobContact_jobId_fkey" FOREIGN KEY ("jobId") REFERENCES "Job"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "JobContact" ADD CONSTRAINT "JobContact_contactId_fkey" FOREIGN KEY ("contactId") REFERENCES "Contact"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "JobContact" ADD CONSTRAINT "JobContact_roleId_fkey" FOREIGN KEY ("roleId") REFERENCES "ContactRole"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "JobTitle" ADD CONSTRAINT "JobTitle_createdBy_fkey" FOREIGN KEY ("createdBy") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Location" ADD CONSTRAINT "Location_createdBy_fkey" FOREIGN KEY ("createdBy") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Company" ADD CONSTRAINT "Company_createdBy_fkey" FOREIGN KEY ("createdBy") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "JobSource" ADD CONSTRAINT "JobSource_createdBy_fkey" FOREIGN KEY ("createdBy") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "JobStageType" ADD CONSTRAINT "JobStageType_statusId_fkey" FOREIGN KEY ("statusId") REFERENCES "JobStatus"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "JobStageType" ADD CONSTRAINT "JobStageType_createdBy_fkey" FOREIGN KEY ("createdBy") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "JobStage" ADD CONSTRAINT "JobStage_jobId_fkey" FOREIGN KEY ("jobId") REFERENCES "Job"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "JobStage" ADD CONSTRAINT "JobStage_stageTypeId_fkey" FOREIGN KEY ("stageTypeId") REFERENCES "JobStageType"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "JobStageInterviewer" ADD CONSTRAINT "JobStageInterviewer_stageId_fkey" FOREIGN KEY ("stageId") REFERENCES "JobStage"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "JobStageInterviewer" ADD CONSTRAINT "JobStageInterviewer_contactId_fkey" FOREIGN KEY ("contactId") REFERENCES "Contact"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "JobStagePrepQuestion" ADD CONSTRAINT "JobStagePrepQuestion_stageId_fkey" FOREIGN KEY ("stageId") REFERENCES "JobStage"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "JobStagePrepQuestion" ADD CONSTRAINT "JobStagePrepQuestion_questionId_fkey" FOREIGN KEY ("questionId") REFERENCES "Question"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Job" ADD CONSTRAINT "Job_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Job" ADD CONSTRAINT "Job_statusId_fkey" FOREIGN KEY ("statusId") REFERENCES "JobStatus"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Job" ADD CONSTRAINT "Job_jobTitleId_fkey" FOREIGN KEY ("jobTitleId") REFERENCES "JobTitle"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Job" ADD CONSTRAINT "Job_companyId_fkey" FOREIGN KEY ("companyId") REFERENCES "Company"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Job" ADD CONSTRAINT "Job_jobSourceId_fkey" FOREIGN KEY ("jobSourceId") REFERENCES "JobSource"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Job" ADD CONSTRAINT "Job_locationId_fkey" FOREIGN KEY ("locationId") REFERENCES "Location"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Job" ADD CONSTRAINT "Job_resumeId_fkey" FOREIGN KEY ("resumeId") REFERENCES "Resume"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Job" ADD CONSTRAINT "Job_coverLetterId_fkey" FOREIGN KEY ("coverLetterId") REFERENCES "CoverLetter"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Job" ADD CONSTRAINT "Job_automationId_fkey" FOREIGN KEY ("automationId") REFERENCES "Automation"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ActivityType" ADD CONSTRAINT "ActivityType_createdBy_fkey" FOREIGN KEY ("createdBy") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Activity" ADD CONSTRAINT "Activity_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Activity" ADD CONSTRAINT "Activity_activityTypeId_fkey" FOREIGN KEY ("activityTypeId") REFERENCES "ActivityType"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Activity" ADD CONSTRAINT "Activity_taskId_fkey" FOREIGN KEY ("taskId") REFERENCES "Task"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Task" ADD CONSTRAINT "Task_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Task" ADD CONSTRAINT "Task_activityTypeId_fkey" FOREIGN KEY ("activityTypeId") REFERENCES "ActivityType"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Automation" ADD CONSTRAINT "Automation_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Automation" ADD CONSTRAINT "Automation_resumeId_fkey" FOREIGN KEY ("resumeId") REFERENCES "Resume"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "AutomationRun" ADD CONSTRAINT "AutomationRun_automationId_fkey" FOREIGN KEY ("automationId") REFERENCES "Automation"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Notification" ADD CONSTRAINT "Notification_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Notification" ADD CONSTRAINT "Notification_automationId_fkey" FOREIGN KEY ("automationId") REFERENCES "Automation"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "AiCall" ADD CONSTRAINT "AiCall_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Note" ADD CONSTRAINT "Note_jobId_fkey" FOREIGN KEY ("jobId") REFERENCES "Job"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Note" ADD CONSTRAINT "Note_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Tag" ADD CONSTRAINT "Tag_createdBy_fkey" FOREIGN KEY ("createdBy") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Skill" ADD CONSTRAINT "Skill_tagId_fkey" FOREIGN KEY ("tagId") REFERENCES "Tag"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Skill" ADD CONSTRAINT "Skill_resumeSectionId_fkey" FOREIGN KEY ("resumeSectionId") REFERENCES "ResumeSection"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Question" ADD CONSTRAINT "Question_createdBy_fkey" FOREIGN KEY ("createdBy") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "McpAccessToken" ADD CONSTRAINT "McpAccessToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ChatConversation" ADD CONSTRAINT "ChatConversation_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_JobToTag" ADD CONSTRAINT "_JobToTag_A_fkey" FOREIGN KEY ("A") REFERENCES "Job"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_JobToTag" ADD CONSTRAINT "_JobToTag_B_fkey" FOREIGN KEY ("B") REFERENCES "Tag"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_QuestionToTag" ADD CONSTRAINT "_QuestionToTag_A_fkey" FOREIGN KEY ("A") REFERENCES "Question"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_QuestionToTag" ADD CONSTRAINT "_QuestionToTag_B_fkey" FOREIGN KEY ("B") REFERENCES "Tag"("id") ON DELETE CASCADE ON UPDATE CASCADE; + + +-- Partial unique indexes. Prisma's schema cannot express these, so +-- `migrate diff` neither emits nor checks them: keep them by hand. + +-- One running/cancelling run per automation (guards the dedup race) +CREATE UNIQUE INDEX "AutomationRun_automationId_active_key" +ON "AutomationRun"("automationId") +WHERE "status" IN ('running', 'cancelling'); + +-- One discovered job per user and URL; manual jobs may reuse a URL +CREATE UNIQUE INDEX "Job_userId_jobUrl_automation_key" +ON "Job"("userId", "jobUrl") +WHERE "automationId" IS NOT NULL; + +-- One current stage per job; Job.statusId is derived from it +CREATE UNIQUE INDEX "JobStage_jobId_current_key" +ON "JobStage"("jobId") +WHERE "isCurrent" = true; diff --git a/prisma/migrations/migration_lock.toml b/prisma/migrations/migration_lock.toml index 2a5a44419..044d57cdb 100644 --- a/prisma/migrations/migration_lock.toml +++ b/prisma/migrations/migration_lock.toml @@ -1,3 +1,3 @@ # Please do not edit this file manually # It should be added in your version-control system (e.g., Git) -provider = "sqlite" +provider = "postgresql" diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 222d36032..e8b681e7f 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -8,7 +8,7 @@ generator client { } datasource db { - provider = "sqlite" + provider = "postgresql" url = env("DATABASE_URL") } From 12663ace8d1bdb02cc4d7aa6c7b46c81461a5db3 Mon Sep 17 00:00:00 2001 From: Khuram Niaz Date: Tue, 13 Oct 2026 09:01:32 -0600 Subject: [PATCH 03/14] fix(queries): keep search case-insensitive, undated tasks first, paged lists tie-broken and resume entries ordered on Postgres Co-Authored-By: Claude Sonnet 5.5 --- __tests__/activity.actions.spec.ts | 20 +- __tests__/company.actions.spec.ts | 30 +-- __tests__/contact.actions.spec.ts | 6 +- __tests__/contactRole.actions.spec.ts | 4 +- __tests__/coverLetter.actions.spec.ts | 2 +- __tests__/interview.actions.spec.ts | 8 +- __tests__/job.actions.spec.ts | 58 +++--- __tests__/jobLocation.actions.spec.ts | 13 +- __tests__/jobSource.actions.spec.ts | 12 +- __tests__/jobtitle.actions.spec.ts | 13 +- __tests__/postgresQueryParity.spec.ts | 252 +++++++++++++++++++++++ __tests__/question.actions.spec.ts | 14 +- __tests__/referenceList.spec.ts | 14 +- __tests__/tag.actions.spec.ts | 6 +- __tests__/task.actions.spec.ts | 33 ++- src/actions/activity/activities.ts | 10 +- src/actions/activity/activityTypes.ts | 2 + src/actions/apiKey.actions.ts | 1 + src/actions/automation/discoveredJobs.ts | 2 +- src/actions/automation/queries.ts | 2 +- src/actions/automation/runs.ts | 2 +- src/actions/company/queries.ts | 1 + src/actions/contact/queries.ts | 8 +- src/actions/contactRole.actions.ts | 4 +- src/actions/coverLetter.actions.ts | 4 +- src/actions/interview.actions.ts | 12 +- src/actions/job/queries.ts | 16 +- src/actions/job/references.ts | 1 + src/actions/jobLocation.actions.ts | 1 + src/actions/jobStageType.actions.ts | 4 +- src/actions/jobtitle.actions.ts | 1 + src/actions/profile/resume.ts | 6 +- src/actions/profile/shared.ts | 10 +- src/actions/question.actions.ts | 8 +- src/actions/referenceList.ts | 14 +- src/actions/tag.actions.ts | 4 +- src/actions/task/queries.ts | 20 +- src/lib/jobs/resumeDetailInclude.ts | 18 +- src/lib/scraper/runner.ts | 24 +-- 39 files changed, 479 insertions(+), 181 deletions(-) create mode 100644 __tests__/postgresQueryParity.spec.ts diff --git a/__tests__/activity.actions.spec.ts b/__tests__/activity.actions.spec.ts index e2763e716..a59c07614 100644 --- a/__tests__/activity.actions.spec.ts +++ b/__tests__/activity.actions.spec.ts @@ -117,9 +117,9 @@ describe("activity.actions", () => { userId: mockUser.id, endTime: { not: null }, OR: [ - { activityName: { contains: "TypeScript" } }, - { description: { contains: "TypeScript" } }, - { activityType: { label: { contains: "TypeScript" } } }, + { activityName: { contains: "TypeScript", mode: "insensitive" } }, + { description: { contains: "TypeScript", mode: "insensitive" } }, + { activityType: { label: { contains: "TypeScript", mode: "insensitive" } } }, ], }), }) @@ -128,9 +128,9 @@ describe("activity.actions", () => { expect.objectContaining({ where: expect.objectContaining({ OR: [ - { activityName: { contains: "TypeScript" } }, - { description: { contains: "TypeScript" } }, - { activityType: { label: { contains: "TypeScript" } } }, + { activityName: { contains: "TypeScript", mode: "insensitive" } }, + { description: { contains: "TypeScript", mode: "insensitive" } }, + { activityType: { label: { contains: "TypeScript", mode: "insensitive" } } }, ], }), }) @@ -147,7 +147,7 @@ describe("activity.actions", () => { const findManyCall = (prisma.activity.findMany as any).mock .calls[0][0]; expect(findManyCall.where.OR).toContainEqual({ - activityName: { contains: "Portfolio" }, + activityName: { contains: "Portfolio", mode: "insensitive" }, }); }); @@ -161,7 +161,7 @@ describe("activity.actions", () => { const findManyCall = (prisma.activity.findMany as any).mock .calls[0][0]; expect(findManyCall.where.OR).toContainEqual({ - description: { contains: "developer" }, + description: { contains: "developer", mode: "insensitive" }, }); }); @@ -175,7 +175,7 @@ describe("activity.actions", () => { const findManyCall = (prisma.activity.findMany as any).mock .calls[0][0]; expect(findManyCall.where.OR).toContainEqual({ - activityType: { label: { contains: "Learning" } }, + activityType: { label: { contains: "Learning", mode: "insensitive" } }, }); }); @@ -244,7 +244,7 @@ describe("activity.actions", () => { expect(prisma.activity.findMany).toHaveBeenCalledWith( expect.objectContaining({ - orderBy: { createdAt: "desc" }, + orderBy: [{ createdAt: "desc" }, { id: "desc" }], }) ); }); diff --git a/__tests__/company.actions.spec.ts b/__tests__/company.actions.spec.ts index 0029a0c6f..1490a0df7 100644 --- a/__tests__/company.actions.spec.ts +++ b/__tests__/company.actions.spec.ts @@ -79,7 +79,7 @@ describe("Company Actions", () => { where: { createdBy: mockUser.id }, skip: 0, take: 10, - orderBy: [{ jobsApplied: { _count: "desc" } }, { label: "asc" }], + orderBy: [{ jobsApplied: { _count: "desc" } }, { label: "asc" }, { id: "desc" }], }); expect(prisma.company.count).toHaveBeenCalledWith({ where: { createdBy: mockUser.id }, @@ -150,7 +150,7 @@ describe("Company Actions", () => { }, }, }, - orderBy: [{ jobsApplied: { _count: "desc" } }, { label: "asc" }], + orderBy: [{ jobsApplied: { _count: "desc" } }, { label: "asc" }, { id: "desc" }], }); expect(prisma.company.count).toHaveBeenCalledWith({ where: { createdBy: mockUser.id }, @@ -191,20 +191,20 @@ describe("Company Actions", () => { where: { createdBy: mockUser.id, OR: [ - { label: { contains: "Ama" } }, - { atsToken: { contains: "Ama" } }, + { label: { contains: "Ama", mode: "insensitive" } }, + { atsToken: { contains: "Ama", mode: "insensitive" } }, ], }, skip: 0, take: 10, - orderBy: [{ jobsApplied: { _count: "desc" } }, { label: "asc" }], + orderBy: [{ jobsApplied: { _count: "desc" } }, { label: "asc" }, { id: "desc" }], }); expect(prisma.company.count).toHaveBeenCalledWith({ where: { createdBy: mockUser.id, OR: [ - { label: { contains: "Ama" } }, - { atsToken: { contains: "Ama" } }, + { label: { contains: "Ama", mode: "insensitive" } }, + { atsToken: { contains: "Ama", mode: "insensitive" } }, ], }, }); @@ -237,8 +237,8 @@ describe("Company Actions", () => { where: { createdBy: mockUser.id, OR: [ - { label: { contains: "Ama" } }, - { atsToken: { contains: "Ama" } }, + { label: { contains: "Ama", mode: "insensitive" } }, + { atsToken: { contains: "Ama", mode: "insensitive" } }, ], }, skip: 0, @@ -268,14 +268,14 @@ describe("Company Actions", () => { }, }, }, - orderBy: [{ jobsApplied: { _count: "desc" } }, { label: "asc" }], + orderBy: [{ jobsApplied: { _count: "desc" } }, { label: "asc" }, { id: "desc" }], }); expect(prisma.company.count).toHaveBeenCalledWith({ where: { createdBy: mockUser.id, OR: [ - { label: { contains: "Ama" } }, - { atsToken: { contains: "Ama" } }, + { label: { contains: "Ama", mode: "insensitive" } }, + { atsToken: { contains: "Ama", mode: "insensitive" } }, ], }, }); @@ -291,7 +291,7 @@ describe("Company Actions", () => { expect(prisma.company.findMany).toHaveBeenCalledWith( expect.objectContaining({ where: { createdBy: mockUser.id, watched: true }, - orderBy: [{ watchedAt: "desc" }, { label: "asc" }], + orderBy: [{ watchedAt: "desc" }, { label: "asc" }, { id: "desc" }], }) ); }); @@ -307,7 +307,7 @@ describe("Company Actions", () => { where: { createdBy: mockUser.id }, skip: 0, take: 10, - orderBy: [{ jobsApplied: { _count: "desc" } }, { label: "asc" }], + orderBy: [{ jobsApplied: { _count: "desc" } }, { label: "asc" }, { id: "desc" }], }); }); }); @@ -327,6 +327,7 @@ describe("Company Actions", () => { expect(result).toEqual(mockCompanies); expect(prisma.company.findMany).toHaveBeenCalledWith({ where: { createdBy: mockUser.id }, + orderBy: [{ label: "asc" }, { id: "asc" }], }); }); @@ -352,6 +353,7 @@ describe("Company Actions", () => { expect(result).toEqual({ success: false, message: "Unexpected error" }); expect(prisma.company.findMany).toHaveBeenCalledWith({ where: { createdBy: mockUser.id }, + orderBy: [{ label: "asc" }, { id: "asc" }], }); }); }); diff --git a/__tests__/contact.actions.spec.ts b/__tests__/contact.actions.spec.ts index 462097150..4cb330f61 100644 --- a/__tests__/contact.actions.spec.ts +++ b/__tests__/contact.actions.spec.ts @@ -51,9 +51,9 @@ describe("contact actions", () => { it("searches name, email and title", async () => { await getContactList(1, 10, "pat"); expect(db.contact.findMany.mock.calls[0][0].where.OR).toEqual([ - { name: { contains: "pat" } }, - { email: { contains: "pat" } }, - { title: { contains: "pat" } }, + { name: { contains: "pat", mode: "insensitive" } }, + { email: { contains: "pat", mode: "insensitive" } }, + { title: { contains: "pat", mode: "insensitive" } }, ]); }); diff --git a/__tests__/contactRole.actions.spec.ts b/__tests__/contactRole.actions.spec.ts index bdbec9376..a13330c3d 100644 --- a/__tests__/contactRole.actions.spec.ts +++ b/__tests__/contactRole.actions.spec.ts @@ -44,7 +44,7 @@ describe("contactRole actions", () => { skip: 0, take: 10, include: { _count: { select: { jobContacts: true, contacts: true } } }, - orderBy: [{ label: "asc" }], + orderBy: [{ label: "asc" }, { id: "desc" }], }), ); }); @@ -57,7 +57,7 @@ describe("contactRole actions", () => { expect(db.contactRole.findMany).toHaveBeenCalledWith( expect.objectContaining({ - where: { createdBy: user.id, label: { contains: "recr" } }, + where: { createdBy: user.id, label: { contains: "recr", mode: "insensitive" } }, }), ); }); diff --git a/__tests__/coverLetter.actions.spec.ts b/__tests__/coverLetter.actions.spec.ts index 897646ebb..4f095cbfe 100644 --- a/__tests__/coverLetter.actions.spec.ts +++ b/__tests__/coverLetter.actions.spec.ts @@ -85,7 +85,7 @@ describe("coverLetterActions", () => { select: { Job: true }, }, }, - orderBy: { createdAt: "desc" }, + orderBy: [{ createdAt: "desc" }, { id: "desc" }], }); expect(prisma.coverLetter.count).toHaveBeenCalledWith({ where: { diff --git a/__tests__/interview.actions.spec.ts b/__tests__/interview.actions.spec.ts index e9bc8354b..a3f75b132 100644 --- a/__tests__/interview.actions.spec.ts +++ b/__tests__/interview.actions.spec.ts @@ -90,10 +90,10 @@ describe("getInterviewList", () => { { Job: { companyId: "co-1" } }, { OR: [ - { Job: { JobTitle: { label: { contains: "acme" } } } }, - { Job: { Company: { label: { contains: "acme" } } } }, - { StageType: { label: { contains: "acme" } } }, - { interviewers: { some: { Contact: { name: { contains: "acme" } } } } }, + { Job: { JobTitle: { label: { contains: "acme", mode: "insensitive" } } } }, + { Job: { Company: { label: { contains: "acme", mode: "insensitive" } } } }, + { StageType: { label: { contains: "acme", mode: "insensitive" } } }, + { interviewers: { some: { Contact: { name: { contains: "acme", mode: "insensitive" } } } } }, ], }, ]); diff --git a/__tests__/job.actions.spec.ts b/__tests__/job.actions.spec.ts index fa0fb5bc3..2f11333c1 100644 --- a/__tests__/job.actions.spec.ts +++ b/__tests__/job.actions.spec.ts @@ -389,7 +389,7 @@ describe("jobActions", () => { it("keeps the search OR alongside the status OR", async () => { await getJobsList(1, 10, { ...EMPTY_JOB_FACETS, statuses: ["applied"] }, "Developer"); const where = whereOf(); - expect(where.OR).toContainEqual({ description: { contains: "Developer" } }); + expect(where.OR).toContainEqual({ description: { contains: "Developer", mode: "insensitive" } }); expect(where.AND).toContainEqual({ OR: [{ Status: { value: { in: ["applied"] } } }], }); @@ -424,11 +424,11 @@ describe("jobActions", () => { where: expect.objectContaining({ userId: mockUser.id, OR: [ - { JobTitle: { label: { contains: "Amazon" } } }, - { Company: { label: { contains: "Amazon" } } }, - { Location: { label: { contains: "Amazon" } } }, - { JobSource: { label: { contains: "Amazon" } } }, - { description: { contains: "Amazon" } }, + { JobTitle: { label: { contains: "Amazon", mode: "insensitive" } } }, + { Company: { label: { contains: "Amazon", mode: "insensitive" } } }, + { Location: { label: { contains: "Amazon", mode: "insensitive" } } }, + { JobSource: { label: { contains: "Amazon", mode: "insensitive" } } }, + { description: { contains: "Amazon", mode: "insensitive" } }, ], }), }), @@ -437,11 +437,11 @@ describe("jobActions", () => { expect.objectContaining({ where: expect.objectContaining({ OR: [ - { JobTitle: { label: { contains: "Amazon" } } }, - { Company: { label: { contains: "Amazon" } } }, - { Location: { label: { contains: "Amazon" } } }, - { JobSource: { label: { contains: "Amazon" } } }, - { description: { contains: "Amazon" } }, + { JobTitle: { label: { contains: "Amazon", mode: "insensitive" } } }, + { Company: { label: { contains: "Amazon", mode: "insensitive" } } }, + { Location: { label: { contains: "Amazon", mode: "insensitive" } } }, + { JobSource: { label: { contains: "Amazon", mode: "insensitive" } } }, + { description: { contains: "Amazon", mode: "insensitive" } }, ], }), }), @@ -457,7 +457,7 @@ describe("jobActions", () => { const findManyCall = (prisma.job.findMany as any).mock.calls[0][0]; expect(findManyCall.where.OR).toContainEqual({ - JobTitle: { label: { contains: "Developer" } }, + JobTitle: { label: { contains: "Developer", mode: "insensitive" } }, }); }); @@ -470,7 +470,7 @@ describe("jobActions", () => { const findManyCall = (prisma.job.findMany as any).mock.calls[0][0]; expect(findManyCall.where.OR).toContainEqual({ - Company: { label: { contains: "Google" } }, + Company: { label: { contains: "Google", mode: "insensitive" } }, }); }); @@ -483,7 +483,7 @@ describe("jobActions", () => { const findManyCall = (prisma.job.findMany as any).mock.calls[0][0]; expect(findManyCall.where.OR).toContainEqual({ - Location: { label: { contains: "Remote" } }, + Location: { label: { contains: "Remote", mode: "insensitive" } }, }); }); @@ -496,7 +496,7 @@ describe("jobActions", () => { const findManyCall = (prisma.job.findMany as any).mock.calls[0][0]; expect(findManyCall.where.OR).toContainEqual({ - JobSource: { label: { contains: "LinkedIn" } }, + JobSource: { label: { contains: "LinkedIn", mode: "insensitive" } }, }); }); @@ -519,7 +519,7 @@ describe("jobActions", () => { const findManyCall = (prisma.job.findMany as any).mock.calls[0][0]; expect(findManyCall.where.OR).not.toContainEqual({ - JobSource: { label: { contains: "LinkedIn" } }, + JobSource: { label: { contains: "LinkedIn", mode: "insensitive" } }, }); }); @@ -532,7 +532,7 @@ describe("jobActions", () => { const findManyCall = (prisma.job.findMany as any).mock.calls[0][0]; expect(findManyCall.where.OR).toContainEqual({ - description: { contains: "React" }, + description: { contains: "React", mode: "insensitive" }, }); }); @@ -663,13 +663,13 @@ describe("jobActions", () => { const findManyCall = (prisma.job.findMany as any).mock.calls[0][0]; expect(findManyCall.where.OR).toEqual([ - { JobTitle: { label: { contains: "Developer" } } }, - { Location: { label: { contains: "Developer" } } }, - { JobSource: { label: { contains: "Developer" } } }, - { description: { contains: "Developer" } }, + { JobTitle: { label: { contains: "Developer", mode: "insensitive" } } }, + { Location: { label: { contains: "Developer", mode: "insensitive" } } }, + { JobSource: { label: { contains: "Developer", mode: "insensitive" } } }, + { description: { contains: "Developer", mode: "insensitive" } }, ]); expect(findManyCall.where.OR).not.toContainEqual({ - Company: { label: { contains: "Developer" } }, + Company: { label: { contains: "Developer", mode: "insensitive" } }, }); }); @@ -682,7 +682,7 @@ describe("jobActions", () => { const findManyCall = (prisma.job.findMany as any).mock.calls[0][0]; expect(findManyCall.where.OR).toContainEqual({ - Company: { label: { contains: "Developer" } }, + Company: { label: { contains: "Developer", mode: "insensitive" } }, }); }); @@ -787,7 +787,7 @@ describe("jobActions", () => { const findManyCall = (prisma.job.findMany as any).mock.calls[0][0]; expect(findManyCall.where.OR).not.toContainEqual({ - JobTitle: { label: { contains: "React" } }, + JobTitle: { label: { contains: "React", mode: "insensitive" } }, }); }); @@ -800,7 +800,7 @@ describe("jobActions", () => { const findManyCall = (prisma.job.findMany as any).mock.calls[0][0]; expect(findManyCall.where.OR).toContainEqual({ - JobTitle: { label: { contains: "React" } }, + JobTitle: { label: { contains: "React", mode: "insensitive" } }, }); }); @@ -882,7 +882,7 @@ describe("jobActions", () => { const findManyCall = (prisma.job.findMany as any).mock.calls[0][0]; expect(findManyCall.where.OR).not.toContainEqual({ - Location: { label: { contains: "React" } }, + Location: { label: { contains: "React", mode: "insensitive" } }, }); }); @@ -895,7 +895,7 @@ describe("jobActions", () => { const findManyCall = (prisma.job.findMany as any).mock.calls[0][0]; expect(findManyCall.where.OR).toContainEqual({ - Location: { label: { contains: "React" } }, + Location: { label: { contains: "React", mode: "insensitive" } }, }); }); @@ -1030,7 +1030,7 @@ describe("jobActions", () => { Company: { value: "google" }, applied: true, }); - expect(totalWhere.OR).toContainEqual({ description: { contains: "Dev" } }); + expect(totalWhere.OR).toContainEqual({ description: { contains: "Dev", mode: "insensitive" } }); const acceptedWhere = (prisma.job.count as any).mock.calls[1][0].where; expect(acceptedWhere.AND[1]).toEqual({ discoveryStatus: "accepted" }); expect(acceptedWhere.AND[0].userId).toBe(mockUser.id); @@ -1085,7 +1085,7 @@ describe("jobActions", () => { File: true, }, }, - tags: true, + tags: { orderBy: [{ label: "asc" }, { id: "asc" }] }, contactLinks: { include: { Role: true, diff --git a/__tests__/jobLocation.actions.spec.ts b/__tests__/jobLocation.actions.spec.ts index 5838076ef..4a0558846 100644 --- a/__tests__/jobLocation.actions.spec.ts +++ b/__tests__/jobLocation.actions.spec.ts @@ -61,6 +61,7 @@ describe("Job Location Actions", () => { expect(result).toEqual(mockLocations); expect(prisma.location.findMany).toHaveBeenCalledWith({ where: { createdBy: mockUser.id }, + orderBy: [{ label: "asc" }, { id: "asc" }], }); }); @@ -103,7 +104,7 @@ describe("Job Location Actions", () => { where: { createdBy: mockUser.id }, skip: 0, take: 10, - orderBy: [{ jobsApplied: { _count: "desc" } }, { label: "asc" }], + orderBy: [{ jobsApplied: { _count: "desc" } }, { label: "asc" }, { id: "desc" }], }); expect(prisma.location.count).toHaveBeenCalledWith({ where: { createdBy: mockUser.id }, @@ -142,7 +143,7 @@ describe("Job Location Actions", () => { }, }, }, - orderBy: [{ jobsApplied: { _count: "desc" } }, { label: "asc" }], + orderBy: [{ jobsApplied: { _count: "desc" } }, { label: "asc" }, { id: "desc" }], }); }); @@ -187,13 +188,13 @@ describe("Job Location Actions", () => { expect(result).toEqual({ data: mockData, total: 1 }); expect(prisma.location.findMany).toHaveBeenCalledWith({ - where: { createdBy: mockUser.id, OR: [{ label: { contains: "New" } }] }, + where: { createdBy: mockUser.id, OR: [{ label: { contains: "New", mode: "insensitive" } }] }, skip: 0, take: 10, - orderBy: [{ jobsApplied: { _count: "desc" } }, { label: "asc" }], + orderBy: [{ jobsApplied: { _count: "desc" } }, { label: "asc" }, { id: "desc" }], }); expect(prisma.location.count).toHaveBeenCalledWith({ - where: { createdBy: mockUser.id, OR: [{ label: { contains: "New" } }] }, + where: { createdBy: mockUser.id, OR: [{ label: { contains: "New", mode: "insensitive" } }] }, }); }); @@ -208,7 +209,7 @@ describe("Job Location Actions", () => { where: { createdBy: mockUser.id }, skip: 0, take: 10, - orderBy: [{ jobsApplied: { _count: "desc" } }, { label: "asc" }], + orderBy: [{ jobsApplied: { _count: "desc" } }, { label: "asc" }, { id: "desc" }], }); }); }); diff --git a/__tests__/jobSource.actions.spec.ts b/__tests__/jobSource.actions.spec.ts index d525dd00b..7b7f5622e 100644 --- a/__tests__/jobSource.actions.spec.ts +++ b/__tests__/jobSource.actions.spec.ts @@ -57,7 +57,7 @@ describe("Job Source Actions", () => { where: { createdBy: mockUser.id }, skip: 0, take: 10, - orderBy: [{ jobsApplied: { _count: "desc" } }, { label: "asc" }], + orderBy: [{ jobsApplied: { _count: "desc" } }, { label: "asc" }, { id: "desc" }], }); expect(prisma.jobSource.count).toHaveBeenCalledWith({ where: { createdBy: mockUser.id }, @@ -95,7 +95,7 @@ describe("Job Source Actions", () => { }, }, }, - orderBy: [{ jobsApplied: { _count: "desc" } }, { label: "asc" }], + orderBy: [{ jobsApplied: { _count: "desc" } }, { label: "asc" }, { id: "desc" }], }); expect(prisma.job.groupBy).toHaveBeenCalledWith({ by: ["jobSourceId"], @@ -143,13 +143,13 @@ describe("Job Source Actions", () => { expect(result).toEqual({ data: mockData, total: 1 }); expect(prisma.jobSource.findMany).toHaveBeenCalledWith({ - where: { createdBy: mockUser.id, OR: [{ label: { contains: "Link" } }] }, + where: { createdBy: mockUser.id, OR: [{ label: { contains: "Link", mode: "insensitive" } }] }, skip: 0, take: 10, - orderBy: [{ jobsApplied: { _count: "desc" } }, { label: "asc" }], + orderBy: [{ jobsApplied: { _count: "desc" } }, { label: "asc" }, { id: "desc" }], }); expect(prisma.jobSource.count).toHaveBeenCalledWith({ - where: { createdBy: mockUser.id, OR: [{ label: { contains: "Link" } }] }, + where: { createdBy: mockUser.id, OR: [{ label: { contains: "Link", mode: "insensitive" } }] }, }); }); @@ -164,7 +164,7 @@ describe("Job Source Actions", () => { where: { createdBy: mockUser.id }, skip: 0, take: 10, - orderBy: [{ jobsApplied: { _count: "desc" } }, { label: "asc" }], + orderBy: [{ jobsApplied: { _count: "desc" } }, { label: "asc" }, { id: "desc" }], }); }); }); diff --git a/__tests__/jobtitle.actions.spec.ts b/__tests__/jobtitle.actions.spec.ts index 66a3572d9..50b3a2e5b 100644 --- a/__tests__/jobtitle.actions.spec.ts +++ b/__tests__/jobtitle.actions.spec.ts @@ -57,6 +57,7 @@ describe("Job Title Actions", () => { expect(result).toEqual(mockTitles); expect(prisma.jobTitle.findMany).toHaveBeenCalledWith({ where: { createdBy: mockUser.id }, + orderBy: [{ label: "asc" }, { id: "asc" }], }); }); @@ -99,7 +100,7 @@ describe("Job Title Actions", () => { where: { createdBy: mockUser.id }, skip: 0, take: 10, - orderBy: [{ jobs: { _count: "desc" } }, { label: "asc" }], + orderBy: [{ jobs: { _count: "desc" } }, { label: "asc" }, { id: "desc" }], }); expect(prisma.jobTitle.count).toHaveBeenCalledWith({ where: { createdBy: mockUser.id }, @@ -138,7 +139,7 @@ describe("Job Title Actions", () => { }, }, }, - orderBy: [{ jobs: { _count: "desc" } }, { label: "asc" }], + orderBy: [{ jobs: { _count: "desc" } }, { label: "asc" }, { id: "desc" }], }); }); @@ -185,13 +186,13 @@ describe("Job Title Actions", () => { expect(result).toEqual({ data: mockData, total: 1 }); expect(prisma.jobTitle.findMany).toHaveBeenCalledWith({ - where: { createdBy: mockUser.id, OR: [{ label: { contains: "Dev" } }] }, + where: { createdBy: mockUser.id, OR: [{ label: { contains: "Dev", mode: "insensitive" } }] }, skip: 0, take: 10, - orderBy: [{ jobs: { _count: "desc" } }, { label: "asc" }], + orderBy: [{ jobs: { _count: "desc" } }, { label: "asc" }, { id: "desc" }], }); expect(prisma.jobTitle.count).toHaveBeenCalledWith({ - where: { createdBy: mockUser.id, OR: [{ label: { contains: "Dev" } }] }, + where: { createdBy: mockUser.id, OR: [{ label: { contains: "Dev", mode: "insensitive" } }] }, }); }); @@ -206,7 +207,7 @@ describe("Job Title Actions", () => { where: { createdBy: mockUser.id }, skip: 0, take: 10, - orderBy: [{ jobs: { _count: "desc" } }, { label: "asc" }], + orderBy: [{ jobs: { _count: "desc" } }, { label: "asc" }, { id: "desc" }], }); }); }); diff --git a/__tests__/postgresQueryParity.spec.ts b/__tests__/postgresQueryParity.spec.ts new file mode 100644 index 000000000..53bae203e --- /dev/null +++ b/__tests__/postgresQueryParity.spec.ts @@ -0,0 +1,252 @@ +import { UNANALYZED_MATCH_MARKER } from "@/actions/job/shared"; +import { getTagList } from "@/actions/tag.actions"; +import { getDiscoveredJobs } from "@/actions/automation/discoveredJobs"; +import { getAutomationRuns } from "@/actions/automation/runs"; +import { getAutomationsList } from "@/actions/automation/queries"; +import { getQuestionsList } from "@/actions/question.actions"; +import { getContactList } from "@/actions/contact/queries"; +import { getResumeList, getResumeById } from "@/actions/profile/resume"; +import { getTasksList } from "@/actions/task/queries"; +import { getReferenceEntityList } from "@/actions/referenceList"; +import { getInterviewList } from "@/actions/interview.actions"; +import { getActivitiesList } from "@/actions/activity/activities"; +import { getCoverLetterList } from "@/actions/coverLetter.actions"; +import { getContactRoleList } from "@/actions/contactRole.actions"; +import { getJobStageTypeList } from "@/actions/jobStageType.actions"; +import { getJobsList, getJobsIterator } from "@/actions/job/queries"; +import prisma from "@/lib/db"; + +type Call = { model: string; method: string; args: any }; + +const h = vi.hoisted(() => ({ + calls: [] as { model: string; method: string; args: any }[], + returns: {} as Record, +})); + +// Every delegate method records its call and returns an empty result, so a +// site can be driven without hand-listing the methods it happens to use. +vi.mock("@/lib/db", () => { + const empty = (method: string) => + method === "count" ? 0 : method === "findMany" || method === "groupBy" ? [] : null; + const delegates: Record = {}; + const delegate = (model: string) => + new Proxy( + {}, + { + get: (_target, method: string) => async (args: any) => { + h.calls.push({ model, method, args }); + const key = `${model}.${method}`; + if (!(key in h.returns)) return empty(method); + const value = h.returns[key]; + return typeof value === "function" ? value(args) : value; + }, + }, + ); + return { + default: new Proxy( + {}, + { get: (_target, model: string) => (delegates[model] ??= delegate(model)) }, + ), + }; +}); + +vi.mock("@/utils/user.utils", () => ({ + getCurrentUser: async () => ({ id: "user-1" }), +})); +vi.mock("next/cache", () => ({ revalidatePath: vi.fn(), revalidateTag: vi.fn() })); + +const findManyCalls = (model: string): Call[] => + h.calls.filter((c) => c.model === model && c.method === "findMany"); + +// Every { contains } object anywhere inside a where clause +const containsFilters = (node: unknown, found: any[] = []): any[] => { + if (Array.isArray(node)) node.forEach((n) => containsFilters(n, found)); + else if (node && typeof node === "object") { + if ("contains" in node) found.push(node); + Object.values(node).forEach((n) => containsFilters(n, found)); + } + return found; +}; + +const lastKey = (orderBy: unknown) => { + const list = Array.isArray(orderBy) ? orderBy : [orderBy]; + return list[list.length - 1]; +}; + +beforeEach(() => { + h.calls.length = 0; + for (const key of Object.keys(h.returns)) delete h.returns[key]; +}); + +describe("search parity: user search is case-insensitive", () => { + const sites: [string, string, () => Promise][] = [ + ["tags", "tag", () => getTagList(1, 10, "Goo")], + ["questions", "question", () => getQuestionsList(1, 10, undefined, "Goo")], + ["contacts", "contact", () => getContactList(1, 10, "Goo")], + ["tasks", "task", () => getTasksList(1, 10, undefined, undefined, "Goo")], + ["activities", "activity", () => getActivitiesList(1, 10, "Goo")], + ["contact roles", "contactRole", () => getContactRoleList(1, 10, "Goo")], + ["stage types", "jobStageType", () => getJobStageTypeList(1, 10, "Goo")], + ["jobs", "job", () => getJobsList(1, 10, undefined, "Goo")], + [ + "reference lists", + "company", + () => + getReferenceEntityList({ + model: (prisma as any).company, + userId: "user-1", + fkField: "companyId", + appliedRelation: "jobsApplied", + page: 1, + limit: 10, + search: "Goo", + }), + ], + ]; + + it.each(sites)("%s", async (_name, model, run) => { + await run(); + const filters = findManyCalls(model).flatMap((c) => containsFilters(c.args.where)); + expect(filters.length).toBeGreaterThan(0); + for (const filter of filters) { + expect(filter).toEqual({ contains: "Goo", mode: "insensitive" }); + } + }); + + it("interviews", async () => { + h.returns["jobStage.count"] = 1; + await getInterviewList(1, 10, "Goo"); + const filters = findManyCalls("jobStage").flatMap((c) => containsFilters(c.args.where)); + expect(filters).toHaveLength(4); + for (const filter of filters) { + expect(filter).toEqual({ contains: "Goo", mode: "insensitive" }); + } + }); + + it("leaves the app-written unanalyzed marker case-sensitive", async () => { + h.returns["job.count"] = 1; + await getJobsList(1, 10, undefined, undefined, undefined, undefined, undefined, undefined, undefined, { + field: "matchScore", + dir: "desc", + }); + const markers = h.calls + .filter((c) => c.model === "job") + .flatMap((c) => containsFilters(c.args?.where)) + .filter((f) => f.contains === UNANALYZED_MATCH_MARKER); + expect(markers.length).toBeGreaterThan(0); + for (const marker of markers) expect(marker).not.toHaveProperty("mode"); + }); +}); + +describe("NULL ordering parity", () => { + it("keeps undated tasks first in the due-date grouping", async () => { + await getTasksList(1, 10, undefined, undefined, undefined, "dueDate"); + const [call] = findManyCalls("task"); + expect(call.args.orderBy[0]).toEqual({ dueDate: { sort: "asc", nulls: "first" } }); + }); +}); + +describe("pagination tiebreaker: every paged sort ends on id", () => { + const sites: [string, string, () => Promise][] = [ + ["tags", "tag", () => getTagList()], + ["discovered jobs", "job", () => getDiscoveredJobs()], + ["questions", "question", () => getQuestionsList()], + ["contacts", "contact", () => getContactList()], + ["automations", "automation", () => getAutomationsList()], + ["resumes", "resume", () => getResumeList()], + ["activities", "activity", () => getActivitiesList()], + ["cover letters", "coverLetter", () => getCoverLetterList()], + ["contact roles", "contactRole", () => getContactRoleList()], + ["stage types", "jobStageType", () => getJobStageTypeList()], + [ + "reference lists", + "company", + () => + getReferenceEntityList({ + model: (prisma as any).company, + userId: "user-1", + fkField: "companyId", + appliedRelation: "jobsApplied", + page: 1, + limit: 10, + }), + ], + [ + "watchlist order passed in by the caller", + "company", + () => + getReferenceEntityList({ + model: (prisma as any).company, + userId: "user-1", + fkField: "companyId", + appliedRelation: "jobsApplied", + orderBy: [{ watchedAt: "desc" }, { label: "asc" }], + page: 1, + limit: 10, + }), + ], + ]; + + it.each(sites)("%s", async (_name, model, run) => { + await run(); + const calls = findManyCalls(model).filter((c) => "take" in c.args); + expect(calls.length).toBeGreaterThan(0); + for (const call of calls) expect(lastKey(call.args.orderBy)).toEqual({ id: "desc" }); + }); + + it.each(["dueDate", "createdDate", "updatedDate", "activityType", undefined] as const)( + "tasks grouped by %s", + async (groupBy) => { + await getTasksList(1, 10, undefined, undefined, undefined, groupBy as any); + const [call] = findManyCalls("task"); + expect(lastKey(call.args.orderBy)).toEqual({ id: "desc" }); + }, + ); + + it("automation runs", async () => { + h.returns["automation.findFirst"] = { id: "auto-1" }; + await getAutomationRuns("auto-1"); + const [call] = findManyCalls("automationRun"); + expect(lastKey(call.args.orderBy)).toEqual({ id: "desc" }); + }); + + it("resumes with a pinned default, on both pages", async () => { + h.returns["user.findUnique"] = { defaultResumeId: "resume-1" }; + await getResumeList(1, 10); + await getResumeList(2, 10); + const calls = findManyCalls("resume"); + expect(calls).toHaveLength(2); + for (const call of calls) expect(lastKey(call.args.orderBy)).toEqual({ id: "desc" }); + }); + + it("interviews, dated and undated halves", async () => { + h.returns["jobStage.count"] = (args: any) => + args.where.AND?.some((c: any) => c.occurredAt?.not === null) ? 1 : 3; + await getInterviewList(1, 10); + const calls = findManyCalls("jobStage"); + expect(calls).toHaveLength(2); + for (const call of calls) expect(lastKey(call.args.orderBy)).toEqual({ id: "desc" }); + }); + + it("the job export iterator", async () => { + for await (const _chunk of getJobsIterator()) break; + const [call] = findManyCalls("job"); + expect(lastKey(call.args.orderBy)).toEqual({ id: "desc" }); + }); +}); + +describe("unordered reads: resume entries carry an explicit order", () => { + it("orders every entry relation on the resume detail read", async () => { + await getResumeById("resume-1"); + const call = h.calls.find((c) => c.model === "resume" && c.method === "findUnique")!; + const sections = call.args.include.ResumeSections; + expect(sections.orderBy).toEqual({ id: "asc" }); + expect(sections.include.workExperiences.orderBy).toEqual([{ createdAt: "asc" }, { id: "asc" }]); + expect(sections.include.educations.orderBy).toEqual([{ createdAt: "asc" }, { id: "asc" }]); + expect(sections.include.licenseOrCertifications.orderBy).toEqual([ + { issueDate: { sort: "desc", nulls: "last" } }, + { title: "asc" }, + { id: "asc" }, + ]); + }); +}); diff --git a/__tests__/question.actions.spec.ts b/__tests__/question.actions.spec.ts index 5c4ee8eed..050abc4b6 100644 --- a/__tests__/question.actions.spec.ts +++ b/__tests__/question.actions.spec.ts @@ -68,8 +68,10 @@ describe("Question Actions", () => { where: { createdBy: mockUser.id }, skip: 0, take: 10, - orderBy: [{ createdAt: "desc" }], - include: { tags: true }, + orderBy: [{ createdAt: "desc" }, { id: "desc" }], + include: { + tags: { orderBy: [{ label: "asc" }, { id: "asc" }] }, + }, }), ); }); @@ -134,8 +136,8 @@ describe("Question Actions", () => { where: { createdBy: mockUser.id, OR: [ - { question: { contains: "react" } }, - { answer: { contains: "react" } }, + { question: { contains: "react", mode: "insensitive" } }, + { answer: { contains: "react", mode: "insensitive" } }, ], }, }), @@ -155,8 +157,8 @@ describe("Question Actions", () => { createdBy: mockUser.id, tags: { some: { id: "tag-1" } }, OR: [ - { question: { contains: "react" } }, - { answer: { contains: "react" } }, + { question: { contains: "react", mode: "insensitive" } }, + { answer: { contains: "react", mode: "insensitive" } }, ], }, }), diff --git a/__tests__/referenceList.spec.ts b/__tests__/referenceList.spec.ts index b61b17234..016c54825 100644 --- a/__tests__/referenceList.spec.ts +++ b/__tests__/referenceList.spec.ts @@ -46,7 +46,7 @@ describe("getReferenceEntityList", () => { where: { createdBy: userId }, skip: 0, take: 10, - orderBy: [{ jobsApplied: { _count: "desc" } }, { label: "asc" }], + orderBy: [{ jobsApplied: { _count: "desc" } }, { label: "asc" }, { id: "desc" }], }); expect(model.count).toHaveBeenCalledWith({ where: { createdBy: userId } }); expect(prisma.job.groupBy).not.toHaveBeenCalled(); @@ -80,7 +80,7 @@ describe("getReferenceEntityList", () => { }, }, }, - orderBy: [{ jobs: { _count: "desc" } }, { label: "asc" }], + orderBy: [{ jobs: { _count: "desc" } }, { label: "asc" }, { id: "desc" }], }); }); @@ -206,11 +206,11 @@ describe("getReferenceEntityList", () => { expect(model.findMany).toHaveBeenCalledWith( expect.objectContaining({ - where: { createdBy: userId, OR: [{ label: { contains: "Ama" } }] }, + where: { createdBy: userId, OR: [{ label: { contains: "Ama", mode: "insensitive" } }] }, }) ); expect(model.count).toHaveBeenCalledWith({ - where: { createdBy: userId, OR: [{ label: { contains: "Ama" } }] }, + where: { createdBy: userId, OR: [{ label: { contains: "Ama", mode: "insensitive" } }] }, }); }); @@ -320,8 +320,8 @@ describe("getReferenceEntityList", () => { where: { createdBy: userId, OR: [ - { label: { contains: "acme" } }, - { atsToken: { contains: "acme" } }, + { label: { contains: "acme", mode: "insensitive" } }, + { atsToken: { contains: "acme", mode: "insensitive" } }, ], }, }) @@ -343,7 +343,7 @@ describe("getReferenceEntityList", () => { }); expect(model.findMany).toHaveBeenCalledWith( - expect.objectContaining({ orderBy }) + expect.objectContaining({ orderBy: [...orderBy, { id: "desc" }] }) ); }); describe("relationCounts", () => { diff --git a/__tests__/tag.actions.spec.ts b/__tests__/tag.actions.spec.ts index 2961888f3..076bdb487 100644 --- a/__tests__/tag.actions.spec.ts +++ b/__tests__/tag.actions.spec.ts @@ -105,7 +105,7 @@ describe("Tag Actions", () => { where: { createdBy: mockUser.id }, skip: 0, take: 10, - orderBy: { jobs: { _count: "desc" } }, + orderBy: [{ jobs: { _count: "desc" } }, { id: "desc" }], }), ); }); @@ -153,11 +153,11 @@ describe("Tag Actions", () => { expect(result).toEqual({ data: mockData, total: 1 }); expect(prisma.tag.findMany).toHaveBeenCalledWith( expect.objectContaining({ - where: { createdBy: mockUser.id, label: { contains: "Rea" } }, + where: { createdBy: mockUser.id, label: { contains: "Rea", mode: "insensitive" } }, }), ); expect(prisma.tag.count).toHaveBeenCalledWith({ - where: { createdBy: mockUser.id, label: { contains: "Rea" } }, + where: { createdBy: mockUser.id, label: { contains: "Rea", mode: "insensitive" } }, }); }); diff --git a/__tests__/task.actions.spec.ts b/__tests__/task.actions.spec.ts index 5b8216e28..36a3e7f1f 100644 --- a/__tests__/task.actions.spec.ts +++ b/__tests__/task.actions.spec.ts @@ -88,7 +88,12 @@ describe("taskActions", () => { select: { id: true }, }, }, - orderBy: [{ priority: "desc" }, { createdAt: "desc" }, { updatedAt: "desc" }], + orderBy: [ + { priority: "desc" }, + { createdAt: "desc" }, + { updatedAt: "desc" }, + { id: "desc" }, + ], skip: 0, take: 25, }); @@ -119,7 +124,12 @@ describe("taskActions", () => { select: { id: true }, }, }, - orderBy: [{ priority: "desc" }, { createdAt: "desc" }, { updatedAt: "desc" }], + orderBy: [ + { priority: "desc" }, + { createdAt: "desc" }, + { updatedAt: "desc" }, + { id: "desc" }, + ], skip: 10, take: 10, }); @@ -150,7 +160,12 @@ describe("taskActions", () => { select: { id: true }, }, }, - orderBy: [{ priority: "desc" }, { createdAt: "desc" }, { updatedAt: "desc" }], + orderBy: [ + { priority: "desc" }, + { createdAt: "desc" }, + { updatedAt: "desc" }, + { id: "desc" }, + ], skip: 0, take: 10, }); @@ -184,7 +199,12 @@ describe("taskActions", () => { select: { id: true }, }, }, - orderBy: [{ priority: "desc" }, { createdAt: "desc" }, { updatedAt: "desc" }], + orderBy: [ + { priority: "desc" }, + { createdAt: "desc" }, + { updatedAt: "desc" }, + { id: "desc" }, + ], skip: 0, take: 10, }); @@ -208,9 +228,10 @@ describe("taskActions", () => { }, }, orderBy: [ - { dueDate: "asc" }, + { dueDate: { sort: "asc", nulls: "first" } }, { priority: "desc" }, { createdAt: "desc" }, + { id: "desc" }, ], skip: 0, take: 25, @@ -245,6 +266,7 @@ describe("taskActions", () => { { activityType: { label: "asc" } }, { priority: "desc" }, { createdAt: "desc" }, + { id: "desc" }, ], skip: 0, take: 25, @@ -852,6 +874,7 @@ describe("taskActions", () => { where: { createdBy: mockUser.id, }, + orderBy: [{ createdAt: "asc" }, { id: "asc" }], include: { _count: { select: { diff --git a/src/actions/activity/activities.ts b/src/actions/activity/activities.ts index c9036fcf9..92ecab85f 100644 --- a/src/actions/activity/activities.ts +++ b/src/actions/activity/activities.ts @@ -37,9 +37,9 @@ export const getActivitiesList = async ( if (search) { whereClause.OR = [ - { activityName: { contains: search } }, - { description: { contains: search } }, - { activityType: { label: { contains: search } } }, + { activityName: { contains: search, mode: "insensitive" } }, + { description: { contains: search, mode: "insensitive" } }, + { activityType: { label: { contains: search, mode: "insensitive" } } }, ]; } @@ -47,9 +47,7 @@ export const getActivitiesList = async ( prisma.activity.findMany({ where: whereClause, select: ACTIVITY_LIST_SELECT, - orderBy: { - createdAt: "desc", - }, + orderBy: [{ createdAt: "desc" }, { id: "desc" }], skip: offset, take: limit, }), diff --git a/src/actions/activity/activityTypes.ts b/src/actions/activity/activityTypes.ts index 23eb8327c..add1104ea 100644 --- a/src/actions/activity/activityTypes.ts +++ b/src/actions/activity/activityTypes.ts @@ -12,6 +12,7 @@ export const getAllActivityTypes = async (): Promise => { where: { createdBy: user.id, }, + orderBy: [{ createdAt: "asc" }, { id: "asc" }], }); return activityTypes; } catch (error) { @@ -68,6 +69,7 @@ export const getActivityTypeList = async ( // Fetch all activity types with counts, then sort by total duration const allTypes = await prisma.activityType.findMany({ where: whereClause, + orderBy: [{ createdAt: "asc" }, { id: "asc" }], select: { id: true, label: true, diff --git a/src/actions/apiKey.actions.ts b/src/actions/apiKey.actions.ts index d65444357..0e3ffb9bc 100644 --- a/src/actions/apiKey.actions.ts +++ b/src/actions/apiKey.actions.ts @@ -21,6 +21,7 @@ export async function getUserApiKeys(): Promise<{ const keys = await db.apiKey.findMany({ where: { userId: user.id }, + orderBy: [{ createdAt: "asc" }, { id: "asc" }], select: { id: true, provider: true, diff --git a/src/actions/automation/discoveredJobs.ts b/src/actions/automation/discoveredJobs.ts index a1106e4a6..8ebd53952 100644 --- a/src/actions/automation/discoveredJobs.ts +++ b/src/actions/automation/discoveredJobs.ts @@ -61,7 +61,7 @@ export async function getDiscoveredJobs(options?: { where, skip, take: limit, - orderBy: { [sortBy]: sortOrder }, + orderBy: [{ [sortBy]: sortOrder }, { id: "desc" }], include: { automation: { select: { id: true, name: true }, diff --git a/src/actions/automation/queries.ts b/src/actions/automation/queries.ts index 03a3882a5..57ab004cc 100644 --- a/src/actions/automation/queries.ts +++ b/src/actions/automation/queries.ts @@ -25,7 +25,7 @@ export async function getAutomationsList( where: { userId: user.id }, skip, take: limit, - orderBy: { createdAt: "desc" }, + orderBy: [{ createdAt: "desc" }, { id: "desc" }], include: { resume: { select: { id: true, title: true }, diff --git a/src/actions/automation/runs.ts b/src/actions/automation/runs.ts index 86118850c..a65412f69 100644 --- a/src/actions/automation/runs.ts +++ b/src/actions/automation/runs.ts @@ -37,7 +37,7 @@ export async function getAutomationRuns( where: { automationId }, skip, take: limit, - orderBy: { startedAt: "desc" }, + orderBy: [{ startedAt: "desc" }, { id: "desc" }], }), db.automationRun.count({ where: { automationId } }), ]); diff --git a/src/actions/company/queries.ts b/src/actions/company/queries.ts index b71c65201..2202db04b 100644 --- a/src/actions/company/queries.ts +++ b/src/actions/company/queries.ts @@ -66,6 +66,7 @@ export const getAllCompanies = async (): Promise => { where: { createdBy: user.id, }, + orderBy: [{ label: "asc" }, { id: "asc" }], }); return companies; } catch (error) { diff --git a/src/actions/contact/queries.ts b/src/actions/contact/queries.ts index 0c4af44e5..0723f1841 100644 --- a/src/actions/contact/queries.ts +++ b/src/actions/contact/queries.ts @@ -20,9 +20,9 @@ export const getContactList = async ( const where: any = { createdBy: user.id }; if (search) { where.OR = [ - { name: { contains: search } }, - { email: { contains: search } }, - { title: { contains: search } }, + { name: { contains: search, mode: "insensitive" } }, + { email: { contains: search, mode: "insensitive" } }, + { title: { contains: search, mode: "insensitive" } }, ]; } // Either the standing role on the person or a role they hold on some job, @@ -41,7 +41,7 @@ export const getContactList = async ( // Name only. The spec's cold-contact use for lastContactedAt implies a // sort, but a sortable header means an orderBy threaded through the // action and the list hook plus a nulls-last rule — deferred to v2. - orderBy: [{ name: "asc" }], + orderBy: [{ name: "asc" }, { id: "desc" }], }), prisma.contact.count({ where }), ]); diff --git a/src/actions/contactRole.actions.ts b/src/actions/contactRole.actions.ts index 73b722eb5..23e4dc39b 100644 --- a/src/actions/contactRole.actions.ts +++ b/src/actions/contactRole.actions.ts @@ -28,7 +28,7 @@ export const getContactRoleList = async ( const user = await requireUser(); const whereClause: any = { createdBy: user.id }; if (search) { - whereClause.label = { contains: search }; + whereClause.label = { contains: search, mode: "insensitive" }; } const [data, total] = await Promise.all([ @@ -37,7 +37,7 @@ export const getContactRoleList = async ( skip: (page - 1) * limit, take: limit, include: { _count: { select: { jobContacts: true, contacts: true } } }, - orderBy: [{ label: "asc" }], + orderBy: [{ label: "asc" }, { id: "desc" }], }), prisma.contactRole.count({ where: whereClause }), ]); diff --git a/src/actions/coverLetter.actions.ts b/src/actions/coverLetter.actions.ts index e6a1c937b..97498c51c 100644 --- a/src/actions/coverLetter.actions.ts +++ b/src/actions/coverLetter.actions.ts @@ -40,9 +40,7 @@ export const getCoverLetterList = async ( }, }, }, - orderBy: { - createdAt: "desc", - }, + orderBy: [{ createdAt: "desc" }, { id: "desc" }], }), prisma.coverLetter.count({ where: { diff --git a/src/actions/interview.actions.ts b/src/actions/interview.actions.ts index f8a62ab65..1a84a2548 100644 --- a/src/actions/interview.actions.ts +++ b/src/actions/interview.actions.ts @@ -35,10 +35,10 @@ const buildInterviewWhere = ( if (search) { and.push({ OR: [ - { Job: { JobTitle: { label: { contains: search } } } }, - { Job: { Company: { label: { contains: search } } } }, - { StageType: { label: { contains: search } } }, - { interviewers: { some: { Contact: { name: { contains: search } } } } }, + { Job: { JobTitle: { label: { contains: search, mode: "insensitive" } } } }, + { Job: { Company: { label: { contains: search, mode: "insensitive" } } } }, + { StageType: { label: { contains: search, mode: "insensitive" } } }, + { interviewers: { some: { Contact: { name: { contains: search, mode: "insensitive" } } } } }, ], }); } @@ -80,7 +80,7 @@ export const getInterviewList = async ( skip, take: datedTake, include: INTERVIEW_LIST_INCLUDE, - orderBy: [{ occurredAt: "desc" }, { createdAt: "desc" }], + orderBy: [{ occurredAt: "desc" }, { createdAt: "desc" }, { id: "desc" }], }) : []; @@ -92,7 +92,7 @@ export const getInterviewList = async ( skip: Math.max(0, skip - datedTotal), take: undatedTake, include: INTERVIEW_LIST_INCLUDE, - orderBy: { createdAt: "desc" }, + orderBy: [{ createdAt: "desc" }, { id: "desc" }], }) : []; diff --git a/src/actions/job/queries.ts b/src/actions/job/queries.ts index 1b9921b1e..5b049fc2c 100644 --- a/src/actions/job/queries.ts +++ b/src/actions/job/queries.ts @@ -64,7 +64,9 @@ const JOB_DETAILS_INCLUDE = { }, }, CoverLetter: true, - tags: true, + tags: { + orderBy: [{ label: "asc" as const }, { id: "asc" as const }], + }, contactLinks: { include: { Role: true, @@ -155,19 +157,19 @@ const buildJobsWhereClause = (userId: string, filters: JobsListFilters) => { if (search) { const searchConditions: Record[] = []; if (!titleValue) { - searchConditions.push({ JobTitle: { label: { contains: search } } }); + searchConditions.push({ JobTitle: { label: { contains: search, mode: "insensitive" } } }); } if (!companyValue) { - searchConditions.push({ Company: { label: { contains: search } } }); + searchConditions.push({ Company: { label: { contains: search, mode: "insensitive" } } }); } if (!locationValue) { - searchConditions.push({ Location: { label: { contains: search } } }); + searchConditions.push({ Location: { label: { contains: search, mode: "insensitive" } } }); } if (!sourceValue) { - searchConditions.push({ JobSource: { label: { contains: search } } }); + searchConditions.push({ JobSource: { label: { contains: search, mode: "insensitive" } } }); } searchConditions.push( - { description: { contains: search } }, + { description: { contains: search, mode: "insensitive" } }, ); whereClause.OR = searchConditions; } @@ -322,7 +324,7 @@ export async function* getJobsIterator(filter?: string, pageSize = 200) { select: JOB_EXPORT_SELECT, skip, take: pageSize, - orderBy: { createdAt: "desc" }, + orderBy: [{ createdAt: "desc" }, { id: "desc" }], }); if (!chunk.length) { diff --git a/src/actions/job/references.ts b/src/actions/job/references.ts index af6cff257..bddd8d92c 100644 --- a/src/actions/job/references.ts +++ b/src/actions/job/references.ts @@ -31,6 +31,7 @@ export const getJobSourceList = async (): Promise => { where: { createdBy: user.id, }, + orderBy: [{ label: "asc" }, { id: "asc" }], }); return list; } catch (error) { diff --git a/src/actions/jobLocation.actions.ts b/src/actions/jobLocation.actions.ts index 3b276edc4..a04ec876a 100644 --- a/src/actions/jobLocation.actions.ts +++ b/src/actions/jobLocation.actions.ts @@ -12,6 +12,7 @@ export const getAllJobLocations = async (): Promise => { where: { createdBy: user.id, }, + orderBy: [{ label: "asc" }, { id: "asc" }], }); return list; } catch (error) { diff --git a/src/actions/jobStageType.actions.ts b/src/actions/jobStageType.actions.ts index 7718be6e1..cb0a5243b 100644 --- a/src/actions/jobStageType.actions.ts +++ b/src/actions/jobStageType.actions.ts @@ -31,7 +31,7 @@ export const getJobStageTypeList = async ( const user = await requireUser(); const whereClause: any = { createdBy: user.id }; if (search) { - whereClause.label = { contains: search }; + whereClause.label = { contains: search, mode: "insensitive" }; } const [data, total] = await Promise.all([ @@ -40,7 +40,7 @@ export const getJobStageTypeList = async ( skip: (page - 1) * limit, take: limit, include: { Status: true, _count: { select: { stages: true } } }, - orderBy: [{ sortOrder: "asc" }, { label: "asc" }], + orderBy: [{ sortOrder: "asc" }, { label: "asc" }, { id: "desc" }], }), prisma.jobStageType.count({ where: whereClause }), ]); diff --git a/src/actions/jobtitle.actions.ts b/src/actions/jobtitle.actions.ts index 90aabacd1..f314b41d6 100644 --- a/src/actions/jobtitle.actions.ts +++ b/src/actions/jobtitle.actions.ts @@ -13,6 +13,7 @@ export const getAllJobTitles = async (): Promise => { where: { createdBy: user?.id, }, + orderBy: [{ label: "asc" }, { id: "asc" }], }); return list; } catch (error) { diff --git a/src/actions/profile/resume.ts b/src/actions/profile/resume.ts index a779d1149..a92a2ecd5 100644 --- a/src/actions/profile/resume.ts +++ b/src/actions/profile/resume.ts @@ -38,7 +38,7 @@ export const getResumeList = async ( prisma.resume.findMany({ where: restWhere, select: resumeListSelect, - orderBy: { createdAt: "desc" }, + orderBy: [{ createdAt: "desc" }, { id: "desc" }], take: limit - 1, }), ]); @@ -47,7 +47,7 @@ export const getResumeList = async ( rawData = await prisma.resume.findMany({ where: restWhere, select: resumeListSelect, - orderBy: { createdAt: "desc" }, + orderBy: [{ createdAt: "desc" }, { id: "desc" }], skip: (page - 1) * limit - 1, take: limit, }); @@ -56,7 +56,7 @@ export const getResumeList = async ( rawData = await prisma.resume.findMany({ where, select: resumeListSelect, - orderBy: { createdAt: "desc" }, + orderBy: [{ createdAt: "desc" }, { id: "desc" }], skip: (page - 1) * limit, take: limit, }); diff --git a/src/actions/profile/shared.ts b/src/actions/profile/shared.ts index bfe9dfac5..13d7c0393 100644 --- a/src/actions/profile/shared.ts +++ b/src/actions/profile/shared.ts @@ -1,5 +1,6 @@ import prisma from "@/lib/db"; import { isResumeFilePath } from "@/lib/resumeFiles"; +import { ENTRY_ORDER, CERTIFICATION_ORDER } from "@/lib/jobs/resumeDetailInclude"; // Not a "use server" module: it exports selects, so it stays internal to // src/actions/profile/ and is never imported by a client component. @@ -63,12 +64,13 @@ export const resumeCopySelect = { title: true, ContactInfo: true, ResumeSections: { + orderBy: { id: "asc" }, include: { summary: true, - workExperiences: true, - educations: true, - licenseOrCertifications: true, - others: true, + workExperiences: { orderBy: ENTRY_ORDER }, + educations: { orderBy: ENTRY_ORDER }, + licenseOrCertifications: { orderBy: CERTIFICATION_ORDER }, + others: { orderBy: ENTRY_ORDER }, skills: true, }, }, diff --git a/src/actions/question.actions.ts b/src/actions/question.actions.ts index 3d75ceb3d..ec7086187 100644 --- a/src/actions/question.actions.ts +++ b/src/actions/question.actions.ts @@ -37,8 +37,8 @@ export const getQuestionsList = async ( if (search) { whereClause.OR = [ - { question: { contains: search } }, - { answer: { contains: search } }, + { question: { contains: search, mode: "insensitive" } }, + { answer: { contains: search, mode: "insensitive" } }, ]; } @@ -46,9 +46,9 @@ export const getQuestionsList = async ( prisma.question.findMany({ where: whereClause, include: { - tags: true, + tags: { orderBy: [{ label: "asc" }, { id: "asc" }] }, }, - orderBy: [{ createdAt: "desc" }], + orderBy: [{ createdAt: "desc" }, { id: "desc" }], skip: offset, take: limit, }), diff --git a/src/actions/referenceList.ts b/src/actions/referenceList.ts index 8cee6ce18..ac0c94674 100644 --- a/src/actions/referenceList.ts +++ b/src/actions/referenceList.ts @@ -64,7 +64,7 @@ export const getReferenceEntityList = async ({ if (search) { const fields = searchFields ?? ["label"]; whereClause.OR = fields.map((field) => ({ - [field]: { contains: search }, + [field]: { contains: search, mode: "insensitive" }, })); } @@ -111,10 +111,14 @@ export const getReferenceEntityList = async ({ } : {}), // Two keys, not one: the applied-count sort alone has no tiebreak, so - // equal-count rows page unstably under infinite scroll. - orderBy: orderBy ?? [ - { [appliedRelation]: { _count: "desc" } }, - { label: "asc" }, + // equal-count rows page unstably under infinite scroll. The trailing id + // makes the order total, which Postgres needs across OFFSET pages. + orderBy: [ + ...(orderBy ?? [ + { [appliedRelation]: { _count: "desc" } }, + { label: "asc" }, + ]), + { id: "desc" }, ], }), model.count({ diff --git a/src/actions/tag.actions.ts b/src/actions/tag.actions.ts index 7964121d2..1c2e673ad 100644 --- a/src/actions/tag.actions.ts +++ b/src/actions/tag.actions.ts @@ -30,7 +30,7 @@ export const getTagList = async ( const whereClause: any = { createdBy: user.id }; if (search) { - whereClause.label = { contains: search }; + whereClause.label = { contains: search, mode: "insensitive" }; } const [data, total] = await Promise.all([ @@ -44,7 +44,7 @@ export const getTagList = async ( value: true, _count: { select: { jobs: true, questions: true, skills: true } }, }, - orderBy: { jobs: { _count: "desc" } }, + orderBy: [{ jobs: { _count: "desc" } }, { id: "desc" }], }), prisma.tag.count({ where: whereClause }), ]); diff --git a/src/actions/task/queries.ts b/src/actions/task/queries.ts index 778e32eba..8c4ca63b2 100644 --- a/src/actions/task/queries.ts +++ b/src/actions/task/queries.ts @@ -16,29 +16,38 @@ function getTasksOrderBy(groupBy?: TaskGroupBy) { switch (groupBy) { case "dueDate": return [ - { dueDate: "asc" as const }, + // Undated tasks first, as SQLite sorted them; Postgres defaults last + { dueDate: { sort: "asc" as const, nulls: "first" as const } }, { priority: "desc" as const }, { createdAt: "desc" as const }, + { id: "desc" as const }, ]; case "createdDate": - return [{ createdAt: "desc" as const }, { priority: "desc" as const }]; + return [ + { createdAt: "desc" as const }, + { priority: "desc" as const }, + { id: "desc" as const }, + ]; case "updatedDate": return [ { updatedAt: "desc" as const }, { priority: "desc" as const }, { createdAt: "desc" as const }, + { id: "desc" as const }, ]; case "activityType": return [ { activityType: { label: "asc" as const } }, { priority: "desc" as const }, { createdAt: "desc" as const }, + { id: "desc" as const }, ]; default: return [ { priority: "desc" as const }, { createdAt: "desc" as const }, { updatedAt: "desc" as const }, + { id: "desc" as const }, ]; } } @@ -70,9 +79,9 @@ export const getTasksList = async ( if (search) { whereClause.OR = [ - { title: { contains: search } }, - { description: { contains: search } }, - { activityType: { label: { contains: search } } }, + { title: { contains: search, mode: "insensitive" } }, + { description: { contains: search, mode: "insensitive" } }, + { activityType: { label: { contains: search, mode: "insensitive" } } }, ]; } @@ -140,6 +149,7 @@ export const getActivityTypesWithTaskCounts = async (): Promise< where: { createdBy: user.id, }, + orderBy: [{ createdAt: "asc" }, { id: "asc" }], include: { _count: { select: { diff --git a/src/lib/jobs/resumeDetailInclude.ts b/src/lib/jobs/resumeDetailInclude.ts index 11050d677..814b32249 100644 --- a/src/lib/jobs/resumeDetailInclude.ts +++ b/src/lib/jobs/resumeDetailInclude.ts @@ -4,13 +4,28 @@ // getDefaultResumeForUser (MCP path) stay in sync. profile.actions.ts has // "use server" at the top, which only allows async-function exports, so this // object can't live there directly. +// Postgres returns rows in storage order and an UPDATE moves the row, so an +// unordered relation would reshuffle a resume after any edit. +export const ENTRY_ORDER = [ + { createdAt: "asc" as const }, + { id: "asc" as const }, +]; +// No createdAt on this model: newest credential first, undated ones last +export const CERTIFICATION_ORDER = [ + { issueDate: { sort: "desc" as const, nulls: "last" as const } }, + { title: "asc" as const }, + { id: "asc" as const }, +]; + export const resumeDetailInclude = { ContactInfo: true, File: true, ResumeSections: { + orderBy: { id: "asc" }, include: { summary: true, workExperiences: { + orderBy: ENTRY_ORDER, include: { jobTitle: true, Company: true, @@ -18,11 +33,12 @@ export const resumeDetailInclude = { }, }, educations: { + orderBy: ENTRY_ORDER, include: { location: true, }, }, - licenseOrCertifications: true, + licenseOrCertifications: { orderBy: CERTIFICATION_ORDER }, skills: { include: { Tag: true } }, }, }, diff --git a/src/lib/scraper/runner.ts b/src/lib/scraper/runner.ts index f9af9c4c8..b34ac6190 100644 --- a/src/lib/scraper/runner.ts +++ b/src/lib/scraper/runner.ts @@ -1,4 +1,5 @@ import db from "@/lib/db"; +import { resumeDetailInclude } from "@/lib/jobs/resumeDetailInclude"; import type { Automation } from "@/models/automation.model"; import { ATS_PROVIDERS } from "./ats/registry"; import { AiProvider } from "@/models/ai.model"; @@ -167,28 +168,7 @@ async function runAutomationTraced( const resume = await db.resume.findUnique({ where: { id: automation.resumeId }, - include: { - ContactInfo: true, - ResumeSections: { - include: { - summary: true, - workExperiences: { - include: { - Company: true, - jobTitle: true, - location: true, - }, - }, - educations: { - include: { - location: true, - }, - }, - licenseOrCertifications: true, - skills: { include: { Tag: true } }, - }, - }, - }, + include: resumeDetailInclude, }); if (!resume) { From dfd6de7dc563e4065537f01eba08bc089fb7e2f1 Mon Sep 17 00:00:00 2001 From: Khuram Niaz Date: Tue, 13 Oct 2026 09:05:55 -0600 Subject: [PATCH 04/14] feat(import): add the frozen SQLite snapshot schema, the boot-script build config and the import ordering and sanitising modules Co-Authored-By: Claude Sonnet 5.5 --- .dockerignore | 2 + .gitignore | 4 + __tests__/importOrder.spec.ts | 100 ++++ __tests__/importSanitize.spec.ts | 35 ++ eslint.config.mjs | 2 +- prisma/schema.sqlite-snapshot.prisma | 735 +++++++++++++++++++++++++++ scripts/import/order.ts | 105 ++++ scripts/import/sanitize.ts | 24 + scripts/tsconfig.json | 15 + 9 files changed, 1021 insertions(+), 1 deletion(-) create mode 100644 __tests__/importOrder.spec.ts create mode 100644 __tests__/importSanitize.spec.ts create mode 100644 prisma/schema.sqlite-snapshot.prisma create mode 100644 scripts/import/order.ts create mode 100644 scripts/import/sanitize.ts create mode 100644 scripts/tsconfig.json diff --git a/.dockerignore b/.dockerignore index 5dbd41ab7..99850d380 100644 --- a/.dockerignore +++ b/.dockerignore @@ -16,3 +16,5 @@ docs coverage __tests__ e2e +src/generated +scripts/dist diff --git a/.gitignore b/.gitignore index 69651172d..d621ce381 100644 --- a/.gitignore +++ b/.gitignore @@ -53,6 +53,10 @@ next-env.d.ts /data/ /jobsyncdb/ +# generated: the frozen SQLite snapshot client and the compiled boot scripts +/src/generated/ +/scripts/dist/ + /test-results/ /playwright-report/ /blob-report/ diff --git a/__tests__/importOrder.spec.ts b/__tests__/importOrder.spec.ts new file mode 100644 index 000000000..3e7c274ea --- /dev/null +++ b/__tests__/importOrder.spec.ts @@ -0,0 +1,100 @@ +import { Prisma } from "@prisma/client"; +import { + buildImportPlan, + delegateName, + type DmmfModel, +} from "../scripts/import/order"; + +const models = Prisma.dmmf.datamodel.models as unknown as DmmfModel[]; + +const model = (name: string, fields: DmmfModel["fields"] = []): DmmfModel => ({ + name, + fields: [{ name: "id", kind: "scalar", type: "String", isList: false, isId: true }, ...fields], +}); +const fk = (name: string, type: string, from: string) => ({ + name, + kind: "object", + type, + isList: false, + relationName: `${name}Rel`, + relationFromFields: [from], +}); + +describe("buildImportPlan on the real schema", () => { + const plan = buildImportPlan(models); + + it("covers every model exactly once", () => { + expect([...plan.order].sort()).toEqual(models.map((m) => m.name).sort()); + }); + + it("places every foreign-key target before its holder", () => { + for (const m of models) { + for (const field of m.fields) { + const from = field.relationFromFields ?? []; + if (field.kind !== "object" || from.length === 0) continue; + if (m.name === "User" && from.includes("defaultResumeId")) continue; + expect(plan.order.indexOf(field.type)).toBeLessThan(plan.order.indexOf(m.name)); + } + } + }); + + it("defers only User.defaultResumeId", () => { + expect(plan.deferred).toEqual([{ model: "User", field: "defaultResumeId" }]); + expect(plan.order.indexOf("User")).toBeLessThan(plan.order.indexOf("Resume")); + }); + + it("finds both implicit join tables with Prisma's A/B order", () => { + expect(plan.joinTables).toEqual( + expect.arrayContaining([ + { table: "_JobToTag", a: "Job", b: "Tag" }, + { table: "_QuestionToTag", a: "Question", b: "Tag" }, + ]), + ); + expect(plan.joinTables).toHaveLength(2); + }); + + it("does not treat Tag.skills as a join table", () => { + expect(plan.joinTables.map((j) => j.table)).not.toContain("_SkillToTag"); + }); + + it("can cursor every model on a string id", () => { + for (const m of models) { + const id = m.fields.find((f) => f.isId); + expect(id, m.name).toMatchObject({ name: "id", type: "String" }); + } + }); + + it("maps model names to client delegates", () => { + expect(delegateName("JobStageType")).toBe("jobStageType"); + expect(delegateName("AiCall")).toBe("aiCall"); + }); +}); + +describe("buildImportPlan guards", () => { + it("throws on a second cycle instead of guessing", () => { + const cyclic = [model("A", [fk("b", "B", "bId")]), model("B", [fk("a", "A", "aId")])]; + expect(() => buildImportPlan(cyclic)).toThrow(/cycle among: A, B/); + }); + + it("throws on a self-referencing foreign key", () => { + expect(() => buildImportPlan([model("Node", [fk("parent", "Node", "parentId")])])).toThrow( + /Self-referencing foreign key on Node\.parent/, + ); + }); + + it("picks up a third implicit many-to-many", () => { + const list = (name: string, type: string) => ({ + name, + kind: "object", + type, + isList: true, + relationName: "ContactToTag", + relationFromFields: [], + }); + const plan = buildImportPlan([ + model("Tag", [list("contacts", "Contact")]), + model("Contact", [list("tags", "Tag")]), + ]); + expect(plan.joinTables).toEqual([{ table: "_ContactToTag", a: "Contact", b: "Tag" }]); + }); +}); diff --git a/__tests__/importSanitize.spec.ts b/__tests__/importSanitize.spec.ts new file mode 100644 index 000000000..98b79501d --- /dev/null +++ b/__tests__/importSanitize.spec.ts @@ -0,0 +1,35 @@ +import { sanitizeRow, sanitizeString } from "../scripts/import/sanitize"; + +describe("sanitizeString", () => { + it("strips NUL", () => { + expect(sanitizeString("a\u0000b")).toBe("ab"); + }); + + it("strips unpaired surrogates on either side", () => { + expect(sanitizeString("a\uD83Db")).toBe("ab"); + expect(sanitizeString("a\uDE00b")).toBe("ab"); + }); + + it("keeps a valid surrogate pair", () => { + expect(sanitizeString("ok 😀")).toBe("ok 😀"); + }); +}); + +describe("sanitizeRow", () => { + it("returns the same object and reports nothing when clean", () => { + const row = { id: "1", title: "Engineer", applied: true, createdAt: new Date(0) }; + const touched: string[] = []; + expect(sanitizeRow(row, (c) => touched.push(c))).toBe(row); + expect(touched).toEqual([]); + }); + + it("cleans string columns only and names each one", () => { + const createdAt = new Date(0); + const row = { id: "1", description: "pdf\u0000text", notes: "x\uD83D", salary: null, createdAt }; + const touched: string[] = []; + const clean = sanitizeRow(row, (c) => touched.push(c)); + expect(clean).toEqual({ id: "1", description: "pdftext", notes: "x", salary: null, createdAt }); + expect(touched).toEqual(["description", "notes"]); + expect(row.description).toBe("pdf\u0000text"); + }); +}); diff --git a/eslint.config.mjs b/eslint.config.mjs index 946bba57e..f10e92171 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -8,7 +8,7 @@ const __dirname = dirname(__filename); const compat = new FlatCompat({ baseDirectory: __dirname }); const eslintConfig = [ - { ignores: [".next/", "coverage/", "__mocks__/"] }, + { ignores: [".next/", "coverage/", "__mocks__/", "src/generated/", "scripts/dist/"] }, ...compat.extends("next/core-web-vitals"), { // Playwright's fixture `use` callback is not a React hook. diff --git a/prisma/schema.sqlite-snapshot.prisma b/prisma/schema.sqlite-snapshot.prisma new file mode 100644 index 000000000..e44ca6574 --- /dev/null +++ b/prisma/schema.sqlite-snapshot.prisma @@ -0,0 +1,735 @@ +// FROZEN. The last SQLite-release schema, read only by scripts/import-sqlite.ts +// to copy a legacy dev.db into Postgres. Never edit the models below; delete +// this file when the import shim is retired (spec section 15). + +generator client { + provider = "prisma-client-js" + output = "../src/generated/sqlite-snapshot" +} + +datasource db { + provider = "sqlite" + url = env("SQLITE_SNAPSHOT_URL") +} + +model User { + id String @id @default(uuid()) + name String + email String @unique + password String + createdAt DateTime @default(now()) + jobsApplied Job[] + JobTitle JobTitle[] + Location Location[] + Company Company[] + JobSource JobSource[] + Contact Contact[] + ContactRole ContactRole[] + JobStageType JobStageType[] + Profile Profile[] + Activity Activity[] + ActivityType ActivityType[] + Task Task[] + Automation Automation[] + Settings UserSettings? + ApiKey ApiKey[] + Note Note[] + Tag Tag[] + Question Question[] + McpAccessToken McpAccessToken[] + Notification Notification[] + aiCalls AiCall[] + ChatConversation ChatConversation? + defaultResumeId String? + defaultResume Resume? @relation("UserDefaultResume", fields: [defaultResumeId], references: [id], onDelete: SetNull) +} + +model ApiKey { + id String @id @default(uuid()) + userId String + user User @relation(fields: [userId], references: [id]) + provider String + encryptedKey String + iv String + last4 String + label String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + lastUsedAt DateTime? + + @@unique([userId, provider]) + @@index([userId]) +} + +model UserSettings { + id String @id @default(uuid()) + userId String @unique + user User @relation(fields: [userId], references: [id]) + settings String @default("{}") + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +model Profile { + id String @id @default(uuid()) + userId String + user User @relation(fields: [userId], references: [id]) + resumes Resume[] + coverLetters CoverLetter[] +} + +model Resume { + id String @id @default(uuid()) + profileId String + title String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + ContactInfo ContactInfo? + ResumeSections ResumeSection[] + profile Profile @relation(fields: [profileId], references: [id]) + File File? @relation(fields: [FileId], references: [id]) + FileId String? @unique + Job Job[] + Automation Automation[] + defaultForUser User[] @relation("UserDefaultResume") + reviewData String? +} + +model CoverLetter { + id String @id @default(uuid()) + profileId String + title String + content String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + profile Profile @relation(fields: [profileId], references: [id]) + Job Job[] +} + +model File { + id String @id @default(uuid()) + fileName String + filePath String + fileType String + uploadedAt DateTime @default(now()) + Resume Resume? +} + +model ResumeSection { + id String @id @default(uuid()) + resumeId String + sectionTitle String + sectionType String + summaryId String? @unique + summary Summary? @relation(fields: [summaryId], references: [id]) + workExperiences WorkExperience[] + educations Education[] + licenseOrCertifications LicenseOrCertification[] + others OtherSection[] + skills Skill[] + Resume Resume @relation(fields: [resumeId], references: [id]) +} + +model ContactInfo { + id String @id @default(uuid()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + resumeId String @unique + firstName String + lastName String + headline String + email String + phone String + address String? + url1 String? + url1Label String? + url2 String? + url2Label String? + resume Resume @relation(fields: [resumeId], references: [id]) +} + +model Summary { + id String @id @default(uuid()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + content String + ResumeSection ResumeSection? @relation +} + +model WorkExperience { + id String @id @default(uuid()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + companyId String + Company Company @relation(fields: [companyId], references: [id]) + jobTitleId String + jobTitle JobTitle @relation(fields: [jobTitleId], references: [id]) + startDate DateTime + endDate DateTime? + description String + locationId String + location Location @relation(fields: [locationId], references: [id]) + resumeSectionId String? + ResumeSection ResumeSection? @relation(fields: [resumeSectionId], references: [id]) +} + +model Education { + id String @id @default(uuid()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + institution String + degree String + fieldOfStudy String + startDate DateTime + endDate DateTime? + description String? + locationId String + location Location @relation(fields: [locationId], references: [id]) + resumeSectionId String? + ResumeSection ResumeSection? @relation(fields: [resumeSectionId], references: [id]) +} + +model LicenseOrCertification { + id String @id @default(uuid()) + title String + organization String + issueDate DateTime? + expirationDate DateTime? + credentialUrl String? + ResumeSection ResumeSection? @relation(fields: [resumeSectionId], references: [id]) + resumeSectionId String? +} + +model OtherSection { + id String @id @default(uuid()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + title String + content String + ResumeSection ResumeSection? @relation(fields: [resumeSectionId], references: [id]) + resumeSectionId String? +} + +model Contact { + id String @id @default(uuid()) + name String + title String? + email String? + phone String? + linkedinUrl String? + + // Where they work now + companyId String? + Company Company? @relation("ContactCompany", fields: [companyId], references: [id]) + locationId String? + Location Location? @relation(fields: [locationId], references: [id]) + + // Shared history — how you know them + relationship String? + workedAtCompanyId String? + WorkedAtCompany Company? @relation("ContactWorkedAt", fields: [workedAtCompanyId], references: [id]) + workedFrom DateTime? + workedTo DateTime? + + // What this person generally is to you. The per-job role on JobContact wins + // for a specific job; this is the standing answer and the Phase 5 prefill. + roleId String? + Role ContactRole? @relation(fields: [roleId], references: [id]) + + notes String? + lastContactedAt DateTime? + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + createdBy String // userId + user User @relation(fields: [createdBy], references: [id]) + + jobLinks JobContact[] + stageLinks JobStageInterviewer[] + + @@index([createdBy]) + @@index([companyId]) + @@index([workedAtCompanyId]) + @@index([roleId]) +} + +model ContactRole { + id String @id @default(uuid()) + label String + value String + createdBy String // userId + user User @relation(fields: [createdBy], references: [id]) + jobContacts JobContact[] + contacts Contact[] + + @@unique([value, createdBy]) +} + +model JobContact { + id String @id @default(uuid()) + jobId String + Job Job @relation(fields: [jobId], references: [id], onDelete: Cascade) + contactId String + Contact Contact @relation(fields: [contactId], references: [id], onDelete: Cascade) + roleId String + Role ContactRole @relation(fields: [roleId], references: [id]) + createdAt DateTime @default(now()) + + @@unique([jobId, contactId, roleId]) + @@index([contactId]) +} + +model JobTitle { + id String @id @default(uuid()) + label String + value String + jobs Job[] + createdBy String // userId + user User @relation(fields: [createdBy], references: [id]) + workExperiences WorkExperience[] + + @@unique([value, createdBy]) +} + +model JobStatus { + id String @id @default(uuid()) + label String + value String @unique + jobs Job[] + stageTypes JobStageType[] +} + +model Location { + id String @id @default(uuid()) + label String + value String + stateProv String? + country String? + jobsApplied Job[] + createdBy String // userId + user User @relation(fields: [createdBy], references: [id]) + educations Education[] + workExperience WorkExperience[] + contacts Contact[] + + @@unique([value, createdBy]) +} + +model Company { + id String @id @default(uuid()) + label String + value String + jobsApplied Job[] + logoUrl String? // field is optional + createdBy String // userId + user User @relation(fields: [createdBy], references: [id]) + workExperiences WorkExperience[] + + contacts Contact[] @relation("ContactCompany") + formerColleagues Contact[] @relation("ContactWorkedAt") + + // Watch intent — independent of whether a board is linked + watched Boolean @default(false) + watchedAt DateTime? + + // Board coordinates; atsHost is Lever-only + atsProvider String? + atsToken String? + atsHost String? + + // Company attributes, unrelated to boards + websiteUrl String? + careersUrl String? + industry String? + linkedinUrl String? + size String? + notes String? + + @@unique([value, createdBy]) + @@unique([createdBy, atsProvider, atsToken]) + @@index([createdBy, watched]) +} + +model JobSource { + id String @id @default(uuid()) + label String + value String + jobsApplied Job[] + createdBy String // userId + user User @relation(fields: [createdBy], references: [id]) + + @@unique([value, createdBy]) +} + +// The kind of stage. Mirrors ContactRole in ownership and resolution, and +// JobSource in being user-extensible. statusId is both the derived-status +// source and the interview-kind discriminator (spec decisions 3 and 10). +model JobStageType { + id String @id @default(uuid()) + label String + value String + statusId String + Status JobStatus @relation(fields: [statusId], references: [id]) + sortOrder Int @default(0) + createdBy String // userId + user User @relation(fields: [createdBy], references: [id]) + stages JobStage[] + + @@unique([value, createdBy]) + @@index([createdBy]) +} + +// One entry in a job's timeline. No ownership column: a stage has no meaning +// apart from its job, so ownership traverses Job: { userId }. +model JobStage { + id String @id @default(uuid()) + jobId String + Job Job @relation(fields: [jobId], references: [id], onDelete: Cascade) + stageTypeId String + StageType JobStageType @relation(fields: [stageTypeId], references: [id]) + occurredAt DateTime? + isCurrent Boolean @default(false) + outcome String? + notes String? + durationMins Int? + format String? + location String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + interviewers JobStageInterviewer[] + prepQuestions JobStagePrepQuestion[] + + @@index([jobId]) + @@index([stageTypeId]) + // Also has a hand-written partial unique index (not expressible here): + // UNIQUE(jobId) WHERE isCurrent = 1 + // See migrations/20260919000000_job_stages_timeline +} + +model JobStageInterviewer { + id String @id @default(uuid()) + stageId String + Stage JobStage @relation(fields: [stageId], references: [id], onDelete: Cascade) + contactId String + Contact Contact @relation(fields: [contactId], references: [id], onDelete: Cascade) + createdAt DateTime @default(now()) + + @@unique([stageId, contactId]) + @@index([contactId]) +} + +// questionId is deliberately NOT cascading: erasing the record that a question +// was asked in a real interview is worse than a delete that fails loudly. +model JobStagePrepQuestion { + id String @id @default(uuid()) + stageId String + Stage JobStage @relation(fields: [stageId], references: [id], onDelete: Cascade) + questionId String + Question Question @relation(fields: [questionId], references: [id]) + asked Boolean @default(false) + askedAt DateTime? + createdAt DateTime @default(now()) + + @@unique([stageId, questionId]) + @@index([questionId]) +} + +model Job { + id String @id @default(uuid()) + userId String + User User @relation(fields: [userId], references: [id]) + jobUrl String? + description String + jobType String + workplaceType String? + createdAt DateTime + applied Boolean @default(false) + appliedDate DateTime? + dueDate DateTime? + statusId String + Status JobStatus @relation(fields: [statusId], references: [id]) + JobTitle JobTitle @relation(fields: [jobTitleId], references: [id]) + jobTitleId String + Company Company @relation(fields: [companyId], references: [id]) + companyId String + JobSource JobSource? @relation(fields: [jobSourceId], references: [id]) + jobSourceId String? + salaryRange String? + Location Location? @relation(fields: [locationId], references: [id]) + locationId String? + Resume Resume? @relation(fields: [resumeId], references: [id]) + resumeId String? + CoverLetter CoverLetter? @relation(fields: [coverLetterId], references: [id]) + coverLetterId String? + Notes Note[] + tags Tag[] + contactLinks JobContact[] + stages JobStage[] + + // Automation discovery fields + automationId String? + automation Automation? @relation(fields: [automationId], references: [id], onDelete: SetNull) + matchScore Int? + matchData String? + discoveryStatus String? + discoveredAt DateTime? + + // MCP provenance: which agent/token created this job (null = UI or automation) + createdVia String? + // Word-count classification of `description` at write time: + // "title-only" | "partial" | "full". Null for pre-existing/UI jobs. + descriptionCompleteness String? + + @@index([userId, automationId]) + @@index([userId, discoveryStatus]) + // Also has a hand-written partial unique index (not expressible here): + // UNIQUE(userId, jobUrl) WHERE automationId IS NOT NULL + // See migrations/20260710000002_job_automation_url_unique +} + +model ActivityType { + id String @id @default(uuid()) + label String + value String + description String? + createdBy String // userId + user User @relation(fields: [createdBy], references: [id]) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + Activities Activity[] + Tasks Task[] + + @@unique([value, createdBy]) +} + +model Activity { + id String @id @default(uuid()) + userId String + User User @relation(fields: [userId], references: [id]) + activityName String + startTime DateTime + endTime DateTime? + duration Int? + // Completed break time in minutes. Existing rows default to 0, so + // duration keeps meaning (endTime - startTime) for everything already saved. + breakMinutes Int @default(0) + // Set only while a break is open — this is the on-break flag. + breakStartedAt DateTime? + // Drives the countdown ring only; never the arithmetic. + breakPlannedMins Int? + description String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + activityType ActivityType @relation(fields: [activityTypeId], references: [id]) + activityTypeId String + + taskId String? + task Task? @relation(fields: [taskId], references: [id]) +} + +model Task { + id String @id @default(uuid()) + userId String + user User @relation(fields: [userId], references: [id]) + + title String + description String? + status String @default("in-progress") + priority Int @default(5) + percentComplete Int @default(0) + dueDate DateTime? + + activityTypeId String? + activityType ActivityType? @relation(fields: [activityTypeId], references: [id]) + + activities Activity[] + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([userId]) + @@index([userId, status]) + @@index([userId, activityTypeId]) + @@index([userId, dueDate]) +} + +model Automation { + id String @id @default(uuid()) + userId String + user User @relation(fields: [userId], references: [id]) + + name String + jobBoard String + keywords String + location String + sourceConfig String? // JSON; per-source params (Greenhouse watchlist, titles) + resumeId String + resume Resume @relation(fields: [resumeId], references: [id]) + matchThreshold Int @default(80) + + scheduleHour Int + nextRunAt DateTime? + lastRunAt DateTime? + + status String @default("active") + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + runs AutomationRun[] + discoveredJobs Job[] + notifications Notification[] + + @@index([userId]) + @@index([status, nextRunAt]) +} + +model AutomationRun { + id String @id @default(uuid()) + automationId String + automation Automation @relation(fields: [automationId], references: [id], onDelete: Cascade) + + jobsSearched Int @default(0) + jobsDeduplicated Int @default(0) + jobsProcessed Int @default(0) + jobsMatched Int @default(0) + jobsSaved Int @default(0) + + status String @default("running") + errorMessage String? + blockedReason String? + funnelStats String? // JSON: ordered per-source funnel for the run summary + + startedAt DateTime @default(now()) + completedAt DateTime? + + @@index([automationId]) + @@index([startedAt]) + // Also has a hand-written partial unique index (not expressible here): + // UNIQUE(automationId) WHERE status IN ('running', 'cancelling') + // See migrations/20260710000001_automation_run_single_active +} + +model Notification { + id String @id @default(uuid()) + userId String + user User @relation(fields: [userId], references: [id]) + automationId String + automation Automation @relation(fields: [automationId], references: [id], onDelete: Cascade) + runId String? // last run that produced/refreshed it; plain id, no FK + + kind String // "run" | "error" | "board" + boardToken String? // set only for kind "board"; the D2 refresh key + payload String // JSON, shape per kind in notification.model.ts + occurrences Int @default(1) + + occurredAt DateTime @default(now()) + readAt DateTime? + dismissedAt DateTime? + createdAt DateTime @default(now()) + + @@index([userId, occurredAt]) + @@index([userId, readAt]) + @@index([automationId, kind, boardToken]) +} + +model AiCall { + id String @id @default(uuid()) + userId String + user User @relation(fields: [userId], references: [id]) + feature String // AiUsageFeature in aiUsage.model.ts + provider String + model String + status String // AiCallStatus in aiUsage.model.ts + inputTokens Int? + outputTokens Int? + durationMs Int + firstTokenMs Int? + numCtx Int? // Ollama only, and only where the app passes num_ctx + startedAt DateTime @default(now()) + + @@index([userId, startedAt]) +} + +model Note { + id String @id @default(uuid()) + jobId String + job Job @relation(fields: [jobId], references: [id], onDelete: Cascade) + userId String + user User @relation(fields: [userId], references: [id]) + content String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([jobId]) + @@index([userId]) +} + +model Tag { + id String @id @default(uuid()) + label String + value String + createdBy String + user User @relation(fields: [createdBy], references: [id]) + jobs Job[] + questions Question[] + skills Skill[] + + @@unique([value, createdBy]) +} + +model Skill { + id String @id @default(uuid()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + category String? + order Int @default(0) + tagId String + Tag Tag @relation(fields: [tagId], references: [id]) + resumeSectionId String + ResumeSection ResumeSection @relation(fields: [resumeSectionId], references: [id]) + + @@index([resumeSectionId]) +} + +model Question { + id String @id @default(uuid()) + question String + answer String? + createdBy String + user User @relation(fields: [createdBy], references: [id]) + tags Tag[] + prepLinks JobStagePrepQuestion[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + createdVia String? // MCP provenance: which agent/token created this, mirrors Job.createdVia + + @@index([createdBy]) +} + +model McpAccessToken { + id String @id @default(uuid()) + userId String + User User @relation(fields: [userId], references: [id], onDelete: Cascade) + name String + tokenHash String @unique + tokenPrefix String + scopes String // JSON array string; new tokens are always ["jobs:write","questions:write","resume:write"] + expiresAt DateTime + lastUsedAt DateTime? + createdAt DateTime @default(now()) + + @@unique([userId, name]) + @@index([userId]) +} + +model ChatConversation { + id String @id @default(uuid()) + userId String @unique + messages String // JSON-serialized UIMessage[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + user User @relation(fields: [userId], references: [id], onDelete: Cascade) +} diff --git a/scripts/import/order.ts b/scripts/import/order.ts new file mode 100644 index 000000000..850b9d27e --- /dev/null +++ b/scripts/import/order.ts @@ -0,0 +1,105 @@ +// Table order for the one-time SQLite -> Postgres copy, derived from the +// Prisma model list so a new model cannot be silently left out. + +export interface DmmfField { + name: string; + kind: string; + type: string; + isList: boolean; + isId?: boolean; + relationName?: string | null; + relationFromFields?: readonly string[] | null; +} + +export interface DmmfModel { + name: string; + fields: readonly DmmfField[]; +} + +export interface DeferredEdge { + model: string; + field: string; +} + +export interface JoinTable { + table: string; + a: string; + b: string; +} + +export interface ImportPlan { + order: string[]; + joinTables: JoinTable[]; + deferred: DeferredEdge[]; +} + +// User -> Resume -> Profile -> User is the schema's one cycle. This edge is +// nullable, so it is copied as null and set once Resume has landed. +export const DEFERRED_EDGES: readonly DeferredEdge[] = [ + { model: "User", field: "defaultResumeId" }, +]; + +export const delegateName = (model: string) => + model[0].toLowerCase() + model.slice(1); + +const isDeferred = (model: string, from: readonly string[]) => + DEFERRED_EDGES.some((e) => e.model === model && from.includes(e.field)); + +export function buildImportPlan(models: readonly DmmfModel[]): ImportPlan { + const byName = new Map(models.map((m) => [m.name, m])); + const needs = new Map>(); + const joins = new Map(); + + for (const model of models) { + const targets = new Set(); + for (const field of model.fields) { + if (field.kind !== "object") continue; + const from = field.relationFromFields ?? []; + + if (from.length > 0) { + if (isDeferred(model.name, from)) continue; + if (field.type === model.name) { + throw new Error( + `Self-referencing foreign key on ${model.name}.${field.name} — the import cannot order it.`, + ); + } + targets.add(field.type); + continue; + } + + // A list with no FK on either side is an implicit many-to-many, kept + // by Prisma in a hidden _ table that has no client delegate. + if (!field.isList || !field.relationName) continue; + const other = byName + .get(field.type) + ?.fields.find( + (f) => + f.kind === "object" && + f.relationName === field.relationName && + f.name !== field.name, + ); + if (!other?.isList || (other.relationFromFields ?? []).length > 0) continue; + const [a, b] = [model.name, field.type].sort(); + joins.set(field.relationName, { table: `_${field.relationName}`, a, b }); + } + needs.set(model.name, targets); + } + + const order: string[] = []; + while (needs.size > 0) { + const ready = [...needs.keys()].filter((name) => + [...needs.get(name)!].every((target) => order.includes(target)), + ); + if (ready.length === 0) { + throw new Error( + `Foreign-key cycle among: ${[...needs.keys()].join(", ")}. Only User.defaultResumeId is deferred; a second cycle is a schema decision, not something the import guesses.`, + ); + } + for (const name of ready) { + order.push(name); + needs.delete(name); + } + } + + return { order, joinTables: [...joins.values()], deferred: [...DEFERRED_EDGES] }; +} diff --git a/scripts/import/sanitize.ts b/scripts/import/sanitize.ts new file mode 100644 index 000000000..2b490c60e --- /dev/null +++ b/scripts/import/sanitize.ts @@ -0,0 +1,24 @@ +// Postgres text rejects NUL and lone surrogates; SQLite TEXT and JS strings +// accept both, and PDF extraction and scraped postings produce them. +const UNSTORABLE = + /\u0000|[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?>( + row: T, + onChange: (column: string) => void, +): T { + let clean: Record | null = null; + for (const [column, value] of Object.entries(row)) { + if (typeof value !== "string") continue; + const next = sanitizeString(value); + if (next === value) continue; + clean ??= { ...row }; + clean[column] = next; + onChange(column); + } + return (clean ?? row) as T; +} diff --git a/scripts/tsconfig.json b/scripts/tsconfig.json new file mode 100644 index 000000000..7f127dee1 --- /dev/null +++ b/scripts/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "commonjs", + "moduleResolution": "node", + "lib": ["ES2022"], + "types": ["node"], + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "rootDir": ".", + "outDir": "dist" + }, + "files": ["wait-for-db.ts", "import-sqlite.ts"] +} From 15b6f0ef09f743a677f531bf311dd7d51894777e Mon Sep 17 00:00:00 2001 From: Khuram Niaz Date: Tue, 13 Oct 2026 09:31:54 -0600 Subject: [PATCH 05/14] feat(import): add the SQLite import decision table, chunked copy, orchestration and entry point Co-Authored-By: Claude Sonnet 5.5 --- __tests__/importCopy.spec.ts | 142 ++++++++++++++++++++++++++++ __tests__/importDecide.spec.ts | 119 ++++++++++++++++++++++++ __tests__/importRun.spec.ts | 165 +++++++++++++++++++++++++++++++++ scripts/import-sqlite.ts | 40 ++++++++ scripts/import/copy.ts | 128 +++++++++++++++++++++++++ scripts/import/decide.ts | 122 ++++++++++++++++++++++++ scripts/import/run.ts | 111 ++++++++++++++++++++++ 7 files changed, 827 insertions(+) create mode 100644 __tests__/importCopy.spec.ts create mode 100644 __tests__/importDecide.spec.ts create mode 100644 __tests__/importRun.spec.ts create mode 100644 scripts/import-sqlite.ts create mode 100644 scripts/import/copy.ts create mode 100644 scripts/import/decide.ts create mode 100644 scripts/import/run.ts diff --git a/__tests__/importCopy.spec.ts b/__tests__/importCopy.spec.ts new file mode 100644 index 000000000..229579925 --- /dev/null +++ b/__tests__/importCopy.spec.ts @@ -0,0 +1,142 @@ +import { copyAll, type CopyClient } from "../scripts/import/copy"; +import type { ImportPlan } from "../scripts/import/order"; + +type Row = Record & { id: string }; + +function memoryClient(tables: Record, joins: Record = {}) { + const calls: { op: string; table: string; size?: number }[] = []; + const delegate = (name: string) => ({ + findMany: async ({ take, cursor }: { take: number; cursor?: { id: string } }) => { + const rows = [...(tables[name] ?? [])].sort((x, y) => (x.id < y.id ? -1 : 1)); + const start = cursor ? rows.findIndex((r) => r.id === cursor.id) + 1 : 0; + return rows.slice(start, start + take); + }, + count: async () => (tables[name] ?? []).length, + createMany: async ({ data, skipDuplicates }: { data: Row[]; skipDuplicates: boolean }) => { + expect(skipDuplicates).toBe(true); + calls.push({ op: "createMany", table: name, size: data.length }); + const existing = (tables[name] ??= []); + for (const row of data) if (!existing.some((r) => r.id === row.id)) existing.push({ ...row }); + }, + update: async ({ where, data }: { where: { id: string }; data: Row }) => { + calls.push({ op: "update", table: name }); + Object.assign(tables[name].find((r) => r.id === where.id)!, data); + }, + }); + const client: CopyClient = new Proxy( + { + $queryRawUnsafe: async (sql: string) => { + const table = /"(_\w+)"/.exec(sql)![1]; + if (sql.startsWith("SELECT COUNT")) return [{ n: BigInt((joins[table] ?? []).length) }]; + return joins[table] ?? []; + }, + $executeRawUnsafe: async (sql: string, ...values: string[]) => { + expect(sql).toContain("ON CONFLICT DO NOTHING"); + const table = /"(_\w+)"/.exec(sql)![1]; + calls.push({ op: "insertJoin", table, size: values.length / 2 }); + const existing = (joins[table] ??= []); + for (let i = 0; i < values.length; i += 2) { + const pair = { A: values[i], B: values[i + 1] }; + if (!existing.some((p) => p.A === pair.A && p.B === pair.B)) existing.push(pair); + } + return values.length / 2; + }, + } as Record, + { get: (target, key: string) => target[key] ?? (target[key] = delegate(key)) }, + ); + return { client, tables, joins, calls }; +} + +const plan: ImportPlan = { + order: ["User", "Resume", "Tag", "Job"], + joinTables: [{ table: "_JobToTag", a: "Job", b: "Tag" }], + deferred: [{ model: "User", field: "defaultResumeId" }], +}; + +const sourceData = () => + memoryClient( + { + user: [{ id: "u1", email: "a@b.c", defaultResumeId: "r1" }, { id: "u2", email: "d@e.f", defaultResumeId: null }], + resume: [{ id: "r1", title: "CV" }], + tag: [{ id: "t1", label: "Remote" }], + job: [ + { id: "j1", description: "one" }, + { id: "j2", description: "bad\u0000byte" }, + { id: "j3", description: "three" }, + ], + }, + { _JobToTag: [{ A: "j1", B: "t1" }, { A: "j2", B: "t1" }, { A: "j3", B: "t1" }] }, + ); + +describe("copyAll", () => { + it("copies every table and join table and reports matching counts", async () => { + const source = sourceData(); + const target = memoryClient({}); + const result = await copyAll(source.client, target.client, plan, () => {}); + expect(result.tables).toEqual([ + { name: "User", source: 2, target: 2 }, + { name: "Resume", source: 1, target: 1 }, + { name: "Tag", source: 1, target: 1 }, + { name: "Job", source: 3, target: 3 }, + { name: "_JobToTag", source: 3, target: 3 }, + ]); + }); + + it("inserts the deferred column as null, then sets it after Resume lands", async () => { + const source = sourceData(); + const target = memoryClient({}); + await copyAll(source.client, target.client, plan, () => {}); + const order = target.calls.map((c) => `${c.op}:${c.table}`); + expect(order.indexOf("update:user")).toBeGreaterThan(order.indexOf("createMany:resume")); + expect(target.calls.filter((c) => c.op === "update")).toHaveLength(1); + expect(target.tables.user.find((u) => u.id === "u1")!.defaultResumeId).toBe("r1"); + expect(target.tables.user.find((u) => u.id === "u2")!.defaultResumeId).toBeNull(); + }); + + it("chunks reads, writes and join inserts by the batch size", async () => { + const source = sourceData(); + const target = memoryClient({}); + await copyAll(source.client, target.client, plan, () => {}, 2); + expect(target.calls.filter((c) => c.table === "job").map((c) => c.size)).toEqual([2, 1]); + expect(target.calls.filter((c) => c.op === "insertJoin").map((c) => c.size)).toEqual([2, 1]); + }); + + it("sanitises strings and logs each value touched", async () => { + const source = sourceData(); + const target = memoryClient({}); + const log: string[] = []; + const result = await copyAll(source.client, target.client, plan, (line) => log.push(line)); + expect(target.tables.job.find((j) => j.id === "j2")!.description).toBe("badbyte"); + expect(result.sanitized).toBe(1); + expect(log).toContain("Sanitised Job.description on row j2 (removed characters Postgres cannot store)"); + }); + + it("inserts nothing new when run a second time", async () => { + const source = sourceData(); + const target = memoryClient({}); + await copyAll(source.client, target.client, plan, () => {}); + const result = await copyAll(source.client, target.client, plan, () => {}); + expect(result.tables.every((t) => t.source === t.target)).toBe(true); + expect(target.tables.job).toHaveLength(3); + expect(target.joins._JobToTag).toHaveLength(3); + }); + + it("aborts on a count mismatch, naming the table", async () => { + const source = sourceData(); + const target = memoryClient({ tag: [{ id: "stray", label: "Left over" }] }); + await expect(copyAll(source.client, target.client, plan, () => {})).rejects.toThrow( + /Row counts differ after the copy: Tag \(1 in SQLite, 2 in Postgres\)/, + ); + }); + + it("names the model when an insert fails", async () => { + const source = sourceData(); + const target = memoryClient({}); + target.client.job.createMany = async () => { + throw new Error("Foreign key constraint violated: Job_companyId_fkey"); + }; + await expect(copyAll(source.client, target.client, plan, () => {})).rejects.toThrow( + "Import failed while copying Job: Foreign key constraint violated: Job_companyId_fkey", + ); + }); +}); diff --git a/__tests__/importDecide.spec.ts b/__tests__/importDecide.spec.ts new file mode 100644 index 000000000..7c12ee230 --- /dev/null +++ b/__tests__/importDecide.spec.ts @@ -0,0 +1,119 @@ +import fs from "fs"; +import path from "path"; +import { + LAST_SQLITE_MIGRATION, + checkLegacySchema, + decideImport, + sentinelPath, + type ImportFacts, +} from "../scripts/import/decide"; + +const facts = (over: Partial = {}): ImportFacts => ({ + legacyPath: "/data/dev.db", + legacyFileExists: true, + skipImport: false, + postgresHasUsers: false, + sentinelExists: false, + sentinelText: "", + ...over, +}); + +describe("decideImport", () => { + it("skips a fresh install with no legacy file", () => { + const d = decideImport(facts({ legacyFileExists: false })); + expect(d).toEqual({ + action: "skip", + message: "No legacy SQLite database found at /data/dev.db, skipping import.", + }); + }); + + it("imports when Postgres is empty and there is no sentinel", () => { + expect(decideImport(facts())).toEqual({ action: "import" }); + }); + + it("skips on a normal boot: data and sentinel both present", () => { + const d = decideImport(facts({ postgresHasUsers: true, sentinelExists: true })); + expect(d.action).toBe("skip"); + }); + + it("stops when the volume was lost after an import", () => { + const d = decideImport( + facts({ sentinelExists: true, sentinelText: "2026-11-02T14:02:11.000Z\n" }), + ); + expect(d.action).toBe("stop"); + const message = (d as { message: string }).message; + expect(message).toContain("dev.db was already imported on 2026-11-02 14:02"); + expect(message).toContain("./restore-db.sh "); + expect(message).toContain("delete jobsyncdb/data/dev.db.imported-at"); + expect(message).toContain("Nothing has been modified. Exiting."); + }); + + it.each(["", " \n", "not a date"])("counts sentinel text %j as present", (sentinelText) => { + const d = decideImport(facts({ sentinelExists: true, sentinelText })); + expect(d.action).toBe("stop"); + expect((d as { message: string }).message).toContain("already imported on an earlier date"); + }); + + it("stops when Postgres has data but nothing was confirmed imported", () => { + const d = decideImport(facts({ postgresHasUsers: true })); + expect(d.action).toBe("stop"); + const message = (d as { message: string }).message; + expect(message).toContain("create an empty file named"); + expect(message).toContain("jobsyncdb/data/dev.db.imported-at"); + expect(message).toContain("empty the Postgres database, restart."); + expect(message).toContain("docker volume rm jobsync_pgdata"); + expect(message).not.toContain("SKIP_IMPORT"); + }); + + it("names the stranded state when SKIP_IMPORT hides an empty database", () => { + const d = decideImport(facts({ skipImport: true })); + expect(d.action).toBe("skip"); + const message = (d as { message: string }).message; + expect(message).toContain("SKIP_IMPORT=1 is set, so the SQLite import was NOT attempted."); + expect(message).toContain("Postgres is empty and /data/dev.db exists"); + expect(message).toContain("Remove SKIP_IMPORT from .env"); + }); + + it("lets SKIP_IMPORT pass both stop states, still loudly", () => { + for (const over of [{ sentinelExists: true }, { postgresHasUsers: true }]) { + const d = decideImport(facts({ skipImport: true, ...over })); + expect(d.action).toBe("skip"); + expect((d as { message: string }).message).toContain("SKIP_IMPORT=1 is set"); + } + }); + + it("shows a custom legacy path as given", () => { + const d = decideImport( + facts({ legacyPath: "/mnt/old/app.db", sentinelExists: true }), + ); + expect((d as { message: string }).message).toContain("delete /mnt/old/app.db.imported-at"); + }); + + it("derives the sentinel beside the legacy file", () => { + expect(sentinelPath("/data/dev.db")).toBe("/data/dev.db.imported-at"); + }); +}); + +describe("checkLegacySchema", () => { + it("accepts a database that has the last SQLite migration", () => { + expect(checkLegacySchema(["20240524022540_init", LAST_SQLITE_MIGRATION])).toBeNull(); + }); + + it("refuses a database from an older release", () => { + const message = checkLegacySchema(["20240524022540_init"]); + expect(message).toContain("older JobSync release"); + expect(message).toContain(LAST_SQLITE_MIGRATION); + expect(message).toContain( + 'docker run --rm -v "$(pwd)/jobsyncdb/data:/data" -e DATABASE_URL=file:/data/dev.db ghcr.io/gsync/jobsync:1.1.22', + ); + expect(message).toContain("Nothing has been modified. Exiting."); + }); + + it("names the newest migration in the retired history", () => { + const dirs = fs + .readdirSync(path.resolve(__dirname, "../prisma/migrations-sqlite-legacy")) + .filter((name) => /^\d/.test(name)) + .sort(); + expect(LAST_SQLITE_MIGRATION).toBe(dirs[dirs.length - 1]); + }); +}); diff --git a/__tests__/importRun.spec.ts b/__tests__/importRun.spec.ts new file mode 100644 index 000000000..938d2ce5a --- /dev/null +++ b/__tests__/importRun.spec.ts @@ -0,0 +1,165 @@ +import { LAST_SQLITE_MIGRATION } from "../scripts/import/decide"; +import { runImport, type ImportDeps } from "../scripts/import/run"; + +const MODELS = [ + { name: "User", fields: [{ name: "id", kind: "scalar", type: "String", isList: false, isId: true }] }, +]; + +function harness(over: { + env?: Record; + files?: Record; + targetUsers?: number; + sourceUsers?: { id: string }[]; + migrations?: string[]; + failCopy?: boolean; + failWrite?: boolean; +} = {}) { + const files: Record = { "/data/dev.db": "sqlite", ...over.files }; + const log: string[] = []; + const targetRows: { id: string }[] = Array.from({ length: over.targetUsers ?? 0 }, (_, i) => ({ id: `t${i}` })); + const sourceRows = over.sourceUsers ?? [{ id: "u1" }, { id: "u2" }]; + let committed = false; + let opened = 0; + + const userDelegate = (rows: { id: string }[]) => ({ + count: async () => rows.length, + findMany: async ({ cursor }: { cursor?: { id: string } }) => (cursor ? [] : rows), + createMany: async ({ data }: { data: { id: string }[] }) => { + if (over.failCopy) throw new Error("Foreign key constraint violated"); + rows.push(...data); + }, + }); + + const target: any = { + user: userDelegate(targetRows), + $transaction: async (fn: (tx: any) => Promise, options: unknown) => { + expect(options).toEqual({ timeout: 300_000, maxWait: 300_000 }); + const before = [...targetRows]; + try { + const result = await fn(target); + committed = true; + return result; + } catch (error) { + targetRows.length = 0; + targetRows.push(...before); + throw error; + } + }, + }; + + const deps: ImportDeps = { + env: over.env ?? {}, + fileExists: (p) => p in files, + readText: (p) => files[p], + writeText: (p, text) => { + if (over.failWrite) throw new Error("EACCES: permission denied"); + files[p] = text; + }, + openSource: (url) => { + opened++; + expect(url).toBe(`file:${over.env?.SQLITE_LEGACY_PATH ?? "/data/dev.db"}`); + return { + user: userDelegate(sourceRows), + $queryRawUnsafe: async () => + (over.migrations ?? [LAST_SQLITE_MIGRATION]).map((migration_name) => ({ migration_name })), + $disconnect: async () => {}, + }; + }, + target, + models: MODELS, + log: (line) => log.push(line), + now: () => new Date("2026-11-02T14:02:11.000Z"), + }; + + return { deps, files, log, targetRows, opened: () => opened, committed: () => committed }; +} + +describe("runImport", () => { + it("imports, reports per table and writes the sentinel", async () => { + const h = harness(); + expect(await runImport(h.deps)).toBe(0); + expect(h.targetRows).toHaveLength(2); + expect(h.log).toContain("User: 2 → 2 ✓"); + expect(h.log.some((l) => l.startsWith("Import complete: 2 rows across 1 tables"))).toBe(true); + expect(h.files["/data/dev.db.imported-at"]).toBe("2026-11-02T14:02:11.000Z\n"); + expect(h.files["/data/dev.db"]).toBe("sqlite"); + }); + + it("skips a fresh install without opening anything", async () => { + const h = harness({ files: {} }); + delete h.files["/data/dev.db"]; + expect(await runImport(h.deps)).toBe(0); + expect(h.opened()).toBe(0); + }); + + it("is a no-op on a normal boot", async () => { + const h = harness({ targetUsers: 3, files: { "/data/dev.db.imported-at": "2026-11-02T14:02:11.000Z\n" } }); + expect(await runImport(h.deps)).toBe(0); + expect(h.opened()).toBe(0); + expect(h.targetRows).toHaveLength(3); + }); + + it("stops on a lost volume", async () => { + const h = harness({ files: { "/data/dev.db.imported-at": "2026-11-02T14:02:11.000Z\n" } }); + expect(await runImport(h.deps)).toBe(1); + expect(h.log.join("\n")).toContain("The database it was imported into appears to be missing."); + expect(h.opened()).toBe(0); + }); + + it("stops on data with no sentinel", async () => { + const h = harness({ targetUsers: 1 }); + expect(await runImport(h.deps)).toBe(1); + expect(h.log.join("\n")).toContain("never confirmed imported"); + }); + + it("boots past everything with SKIP_IMPORT, loudly", async () => { + const h = harness({ env: { SKIP_IMPORT: "1" } }); + expect(await runImport(h.deps)).toBe(0); + expect(h.log.join("\n")).toContain("has not been migrated"); + expect(h.targetRows).toHaveLength(0); + }); + + it("honours SQLITE_LEGACY_PATH for the file and the sentinel", async () => { + const h = harness({ env: { SQLITE_LEGACY_PATH: "/mnt/old.db" }, files: { "/mnt/old.db": "sqlite" } }); + expect(await runImport(h.deps)).toBe(0); + expect(h.files["/mnt/old.db.imported-at"]).toBeDefined(); + }); + + it("stops on an older legacy schema", async () => { + const h = harness({ migrations: ["20240524022540_init"] }); + expect(await runImport(h.deps)).toBe(1); + expect(h.log.join("\n")).toContain("older JobSync release"); + expect(h.targetRows).toHaveLength(0); + expect(h.files["/data/dev.db.imported-at"]).toBeUndefined(); + }); + + it("writes no sentinel when the copy fails", async () => { + const h = harness({ failCopy: true }); + expect(await runImport(h.deps)).toBe(1); + expect(h.targetRows).toHaveLength(0); + expect(h.files["/data/dev.db.imported-at"]).toBeUndefined(); + const text = h.log.join("\n"); + expect(text).toContain("Import failed while copying User"); + expect(text).toContain("Postgres was left empty and dev.db was not modified."); + }); + + it("dry run rolls back and exits 2", async () => { + const h = harness({ env: { IMPORT_DRY_RUN: "1" } }); + expect(await runImport(h.deps)).toBe(2); + expect(h.targetRows).toHaveLength(0); + expect(h.committed()).toBe(false); + expect(h.files["/data/dev.db.imported-at"]).toBeUndefined(); + const text = h.log.join("\n"); + expect(text).toContain("User: 2 → 2 ✓"); + expect(text).toContain("Dry run complete — nothing was written."); + }); + + it("reports an unwritable sentinel", async () => { + const h = harness({ failWrite: true }); + expect(await runImport(h.deps)).toBe(1); + expect(h.targetRows).toHaveLength(2); + const text = h.log.join("\n"); + expect(text).toContain("The import succeeded, but its record could not be written"); + expect(text).toContain("jobsyncdb/data/dev.db.imported-at"); + }); +}); diff --git a/scripts/import-sqlite.ts b/scripts/import-sqlite.ts new file mode 100644 index 000000000..ed663912b --- /dev/null +++ b/scripts/import-sqlite.ts @@ -0,0 +1,40 @@ +import fs from "node:fs"; +import path from "node:path"; +import { PrismaClient } from "@prisma/client"; +import type * as Snapshot from "../src/generated/sqlite-snapshot"; +import { runImport } from "./import/run"; + +// Resolved from the working directory, not this file: the compiled copy in +// scripts/dist sits one level deeper than this source does. +const snapshot: typeof Snapshot = require( + path.resolve(process.cwd(), "src/generated/sqlite-snapshot"), +); + +async function main(): Promise { + const target = new PrismaClient(); + try { + return await runImport({ + env: process.env, + fileExists: (file) => fs.existsSync(file), + readText: (file) => fs.readFileSync(file, "utf8"), + writeText: (file, text) => fs.writeFileSync(file, text), + openSource: (url) => new snapshot.PrismaClient({ datasources: { db: { url } } }), + target, + // The snapshot's model list, so the copy never asks the old database + // for a model that only the newer Postgres schema has. + models: snapshot.Prisma.dmmf.datamodel.models, + log: (line) => console.log(line), + now: () => new Date(), + }); + } finally { + await target.$disconnect(); + } +} + +main().then( + (code) => process.exit(code), + (error) => { + console.error(error instanceof Error ? error.message : error); + process.exit(1); + }, +); diff --git a/scripts/import/copy.ts b/scripts/import/copy.ts new file mode 100644 index 000000000..0c1433c5b --- /dev/null +++ b/scripts/import/copy.ts @@ -0,0 +1,128 @@ +import { delegateName, type ImportPlan } from "./order"; +import { sanitizeRow } from "./sanitize"; + +// Postgres caps one statement at 32767 bind parameters; Job has ~35 columns, +// so 500 rows stays well under it. Do not raise this to 1000. +export const IMPORT_BATCH_SIZE = 500; + +// Structural on purpose: the real argument is a Prisma client or transaction +// client, and this module must not import either generated client. +export type CopyClient = Record; + +export interface CopyResult { + tables: { name: string; source: number; target: number }[]; + sanitized: number; +} + +type Row = Record & { id: string }; + +const reason = (error: unknown) => + error instanceof Error ? error.message : String(error); + +const joinCount = async (client: CopyClient, table: string) => { + const rows = await client.$queryRawUnsafe(`SELECT COUNT(*) AS n FROM "${table}"`); + return Number(rows[0].n); +}; + +export async function copyAll( + source: CopyClient, + target: CopyClient, + plan: ImportPlan, + log: (line: string) => void, + batchSize: number = IMPORT_BATCH_SIZE, +): Promise { + let sanitized = 0; + const pending: { model: string; id: string; field: string; value: unknown }[] = []; + + for (const model of plan.order) { + const key = delegateName(model); + const deferred = plan.deferred.filter((d) => d.model === model).map((d) => d.field); + let cursor: string | undefined; + + for (;;) { + const rows: Row[] = await source[key].findMany({ + take: batchSize, + orderBy: { id: "asc" }, + ...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}), + }); + if (rows.length === 0) break; + cursor = rows[rows.length - 1].id; + + const data = rows.map((row) => { + const clean = sanitizeRow(row, (column) => { + sanitized++; + log(`Sanitised ${model}.${column} on row ${row.id} (removed characters Postgres cannot store)`); + }); + if (deferred.length === 0) return clean; + const copy: Row = { ...clean }; + for (const field of deferred) { + if (copy[field] != null) pending.push({ model, id: row.id, field, value: copy[field] }); + copy[field] = null; + } + return copy; + }); + + try { + await target[key].createMany({ data, skipDuplicates: true }); + } catch (error) { + throw new Error(`Import failed while copying ${model}: ${reason(error)}`); + } + if (rows.length < batchSize) break; + } + } + + for (const edge of pending) { + await target[delegateName(edge.model)].update({ + where: { id: edge.id }, + data: { [edge.field]: edge.value }, + }); + } + + // Implicit many-to-many tables have no delegate, so they go through raw SQL + // and must carry ON CONFLICT themselves to stay re-runnable. + for (const join of plan.joinTables) { + const pairs: { A: string; B: string }[] = await source.$queryRawUnsafe( + `SELECT "A", "B" FROM "${join.table}"`, + ); + for (let i = 0; i < pairs.length; i += batchSize) { + const chunk = pairs.slice(i, i + batchSize); + const values = chunk.map((_, n) => `($${n * 2 + 1}, $${n * 2 + 2})`).join(", "); + try { + await target.$executeRawUnsafe( + `INSERT INTO "${join.table}" ("A", "B") VALUES ${values} ON CONFLICT DO NOTHING`, + ...chunk.flatMap((pair) => [pair.A, pair.B]), + ); + } catch (error) { + throw new Error(`Import failed while copying ${join.table}: ${reason(error)}`); + } + } + } + + // ON CONFLICT can only hide a conflict, so the counts are what make it safe + const tables: CopyResult["tables"] = []; + for (const model of plan.order) { + const key = delegateName(model); + tables.push({ + name: model, + source: await source[key].count(), + target: await target[key].count(), + }); + } + for (const join of plan.joinTables) { + tables.push({ + name: join.table, + source: await joinCount(source, join.table), + target: await joinCount(target, join.table), + }); + } + + const wrong = tables.filter((t) => t.source !== t.target); + if (wrong.length > 0) { + const detail = wrong + .map((t) => `${t.name} (${t.source} in SQLite, ${t.target} in Postgres)`) + .join(", "); + throw new Error(`Row counts differ after the copy: ${detail}`); + } + + return { tables, sanitized }; +} diff --git a/scripts/import/decide.ts b/scripts/import/decide.ts new file mode 100644 index 000000000..2612b4244 --- /dev/null +++ b/scripts/import/decide.ts @@ -0,0 +1,122 @@ +// The whole import decision is two facts: is Postgres empty, and was this +// dev.db already imported. No mtime comparison (spec section 4). + +// Newest directory in prisma/migrations-sqlite-legacy; a spec pins it there +export const LAST_SQLITE_MIGRATION = "20261002141429_company_notes_size_linkedin"; +// The image tag of the last release that still ran on SQLite +const LAST_SQLITE_IMAGE = "ghcr.io/gsync/jobsync:1.1.22"; + +export interface ImportFacts { + legacyPath: string; + legacyFileExists: boolean; + skipImport: boolean; + postgresHasUsers: boolean; + sentinelExists: boolean; + sentinelText: string; +} + +export type ImportDecision = + | { action: "import" } + | { action: "skip"; message: string } + | { action: "stop"; message: string }; + +export const sentinelPath = (legacyPath: string) => `${legacyPath}.imported-at`; + +// Operators see the bind mount, not the container path +const hostPath = (containerPath: string) => + containerPath.startsWith("/data/") + ? `jobsyncdb/data/${containerPath.slice("/data/".length)}` + : containerPath; + +const importedOn = (sentinelText: string) => { + const text = sentinelText.trim(); + if (!text || Number.isNaN(Date.parse(text))) return "an earlier date"; + return new Date(text).toISOString().slice(0, 16).replace("T", " "); +}; + +const NOTHING_MODIFIED = "Nothing has been modified. Exiting."; + +export function decideImport(facts: ImportFacts): ImportDecision { + const { legacyPath } = facts; + const sentinel = hostPath(sentinelPath(legacyPath)); + + if (!facts.legacyFileExists) { + return { + action: "skip", + message: `No legacy SQLite database found at ${legacyPath}, skipping import.`, + }; + } + + if (facts.skipImport) { + const lines = ["SKIP_IMPORT=1 is set, so the SQLite import was NOT attempted."]; + if (!facts.postgresHasUsers) { + lines.push( + `Postgres is empty and ${legacyPath} exists — your existing data`, + "has not been migrated and the app is starting with no data.", + ); + } + lines.push("", "Remove SKIP_IMPORT from .env and restart once the cause is fixed."); + return { action: "skip", message: lines.join("\n") }; + } + + if (facts.postgresHasUsers && facts.sentinelExists) { + return { action: "skip", message: "SQLite data was already imported, skipping import." }; + } + + if (!facts.postgresHasUsers && facts.sentinelExists) { + return { + action: "stop", + message: [ + `Postgres is empty, but dev.db was already imported on ${importedOn(facts.sentinelText)}.`, + "The database it was imported into appears to be missing.", + "", + "To restore a backup: ./restore-db.sh ", + " (dumps are in jobsyncdb/data/backups/)", + "To re-import the old SQLite data as it was on that date:", + ` delete ${sentinel}, restart.`, + "", + NOTHING_MODIFIED, + ].join("\n"), + }; + } + + if (facts.postgresHasUsers) { + return { + action: "stop", + message: [ + "Postgres already contains data, but dev.db has no import record —", + "its contents were never confirmed imported.", + "", + "To keep the Postgres data: create an empty file named", + ` ${sentinel}, restart.`, + "To use the SQLite data: empty the Postgres database, restart. For the", + " bundled database: docker compose down, then", + " docker volume rm jobsync_pgdata.", + "", + NOTHING_MODIFIED, + ].join("\n"), + }; + } + + return { action: "import" }; +} + +// The snapshot client describes the last SQLite schema only, so a dev.db +// from an older release would fail mid-copy with an opaque column error. +export function checkLegacySchema(appliedMigrations: readonly string[]): string | null { + if (appliedMigrations.includes(LAST_SQLITE_MIGRATION)) return null; + return [ + "This dev.db comes from an older JobSync release and cannot be imported directly.", + `It is missing the migration ${LAST_SQLITE_MIGRATION}.`, + "", + "Bring it up to date by starting the last SQLite release once. From the", + "folder that holds docker-compose.yml, with JobSync stopped, run:", + "", + ` docker run --rm -v "$(pwd)/jobsyncdb/data:/data" -e DATABASE_URL=file:/data/dev.db ${LAST_SQLITE_IMAGE}`, + "", + "Wait for its server to report it is ready, press Ctrl+C, then start JobSync", + "again. That command lets the old release migrate dev.db; this one never does.", + "", + NOTHING_MODIFIED, + ].join("\n"); +} diff --git a/scripts/import/run.ts b/scripts/import/run.ts new file mode 100644 index 000000000..9fc3a7eab --- /dev/null +++ b/scripts/import/run.ts @@ -0,0 +1,111 @@ +import { copyAll, type CopyClient, type CopyResult } from "./copy"; +import { checkLegacySchema, decideImport, sentinelPath } from "./decide"; +import { buildImportPlan, type DmmfModel } from "./order"; + +export interface ImportDeps { + env: Record; + fileExists(path: string): boolean; + readText(path: string): string; + writeText(path: string, text: string): void; + openSource(url: string): CopyClient; + target: CopyClient; + models: readonly DmmfModel[]; + log(line: string): void; + now(): Date; +} + +const DEFAULT_LEGACY_PATH = "/data/dev.db"; +// One transaction holds the whole copy, well past Prisma's 5s default +const TRANSACTION_OPTIONS = { timeout: 300_000, maxWait: 300_000 }; + +class DryRunRollback extends Error { + constructor(readonly result: CopyResult) { + super("dry run"); + } +} + +const reason = (error: unknown) => + error instanceof Error ? error.message : String(error); + +function report(result: CopyResult, log: (line: string) => void) { + for (const table of result.tables) { + log(`${table.name}: ${table.source} → ${table.target} ✓`); + } + const rows = result.tables.reduce((sum, t) => sum + t.target, 0); + log( + `Import complete: ${rows} rows across ${result.tables.length} tables, ${result.sanitized} values sanitised.`, + ); +} + +// Returns the process exit code: 0 continue to boot, 1 stop, 2 dry run done +export async function runImport(deps: ImportDeps): Promise { + const { env, log } = deps; + const legacyPath = env.SQLITE_LEGACY_PATH || DEFAULT_LEGACY_PATH; + const sentinel = sentinelPath(legacyPath); + const legacyFileExists = deps.fileExists(legacyPath); + const sentinelExists = legacyFileExists && deps.fileExists(sentinel); + + const decision = decideImport({ + legacyPath, + legacyFileExists, + skipImport: env.SKIP_IMPORT === "1", + postgresHasUsers: legacyFileExists ? (await deps.target.user.count()) > 0 : false, + sentinelExists, + sentinelText: sentinelExists ? deps.readText(sentinel) : "", + }); + + if (decision.action !== "import") { + log(decision.message); + return decision.action === "skip" ? 0 : 1; + } + + const source = deps.openSource(`file:${legacyPath}`); + try { + const applied: { migration_name: string }[] = await source.$queryRawUnsafe( + 'SELECT migration_name FROM "_prisma_migrations" WHERE finished_at IS NOT NULL', + ); + const refusal = checkLegacySchema(applied.map((row) => row.migration_name)); + if (refusal) { + log(refusal); + return 1; + } + + const dryRun = env.IMPORT_DRY_RUN === "1"; + const plan = buildImportPlan(deps.models); + log(`Importing ${legacyPath} into Postgres${dryRun ? " (dry run)" : ""}…`); + + let result: CopyResult; + try { + result = await deps.target.$transaction(async (tx: CopyClient) => { + const copied = await copyAll(source, tx, plan, log); + if (dryRun) throw new DryRunRollback(copied); + return copied; + }, TRANSACTION_OPTIONS); + } catch (error) { + if (error instanceof DryRunRollback) { + report(error.result, log); + log("Dry run complete — nothing was written. Remove IMPORT_DRY_RUN from .env and restart to import for real."); + return 2; + } + log(reason(error)); + log("Postgres was left empty and dev.db was not modified. Fix the cause and restart to retry, or set SKIP_IMPORT=1 once to start the app without importing."); + return 1; + } + + report(result, log); + + try { + deps.writeText(sentinel, `${deps.now().toISOString()}\n`); + } catch (error) { + const shown = sentinel.startsWith("/data/") + ? `jobsyncdb/data/${sentinel.slice("/data/".length)}` + : sentinel; + log(`The import succeeded, but its record could not be written (${reason(error)}).`); + log(`Create an empty file named ${shown} and restart. Without it the next start cannot tell that dev.db was already imported.`); + return 1; + } + return 0; + } finally { + await source.$disconnect?.(); + } +} From ace36f76a6135892bb5232c1b6ed2596cb98bd3b Mon Sep 17 00:00:00 2001 From: Khuram Niaz Date: Tue, 13 Oct 2026 09:43:44 -0600 Subject: [PATCH 06/14] feat(boot): add the wait-for-db preflight with bounded retry, database auto-create and credential-safe failure messages Co-Authored-By: Claude Sonnet 5.5 --- __tests__/waitForDb.spec.ts | 210 ++++++++++++++++++++++++++++++++++++ scripts/wait-for-db.ts | 41 +++++++ scripts/wait/classify.ts | 136 +++++++++++++++++++++++ scripts/wait/run.ts | 92 ++++++++++++++++ 4 files changed, 479 insertions(+) create mode 100644 __tests__/waitForDb.spec.ts create mode 100644 scripts/wait-for-db.ts create mode 100644 scripts/wait/classify.ts create mode 100644 scripts/wait/run.ts diff --git a/__tests__/waitForDb.spec.ts b/__tests__/waitForDb.spec.ts new file mode 100644 index 000000000..0a884e0d8 --- /dev/null +++ b/__tests__/waitForDb.spec.ts @@ -0,0 +1,210 @@ +import { checkUrl, classify, maintenanceUrl, quoteIdent, UPGRADE_GUIDE_URL } from "../scripts/wait/classify"; +import { waitForDb, type WaitDeps } from "../scripts/wait/run"; + +const SECRET = "s3cr@t:pa/ss#word"; +const URL_OK = `postgres://jobsync:${encodeURIComponent(SECRET)}@db.example.internal:5433/jobsync`; + +const failure = (errorCode?: string, message = "boom") => Object.assign(new Error(message), { errorCode }); + +function harness(over: { + url?: string; + env?: Record; + attempts?: (Error | null)[]; + createFails?: Error; + dbResolves?: boolean; +} = {}) { + const log: string[] = []; + const attempts = [...(over.attempts ?? [null])]; + let clock = 0; + const created: { url: string; name: string }[] = []; + let connects = 0; + const deps: WaitDeps = { + env: { DATABASE_URL: "url" in over ? over.url : URL_OK, ...over.env }, + connect: async () => { + connects++; + const next = attempts.length > 1 ? attempts.shift() : attempts[0]; + if (next) throw next; + }, + createDatabase: async (url, name) => { + if (over.createFails) throw over.createFails; + created.push({ url, name }); + }, + resolves: async () => over.dbResolves ?? true, + sleep: async (ms) => { + clock += ms; + }, + now: () => clock, + log: (line) => log.push(line), + }; + return { deps, log, created, connects: () => connects, text: () => log.join("\n") }; +} + +describe("checkUrl", () => { + it("parses host, port and database", () => { + expect(checkUrl(URL_OK)).toEqual({ + ok: true, + target: { host: "db.example.internal", port: "5433", database: "jobsync" }, + }); + }); + + it("defaults the port and accepts postgresql://", () => { + expect(checkUrl("postgresql://u:p@host/app")).toEqual({ + ok: true, + target: { host: "host", port: "5432", database: "app" }, + }); + }); + + it.each([undefined, "", " "])("reports %j as not set", (raw) => { + expect(checkUrl(raw)).toEqual({ ok: false, message: "DATABASE_URL is not set." }); + }); + + it.each(["file:/data/dev.db", "file:./dev.db"])("names the SQLite url %s as found", (raw) => { + const result = checkUrl(raw) as { ok: false; message: string }; + expect(result.ok).toBe(false); + expect(result.message).toContain(`DATABASE_URL still points at SQLite (${raw}).`); + expect(result.message).toContain(UPGRADE_GUIDE_URL); + expect(result.message).toContain("Your existing database has NOT been modified."); + }); + + it.each(["not a url", "mysql://u:p@h/db", "postgres://u:p@host", "postgres://u:p@/db"])( + "reports %s as malformed", + (raw) => { + const result = checkUrl(raw) as { ok: false; message: string }; + expect(result.message).toContain("DATABASE_URL is malformed."); + }, + ); +}); + +describe("classify", () => { + it.each([ + ["P1000", "auth"], + ["P1003", "missing-database"], + ["P1012", "bad-url"], + ["P1001", "retry"], + ["P1002", "retry"], + ["P1017", "retry"], + ["P9999", "retry"], + [undefined, "retry"], + ])("%s -> %s", (code, verdict) => { + expect(classify(code)).toBe(verdict); + }); +}); + +describe("helpers", () => { + it("points the maintenance url at postgres, keeping credentials and params", () => { + expect(maintenanceUrl("postgres://u:p@host:5433/app?sslmode=require")).toBe( + "postgres://u:p@host:5433/postgres?sslmode=require", + ); + }); + + it("quotes an identifier", () => { + expect(quoteIdent('job"sync')).toBe('"job""sync"'); + }); +}); + +describe("waitForDb", () => { + it("returns 0 on a first-try connection", async () => { + const h = harness(); + expect(await waitForDb(h.deps)).toBe(0); + expect(h.text()).toContain("Database reachable at db.example.internal:5433/jobsync."); + }); + + it("retries a starting database and then succeeds", async () => { + const h = harness({ attempts: [failure("P1001"), failure("P1002"), null] }); + expect(await waitForDb(h.deps)).toBe(0); + expect(h.connects()).toBe(3); + expect(h.text()).toContain("waiting for database at db.example.internal:5433"); + }); + + it("retries an unknown error code within the window, then gives up", async () => { + const h = harness({ attempts: [failure("P7777")] }); + expect(await waitForDb(h.deps)).toBe(1); + expect(h.connects()).toBeGreaterThan(5); + expect(h.text()).toContain("Could not reach the database at db.example.internal:5433 within 60 seconds."); + }); + + it("exits at once on an auth failure, with the external causes", async () => { + const h = harness({ attempts: [failure("P1000")] }); + expect(await waitForDb(h.deps)).toBe(1); + expect(h.connects()).toBe(1); + expect(h.text()).toContain("percent-encoded"); + expect(h.text()).toContain("pg_hba.conf"); + }); + + it("names password rotation for the bundled database", async () => { + const h = harness({ url: "postgres://jobsync:jobsync@db:5432/jobsync", attempts: [failure("P1000")] }); + expect(await waitForDb(h.deps)).toBe(1); + expect(h.text()).toContain("POSTGRES_PASSWORD was changed after the database volume was created"); + }); + + it("exits at once when the url is not set, is SQLite or is malformed", async () => { + for (const url of [undefined, "file:/data/dev.db", "nonsense"]) { + const h = harness({ url }); + expect(await waitForDb(h.deps)).toBe(1); + expect(h.connects()).toBe(0); + } + }); + + it("creates a missing database, says so, and continues", async () => { + const h = harness({ attempts: [failure("P1003"), null] }); + expect(await waitForDb(h.deps)).toBe(0); + expect(h.created).toEqual([ + { url: `postgres://jobsync:${encodeURIComponent(SECRET)}@db.example.internal:5433/postgres`, name: '"jobsync"' }, + ]); + expect(h.text()).toContain('Database "jobsync" did not exist on db.example.internal:5433 — created it.'); + expect(h.text()).toContain("a typo in the database"); + }); + + it("falls back to the manual SQL when creation is refused", async () => { + const h = harness({ + attempts: [failure("P1003")], + createFails: new Error("permission denied to create database"), + }); + expect(await waitForDb(h.deps)).toBe(1); + const text = h.text(); + expect(text).toContain("could not create it: permission denied to create database"); + expect(text).toContain("CREATE DATABASE jobsync OWNER ;"); + expect(text).toContain("GRANT ALL ON SCHEMA public TO ;"); + }); + + it("does not create when CREATE_DATABASE_IF_MISSING=0", async () => { + const h = harness({ attempts: [failure("P1003")], env: { CREATE_DATABASE_IF_MISSING: "0" } }); + expect(await waitForDb(h.deps)).toBe(1); + expect(h.created).toEqual([]); + expect(h.text()).toContain("CREATE DATABASE jobsync OWNER ;"); + }); + + it("does not loop when the database is still missing after creating it", async () => { + const h = harness({ attempts: [failure("P1003")] }); + expect(await waitForDb(h.deps)).toBe(1); + expect(h.created).toHaveLength(1); + }); + + it("explains an unresolvable bundled host", async () => { + const h = harness({ + url: "postgres://jobsync:jobsync@db:5432/jobsync", + attempts: [failure("P1001")], + dbResolves: false, + }); + expect(await waitForDb(h.deps)).toBe(1); + expect(h.text()).toContain('DATABASE_URL points at host "db", but no such host exists'); + expect(h.text()).toContain("BUNDLED_DB=0"); + }); + + it("never logs the password", async () => { + const cases = [ + { attempts: [failure("P1000")] }, + { attempts: [failure("P1012")] }, + { attempts: [failure("P1001", `Can't reach ${URL_OK}`)] }, + { attempts: [failure("P1003")], createFails: new Error(`denied for ${URL_OK}`) }, + { attempts: [failure("P1003"), null] }, + { url: `postgres://jobsync:${SECRET}@host/db`, attempts: [failure("P1000")] }, + ]; + for (const c of cases) { + const h = harness(c); + await waitForDb(h.deps); + expect(h.text()).not.toContain(SECRET); + expect(h.text()).not.toContain(encodeURIComponent(SECRET)); + } + }); +}); diff --git a/scripts/wait-for-db.ts b/scripts/wait-for-db.ts new file mode 100644 index 000000000..632d00b92 --- /dev/null +++ b/scripts/wait-for-db.ts @@ -0,0 +1,41 @@ +import { lookup } from "node:dns/promises"; +import { PrismaClient } from "@prisma/client"; +import { waitForDb } from "./wait/run"; + +const client = (url: string) => new PrismaClient({ datasources: { db: { url } } }); + +waitForDb({ + env: process.env, + // $connect(), never a throwaway query: a lazy first query loses errorCode + connect: async (url) => { + const prisma = client(url); + try { + await prisma.$connect(); + } finally { + await prisma.$disconnect().catch(() => {}); + } + }, + // One standalone statement: CREATE DATABASE cannot run in a transaction + createDatabase: async (url, quotedName) => { + const prisma = client(url); + try { + await prisma.$executeRawUnsafe(`CREATE DATABASE ${quotedName}`); + } finally { + await prisma.$disconnect().catch(() => {}); + } + }, + resolves: (host) => + lookup(host).then( + () => true, + () => false, + ), + sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)), + now: () => Date.now(), + log: (line) => console.log(line), +}).then( + (code) => process.exit(code), + (error) => { + console.error(error instanceof Error ? error.message : error); + process.exit(1); + }, +); diff --git a/scripts/wait/classify.ts b/scripts/wait/classify.ts new file mode 100644 index 000000000..e8df605b7 --- /dev/null +++ b/scripts/wait/classify.ts @@ -0,0 +1,136 @@ +// toWikiPageName capitalises every word of the file name, "To" included +export const UPGRADE_GUIDE_URL = "https://github.com/Gsync/jobsync/wiki/Upgrading-To-Postgres"; + +export interface Target { + host: string; + port: string; + database: string; +} + +export type UrlCheck = { ok: true; target: Target } | { ok: false; message: string }; + +const MALFORMED = + "DATABASE_URL is malformed. Expected postgres://user:password@host:5432/database — a password containing @ : / or # must be percent-encoded."; + +export function checkUrl(raw: string | undefined): UrlCheck { + if (!raw || raw.trim() === "") return { ok: false, message: "DATABASE_URL is not set." }; + + // Checked before Prisma sees it: P1012 covers this and every other bad + // string, and an upgrading operator needs the specific message. + if (raw.startsWith("file:")) { + return { + ok: false, + message: [ + `DATABASE_URL still points at SQLite (${raw}).`, + `This release requires Postgres — see ${UPGRADE_GUIDE_URL}.`, + "Delete the DATABASE_URL line from .env to use the bundled database.", + "Your existing database has NOT been modified.", + ].join("\n"), + }; + } + + let url: URL; + try { + url = new URL(raw); + } catch { + return { ok: false, message: MALFORMED }; + } + const database = decodeURIComponent(url.pathname.replace(/^\//, "")); + if (!/^postgres(ql)?:$/.test(url.protocol) || !url.hostname || !database) { + return { ok: false, message: MALFORMED }; + } + return { ok: true, target: { host: url.hostname, port: url.port || "5432", database } }; +} + +export type Verdict = "retry" | "auth" | "missing-database" | "bad-url"; + +// Anything unlisted retries: the window is bounded, so an unknown code still +// ends in a non-zero exit, only with a coherent log. +export function classify(errorCode: string | undefined): Verdict { + switch (errorCode) { + case "P1000": + return "auth"; + case "P1003": + return "missing-database"; + case "P1012": + return "bad-url"; + default: + return "retry"; + } +} + +export function maintenanceUrl(raw: string): string { + const url = new URL(raw); + url.pathname = "/postgres"; + return url.toString(); +} + +export const quoteIdent = (name: string) => `"${name.replace(/"/g, '""')}"`; + +// Driver messages can echo the connection string +export function redact(message: string, raw: string): string { + let password = ""; + try { + password = new URL(raw).password; + } catch { + return message; + } + if (!password) return message; + return message + .split(password) + .join("***") + .split(decodeURIComponent(password)) + .join("***"); +} + +export const MALFORMED_MESSAGE = MALFORMED; + +export function authMessage(target: Target): string { + const head = `Authentication failed for the database at ${target.host}:${target.port}.`; + if (target.host === "db") { + return [ + head, + "", + "The most likely cause is that POSTGRES_PASSWORD was changed after the database volume was created.", + "That variable only applies the first time the volume is initialised. Either change it back, or run", + "ALTER USER jobsync PASSWORD '' inside the jobsync_db container.", + ].join("\n"); + } + return [ + head, + "", + "Check DATABASE_URL: a password containing @ : / or # must be percent-encoded.", + "On a self-managed server, also check pg_hba.conf has an entry for this host's address.", + ].join("\n"); +} + +export function createdMessage(target: Target): string { + return [ + `Database "${target.database}" did not exist on ${target.host}:${target.port} — created it.`, + "If that name looks wrong, check DATABASE_URL: a typo in the database", + "name will silently produce a new, empty database rather than an error.", + ].join("\n"); +} + +export function missingDatabaseMessage(target: Target, why: string | null): string { + const head = why + ? [`Database "${target.database}" does not exist on ${target.host}:${target.port}, and JobSync`, `could not create it: ${why}`] + : [`Database "${target.database}" does not exist on ${target.host}:${target.port}.`]; + return [ + ...head, + "", + "Create it manually with:", + ` CREATE DATABASE ${target.database} OWNER ;`, + "", + "On Postgres 15+, if the role does not own the database, also:", + " GRANT ALL ON SCHEMA public TO ;", + ].join("\n"); +} + +export const BUNDLED_HOST_MESSAGE = [ + 'DATABASE_URL points at host "db", but no such host exists on the', + "Docker network — the bundled database container is not running.", + "", + "If BUNDLED_DB=0 is set in your .env, either remove it (to use the", + "bundled database) or set DATABASE_URL to your own Postgres.", +].join("\n"); diff --git a/scripts/wait/run.ts b/scripts/wait/run.ts new file mode 100644 index 000000000..ecd02ea3d --- /dev/null +++ b/scripts/wait/run.ts @@ -0,0 +1,92 @@ +import { + BUNDLED_HOST_MESSAGE, + MALFORMED_MESSAGE, + authMessage, + checkUrl, + classify, + createdMessage, + maintenanceUrl, + missingDatabaseMessage, + quoteIdent, + redact, +} from "./classify"; + +export interface WaitDeps { + env: Record; + // Must reject with the driver error untouched: errorCode is only present + // when the failure came from $connect(), never from a lazy first query. + connect(url: string): Promise; + createDatabase(maintenanceUrl: string, quotedName: string): Promise; + resolves(host: string): Promise; + sleep(ms: number): Promise; + now(): number; + log(line: string): void; +} + +const WAIT_WINDOW_MS = 60_000; +const MAX_BACKOFF_MS = 5_000; + +const reason = (error: unknown, raw: string) => + redact(error instanceof Error ? error.message : String(error), raw); + +// Returns the process exit code +export async function waitForDb(deps: WaitDeps): Promise { + const raw = deps.env.DATABASE_URL; + const checked = checkUrl(raw); + if (!checked.ok) { + deps.log(checked.message); + return 1; + } + const url = raw as string; + const { target } = checked; + const where = `${target.host}:${target.port}`; + const deadline = deps.now() + WAIT_WINDOW_MS; + let attempt = 0; + let created = false; + + for (;;) { + try { + await deps.connect(url); + deps.log(`Database reachable at ${where}/${target.database}.`); + return 0; + } catch (error) { + const verdict = classify((error as { errorCode?: string }).errorCode); + + if (verdict === "auth") { + deps.log(authMessage(target)); + return 1; + } + if (verdict === "bad-url") { + deps.log(MALFORMED_MESSAGE); + return 1; + } + if (verdict === "missing-database") { + if (created || deps.env.CREATE_DATABASE_IF_MISSING === "0") { + deps.log(missingDatabaseMessage(target, null)); + return 1; + } + try { + await deps.createDatabase(maintenanceUrl(url), quoteIdent(target.database)); + } catch (createError) { + deps.log(missingDatabaseMessage(target, reason(createError, url))); + return 1; + } + created = true; + deps.log(createdMessage(target)); + continue; + } + + if (deps.now() >= deadline) { + if (target.host === "db" && !(await deps.resolves("db"))) { + deps.log(BUNDLED_HOST_MESSAGE); + } else { + deps.log(`Could not reach the database at ${where} within 60 seconds. Last error: ${reason(error, url)}`); + } + return 1; + } + attempt++; + deps.log(`waiting for database at ${where}… (attempt ${attempt})`); + await deps.sleep(Math.min(attempt * 1000, MAX_BACKOFF_MS)); + } + } +} From 8145c9af6cb26dde91f3ad1898443d5059d6620b Mon Sep 17 00:00:00 2001 From: Khuram Niaz Date: Wed, 14 Oct 2026 16:04:54 -0600 Subject: [PATCH 07/14] feat(docker): bundle Postgres 18 as the default database, boot through wait-for-db and the one-time import, build both Prisma clients into the image and guard deploys against a locally edited compose file Co-Authored-By: Claude Sonnet 5.5 --- .env.example | 10 ++++++++- Dockerfile | 13 +++++++++--- deploy.ps1 | 14 +++++++++++++ deploy.sh | 21 +++++++++++++++---- docker-compose.lan-db.yml | 8 ++++++++ docker-compose.yml | 43 +++++++++++++++++++++++++++++++++++++-- docker-entrypoint.sh | 9 +++++++- 7 files changed, 107 insertions(+), 11 deletions(-) create mode 100644 docker-compose.lan-db.yml diff --git a/.env.example b/.env.example index b86a2105d..317b5d18d 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,14 @@ # Note: Copy these variables in your .env file -# DATABASE_URL=file:./dev.db +# Database. The bundled Postgres runs automatically and needs no +# configuration — it is not exposed outside the Docker network. +# +# To use your own Postgres instead, uncomment and set both: +# DATABASE_URL=postgres://user:pass@db.example.internal:5432/jobsync +# BUNDLED_DB=0 +# +# For local development (npm run dev) DATABASE_URL must be set: point it at +# any Postgres you can reach. See CONTRIBUTING.md. # If you are running it on a remote server/homelab, you must update timezone otherwise activities times may shift TZ=America/Edmonton diff --git a/Dockerfile b/Dockerfile index 5388e1ecc..9da2d488b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -17,10 +17,13 @@ WORKDIR /app COPY --from=deps /app/node_modules ./node_modules COPY . . -ENV DATABASE_URL=file:/data/dev.db +# Two clients: the Postgres runtime client, and the frozen SQLite snapshot +# client that only the one-time import reads. Neither needs DATABASE_URL. +RUN npx -y prisma@6.19.0 generate \ + && npx -y prisma@6.19.0 generate --schema=prisma/schema.sqlite-snapshot.prisma -# Generate Prisma client -RUN npx prisma generate +# Boot scripts (wait-for-db, import-sqlite), compiled to plain CommonJS +RUN npx tsc -p scripts/tsconfig.json RUN npm run build @@ -45,6 +48,10 @@ COPY --from=builder /app/public ./public COPY --from=builder /app/prisma ./prisma COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static +COPY --from=builder /app/scripts/dist ./scripts/dist +# Nothing in the app imports the snapshot client, so Next's standalone trace +# never includes it or its SQLite engine binary: copy it explicitly. +COPY --from=builder /app/src/generated/sqlite-snapshot ./src/generated/sqlite-snapshot COPY docker-entrypoint.sh /app/docker-entrypoint.sh RUN chmod +x /app/docker-entrypoint.sh diff --git a/deploy.ps1 b/deploy.ps1 index ded66a5de..525ec9717 100644 --- a/deploy.ps1 +++ b/deploy.ps1 @@ -52,6 +52,20 @@ if (-not (Test-Path ".git")) { exit 1 } +# A local edit to docker-compose.yml blocks the pull, and discarding it to +# get unstuck would throw away an inline ENCRYPTION_KEY. Stop and explain. +git diff --quiet HEAD -- docker-compose.yml +if ($LASTEXITCODE -ne 0) { + Write-Host "docker-compose.yml has local edits, which would block this update." -ForegroundColor Red + Write-Host "" + git --no-pager diff HEAD -- docker-compose.yml + Write-Host "" + Write-Host "Copy each value you changed into .env (for example ENCRYPTION_KEY=...)," -ForegroundColor Yellow + Write-Host "then discard the edit with: git checkout -- docker-compose.yml" -ForegroundColor Yellow + Write-Host "Do not discard it first: without your ENCRYPTION_KEY in .env, saved API keys stop decrypting." -ForegroundColor Red + exit 1 +} + Write-Host "[2/5] Fetching latest changes from GitHub..." -ForegroundColor Green git fetch origin Assert-LastExit "git fetch failed" diff --git a/deploy.sh b/deploy.sh index a9977d99b..35df284f4 100755 --- a/deploy.sh +++ b/deploy.sh @@ -29,11 +29,11 @@ cd "$SCRIPT_DIR" echo -e "${GREEN}[1/5]${NC} Current directory: $SCRIPT_DIR" echo "" -# Detect Docker Compose (standalone v2 or plugin) -if command -v docker-compose >/dev/null 2>&1; then - COMPOSE_CMD="docker-compose" -elif docker compose version >/dev/null 2>&1; then +# Detect Docker Compose (v2 plugin first, then the standalone binary) +if docker compose version >/dev/null 2>&1; then COMPOSE_CMD="docker compose" +elif command -v docker-compose >/dev/null 2>&1; then + COMPOSE_CMD="docker-compose" else echo -e "${RED}Error: Docker Compose not found${NC}" exit 1 @@ -48,6 +48,19 @@ if [ ! -d ".git" ]; then exit 1 fi +# A local edit to docker-compose.yml blocks the pull, and discarding it to +# get unstuck would throw away an inline ENCRYPTION_KEY. Stop and explain. +if ! git diff --quiet HEAD -- docker-compose.yml; then + echo -e "${RED}docker-compose.yml has local edits, which would block this update.${NC}" + echo "" + git --no-pager diff HEAD -- docker-compose.yml + echo "" + echo -e "Copy each value you changed into ${YELLOW}.env${NC} (for example ENCRYPTION_KEY=...)," + echo -e "then discard the edit with: ${YELLOW}git checkout -- docker-compose.yml${NC}" + echo -e "${RED}Do not discard it first: without your ENCRYPTION_KEY in .env, saved API keys stop decrypting.${NC}" + exit 1 +fi + # Fetch latest changes echo -e "${GREEN}[2/5]${NC} Fetching latest changes from GitHub..." git fetch origin diff --git a/docker-compose.lan-db.yml b/docker-compose.lan-db.yml new file mode 100644 index 000000000..889e1bcfc --- /dev/null +++ b/docker-compose.lan-db.yml @@ -0,0 +1,8 @@ +# Opt-in: publishes the bundled database on the LAN. Never on by default — +# the bundled password is printed in this public repository. +# Enable from .env: +# COMPOSE_FILE=docker-compose.yml:docker-compose.lan-db.yml +services: + db: + ports: + - "5432:5432" diff --git a/docker-compose.yml b/docker-compose.yml index 503f8819f..3dd33dbd8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,4 +1,31 @@ services: + db: + # Stock Postgres 18 plus the vector extension, which nothing enables yet + image: pgvector/pgvector:pg18 + container_name: jobsync_db + # BUNDLED_DB=0 in .env when DATABASE_URL points at your own Postgres + scale: ${BUNDLED_DB:-1} + # No ports published. docker-compose.lan-db.yml opens 5432 on request. + environment: + - POSTGRES_USER=jobsync + - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-jobsync} + - POSTGRES_DB=jobsync + # Do NOT add TZ here. DB-side timestamp defaults would then be written + # hours off from the ones Prisma writes. The app's TZ governs display. + volumes: + # 18+ mounts the parent directory, NOT /var/lib/postgresql/data: on + # that path the data lands in an anonymous volume a prune can remove. + - pgdata:/var/lib/postgresql + healthcheck: + # For `docker compose ps` only. Startup ordering belongs to the app's + # wait-for-db, which also works when this service is scaled to 0, so + # there is deliberately no depends_on. + test: ["CMD-SHELL", "pg_isready -U jobsync -d jobsync"] + interval: 5s + timeout: 5s + retries: 10 + restart: unless-stopped + app: image: ghcr.io/gsync/jobsync:latest container_name: jobsync_app @@ -7,7 +34,11 @@ services: environment: - NODE_ENV=production - MCP_ENABLED=${MCP_ENABLED:-true} - - DATABASE_URL=file:/data/dev.db + - DATABASE_URL=${DATABASE_URL:-postgres://jobsync:${POSTGRES_PASSWORD:-jobsync}@db:5432/jobsync} + - CREATE_DATABASE_IF_MISSING=${CREATE_DATABASE_IF_MISSING:-} + - SQLITE_LEGACY_PATH=${SQLITE_LEGACY_PATH:-/data/dev.db} + - SKIP_IMPORT=${SKIP_IMPORT:-} + - IMPORT_DRY_RUN=${IMPORT_DRY_RUN:-} - AUTH_SECRET=${AUTH_SECRET:-your-auth-secret} - ENCRYPTION_KEY=${ENCRYPTION_KEY:-you-encryption-key-here} - NEXTAUTH_URL=${NEXTAUTH_URL:-http://localhost:3737} @@ -20,6 +51,8 @@ services: - OPENROUTER_API_KEY=${OPENROUTER_API_KEY:-} - RAPIDAPI_KEY=${RAPIDAPI_KEY:-} volumes: + # Permanent: uploaded resumes live here, and the one-time import reads + # the legacy dev.db from the same mount. - ./jobsyncdb/data:/data extra_hosts: - "host.docker.internal:host-gateway" @@ -29,4 +62,10 @@ services: interval: 30s timeout: 10s retries: 3 - start_period: 30s + # The first boot after upgrading also runs the one-time import + start_period: 120s + +volumes: + pgdata: + # Fixed, so renaming the project folder cannot orphan the data + name: jobsync_pgdata diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index 647112fa7..d18c8f62d 100755 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -7,9 +7,16 @@ if [ -z "$AUTH_SECRET" ]; then echo "AUTH_SECRET was not set — generated a temporary secret for this container." fi -# Run migrations as root (before switching users) +# Wait for Postgres; exits non-zero at once on a permanent misconfiguration +node scripts/dist/wait-for-db.js + +# Apply the schema as root (before switching users) npx -y prisma@6.19.0 migrate deploy +# One-time SQLite -> Postgres import. A no-op without a legacy dev.db, and it +# must finish before the server (and its scheduler) starts. +node scripts/dist/import-sqlite.js + # Fix /data permissions and run app as nextjs user chown -R nextjs:nodejs /data export HOME=/home/nextjs From 5f7cca6a08f7af4290669f6a2aabcb7459c1de92 Mon Sep 17 00:00:00 2001 From: Khuram Niaz Date: Wed, 14 Oct 2026 16:08:17 -0600 Subject: [PATCH 08/14] feat(backup): add pg_dump backup and restore scripts for the full instance Co-Authored-By: Claude Sonnet 5.5 --- backup-db.sh | 76 +++++++++++++++++++++++++++++++++++++++ restore-db.sh | 98 +++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 174 insertions(+) create mode 100755 backup-db.sh create mode 100755 restore-db.sh diff --git a/backup-db.sh b/backup-db.sh new file mode 100755 index 000000000..93a2ec449 --- /dev/null +++ b/backup-db.sh @@ -0,0 +1,76 @@ +#!/bin/bash + +# JobSync database backup +# Usage: ./backup-db.sh +# Writes jobsyncdb/data/backups/jobsync-YYYY-MM-DD-HHMM.sql.gz + +# pipefail matters: without it a failed pg_dump still lets gzip exit 0 and +# leaves a dated, valid, empty archive that looks like a backup. +set -euo pipefail + +SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" +cd "$SCRIPT_DIR" + +# Last uncommented NAME=value line in .env, with surrounding quotes removed +env_value() { + [ -f .env ] || return 0 + { grep -E "^[[:space:]]*$1=" .env || true; } | tail -n 1 | cut -d= -f2- \ + | sed -e 's/^["'\'']//' -e 's/["'\'']$//' +} + +if docker compose version >/dev/null 2>&1; then + COMPOSE_CMD="docker compose" +elif command -v docker-compose >/dev/null 2>&1; then + COMPOSE_CMD="docker-compose" +else + echo "Error: Docker Compose not found" + exit 1 +fi + +BUNDLED_DB="$(env_value BUNDLED_DB)" +DATABASE_URL="$(env_value DATABASE_URL)" +PG_DUMP_IMAGE="$(env_value PG_DUMP_IMAGE)" +PG_DUMP_IMAGE="${PG_DUMP_IMAGE:-pgvector/pgvector:pg18}" +export DATABASE_URL + +BACKUP_DIR="jobsyncdb/data/backups" +if ! mkdir -p "$BACKUP_DIR" 2>/dev/null || [ ! -w "$BACKUP_DIR" ]; then + echo "Cannot write to $BACKUP_DIR. The app container owns jobsyncdb/data; re-run with sudo." + exit 1 +fi + +# Dump to a temporary name; only a complete dump gets a dated file name +TMP="$(mktemp "$BACKUP_DIR/.jobsync-dump.XXXXXX")" +ERR="$(mktemp)" +trap 'rm -f "$TMP" "$ERR"' EXIT + +if [ "$BUNDLED_DB" = "0" ]; then + if [ -z "$DATABASE_URL" ]; then + echo "BUNDLED_DB=0 is set in .env but DATABASE_URL is not: there is no database to back up." + exit 1 + fi + echo "Backing up the external database with $PG_DUMP_IMAGE..." + # pg_dump comes from a container: a Docker-only host has no client tools + dump() { + docker run --rm -e DATABASE_URL --add-host=host.docker.internal:host-gateway \ + "$PG_DUMP_IMAGE" sh -c 'pg_dump "$DATABASE_URL"' + } +else + echo "Backing up the bundled database..." + dump() { $COMPOSE_CMD exec -T db pg_dump -U jobsync jobsync; } +fi + +if ! dump 2>"$ERR" | gzip > "$TMP"; then + echo "Backup FAILED. No backup file was written." + cat "$ERR" + if grep -qi "server version mismatch" "$ERR"; then + echo "" + echo "Your Postgres server is newer than the dump tool. Set PG_DUMP_IMAGE in .env" + echo "to an image of the server's major version, for example PG_DUMP_IMAGE=postgres:19." + fi + exit 1 +fi + +OUT="$BACKUP_DIR/jobsync-$(date +%F-%H%M).sql.gz" +mv "$TMP" "$OUT" +echo "Backup written to $OUT ($(du -h "$OUT" | cut -f1))" diff --git a/restore-db.sh b/restore-db.sh new file mode 100755 index 000000000..2a9854954 --- /dev/null +++ b/restore-db.sh @@ -0,0 +1,98 @@ +#!/bin/bash + +# JobSync database restore +# Usage: ./restore-db.sh +# Loads a backup-db.sh dump into an EMPTY database. Refuses one with data. + +set -euo pipefail + +SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" + +FILE="${1:-}" +if [ -z "$FILE" ] || [ ! -f "$FILE" ]; then + echo "Usage: ./restore-db.sh " + echo "Backups are in jobsyncdb/data/backups/" + exit 1 +fi +FILE="$( cd "$( dirname "$FILE" )" && pwd )/$( basename "$FILE" )" +cd "$SCRIPT_DIR" + +env_value() { + [ -f .env ] || return 0 + { grep -E "^[[:space:]]*$1=" .env || true; } | tail -n 1 | cut -d= -f2- \ + | sed -e 's/^["'\'']//' -e 's/["'\'']$//' +} + +if docker compose version >/dev/null 2>&1; then + COMPOSE_CMD="docker compose" +elif command -v docker-compose >/dev/null 2>&1; then + COMPOSE_CMD="docker-compose" +else + echo "Error: Docker Compose not found" + exit 1 +fi + +BUNDLED_DB="$(env_value BUNDLED_DB)" +DATABASE_URL="$(env_value DATABASE_URL)" +PG_DUMP_IMAGE="$(env_value PG_DUMP_IMAGE)" +PG_DUMP_IMAGE="${PG_DUMP_IMAGE:-pgvector/pgvector:pg18}" +export DATABASE_URL + +# A corrupt archive would otherwise feed psql empty input that "succeeds" +if ! gunzip -t "$FILE" 2>/dev/null; then + echo "$FILE is not a readable gzip archive. Nothing was changed." + exit 1 +fi + +# psql on stdin, in whichever mode .env selects. ON_ERROR_STOP is required: +# without it psql carries on past errors and still exits 0. +if [ "$BUNDLED_DB" = "0" ]; then + if [ -z "$DATABASE_URL" ]; then + echo "BUNDLED_DB=0 is set in .env but DATABASE_URL is not: there is no database to restore into." + exit 1 + fi + run_psql() { + docker run --rm -i -e DATABASE_URL --add-host=host.docker.internal:host-gateway \ + "$PG_DUMP_IMAGE" sh -c 'psql -v ON_ERROR_STOP=1 "$@" "$DATABASE_URL"' sh "$@" + } +else + run_psql() { $COMPOSE_CMD exec -T db psql -v ON_ERROR_STOP=1 -U jobsync -d jobsync "$@"; } +fi + +if ! HAS_TABLE="$(echo "SELECT count(*) FROM pg_tables WHERE schemaname = 'public' AND tablename = 'User';" | run_psql -tA)"; then + echo "Could not reach the database. Is it running? Nothing was changed." + exit 1 +fi +if [ "$HAS_TABLE" = "1" ]; then + USERS="$(echo 'SELECT count(*) FROM "User";' | run_psql -tA)" + if [ "$USERS" != "0" ]; then + echo "This database already contains data ($USERS account(s)). Restore only loads into an empty database." + echo "Nothing was changed. To replace live data on purpose, empty the database first." + exit 1 + fi +fi + +# The app is stopped so a restarting container cannot run migrations mid-load +echo "Stopping the app..." +$COMPOSE_CMD stop app + +# A fresh volume already has the tables from migrate deploy, and a plain dump +# has no DROP statements, so the schema is reset before loading. +echo "Resetting the schema..." +if ! echo "DROP SCHEMA public CASCADE; CREATE SCHEMA public;" | run_psql -q; then + echo "Could not reset the public schema. On an external server the connecting role must own it." + echo "The app is stopped. Fix the cause and re-run, or start it with: $COMPOSE_CMD start app" + exit 1 +fi + +echo "Loading $FILE..." +# One transaction: a failed load rolls back to an empty schema, never half +if ! gunzip -c "$FILE" | run_psql -q -1 >/dev/null; then + echo "Restore FAILED and was rolled back. The database is empty and the app is stopped." + echo "Fix the cause and re-run this script." + exit 1 +fi + +echo "Starting the app..." +$COMPOSE_CMD start app +echo "Restore complete." From b1ecf643fcee79a1eac73192527c78cbdc8099b9 Mon Sep 17 00:00:00 2001 From: Khuram Niaz Date: Sun, 18 Oct 2026 06:53:11 -0600 Subject: [PATCH 09/14] feat(boot): treat a leftover SQLite DATABASE_URL as the bundled database, pick the backup and restore target from DATABASE_URL and make BUNDLED_DB optional - resolve-db-url runs first in the entrypoint: an unset DATABASE_URL or a leftover file: one resolves to BUNDLED_DATABASE_URL, the latter with a one-line warning, so upgrading never needs an .env edit - compose passes DATABASE_URL as found plus BUNDLED_DATABASE_URL and BUNDLED_DB - the file: stop now applies only with BUNDLED_DB=0 and says so - backup-db.sh and restore-db.sh choose external mode from DATABASE_URL, so an external operator without BUNDLED_DB=0 no longer dumps the empty bundled db - .env.example marks BUNDLED_DB=0 as optional Co-Authored-By: Claude Opus 5.5 --- .env.example | 3 ++- __tests__/resolveDbUrl.spec.ts | 49 ++++++++++++++++++++++++++++++++++ __tests__/waitForDb.spec.ts | 3 +++ backup-db.sh | 23 ++++++++++++---- docker-compose.yml | 7 +++-- docker-entrypoint.sh | 5 ++++ restore-db.sh | 23 ++++++++++++---- scripts/resolve-db-url.ts | 6 +++++ scripts/tsconfig.json | 2 +- scripts/wait/classify.ts | 5 ++-- scripts/wait/resolve.ts | 23 ++++++++++++++++ 11 files changed, 133 insertions(+), 16 deletions(-) create mode 100644 __tests__/resolveDbUrl.spec.ts create mode 100644 scripts/resolve-db-url.ts create mode 100644 scripts/wait/resolve.ts diff --git a/.env.example b/.env.example index 317b5d18d..1b7b732c4 100644 --- a/.env.example +++ b/.env.example @@ -3,8 +3,9 @@ # Database. The bundled Postgres runs automatically and needs no # configuration — it is not exposed outside the Docker network. # -# To use your own Postgres instead, uncomment and set both: +# To use your own Postgres instead, uncomment and set: # DATABASE_URL=postgres://user:pass@db.example.internal:5432/jobsync +# Optional: also stop the then-unused bundled database container # BUNDLED_DB=0 # # For local development (npm run dev) DATABASE_URL must be set: point it at diff --git a/__tests__/resolveDbUrl.spec.ts b/__tests__/resolveDbUrl.spec.ts new file mode 100644 index 000000000..21ce41372 --- /dev/null +++ b/__tests__/resolveDbUrl.spec.ts @@ -0,0 +1,49 @@ +import { resolveDatabaseUrl } from "../scripts/wait/resolve"; + +const BUNDLED = "postgres://jobsync:b3und!ed@db:5432/jobsync"; +const EXTERNAL = "postgres://me:pw@192.168.0.162:5432/jobsync"; + +describe("resolveDatabaseUrl", () => { + it("keeps the url as found when BUNDLED_DATABASE_URL is absent", () => { + expect(resolveDatabaseUrl({ DATABASE_URL: "file:/data/dev.db" })).toEqual({ + url: "file:/data/dev.db", + warning: null, + }); + }); + + it.each([undefined, "", " "])("uses the bundled url when DATABASE_URL is %j", (raw) => { + expect(resolveDatabaseUrl({ DATABASE_URL: raw, BUNDLED_DATABASE_URL: BUNDLED })).toEqual({ + url: BUNDLED, + warning: null, + }); + }); + + it.each([undefined, "1"])("swaps a file: url for the bundled one when BUNDLED_DB is %j", (flag) => { + const result = resolveDatabaseUrl({ + DATABASE_URL: "file:./dev.db", + BUNDLED_DATABASE_URL: BUNDLED, + BUNDLED_DB: flag, + }); + expect(result.url).toBe(BUNDLED); + expect(result.warning).toContain("Ignoring DATABASE_URL=file:./dev.db"); + expect(result.warning).toContain("You can delete that line from .env."); + expect(result.warning).not.toContain("b3und!ed"); + }); + + it("keeps a file: url when the bundled database is switched off", () => { + expect( + resolveDatabaseUrl({ + DATABASE_URL: "file:/data/dev.db", + BUNDLED_DATABASE_URL: BUNDLED, + BUNDLED_DB: "0", + }), + ).toEqual({ url: "file:/data/dev.db", warning: null }); + }); + + it("keeps an external url", () => { + expect(resolveDatabaseUrl({ DATABASE_URL: EXTERNAL, BUNDLED_DATABASE_URL: BUNDLED })).toEqual({ + url: EXTERNAL, + warning: null, + }); + }); +}); diff --git a/__tests__/waitForDb.spec.ts b/__tests__/waitForDb.spec.ts index 0a884e0d8..6038958da 100644 --- a/__tests__/waitForDb.spec.ts +++ b/__tests__/waitForDb.spec.ts @@ -63,6 +63,9 @@ describe("checkUrl", () => { expect(result.ok).toBe(false); expect(result.message).toContain(`DATABASE_URL still points at SQLite (${raw}).`); expect(result.message).toContain(UPGRADE_GUIDE_URL); + // Reached only with BUNDLED_DB=0: a plain leftover falls back to bundled + expect(result.message).toContain("Set DATABASE_URL to your own Postgres, or remove BUNDLED_DB=0"); + expect(result.message).not.toContain("Delete the DATABASE_URL line"); expect(result.message).toContain("Your existing database has NOT been modified."); }); diff --git a/backup-db.sh b/backup-db.sh index 93a2ec449..1ae5e3790 100755 --- a/backup-db.sh +++ b/backup-db.sh @@ -44,11 +44,20 @@ TMP="$(mktemp "$BACKUP_DIR/.jobsync-dump.XXXXXX")" ERR="$(mktemp)" trap 'rm -f "$TMP" "$ERR"' EXIT -if [ "$BUNDLED_DB" = "0" ]; then - if [ -z "$DATABASE_URL" ]; then - echo "BUNDLED_DB=0 is set in .env but DATABASE_URL is not: there is no database to back up." - exit 1 - fi +# The app's rule: unset, a leftover file: or host db all mean bundled +db_mode() { + case "$DATABASE_URL" in + postgres://*|postgresql://*) ;; + *) echo bundled; return ;; + esac + local host="${DATABASE_URL#*://}" + host="${host%%/*}" + host="${host##*@}" + host="${host%%:*}" + if [ "$host" = "db" ]; then echo bundled; else echo external; fi +} + +if [ "$(db_mode)" = "external" ]; then echo "Backing up the external database with $PG_DUMP_IMAGE..." # pg_dump comes from a container: a Docker-only host has no client tools dump() { @@ -56,6 +65,10 @@ if [ "$BUNDLED_DB" = "0" ]; then "$PG_DUMP_IMAGE" sh -c 'pg_dump "$DATABASE_URL"' } else + if [ "$BUNDLED_DB" = "0" ]; then + echo "BUNDLED_DB=0 is set in .env but DATABASE_URL does not point at your own Postgres: there is no database to back up." + exit 1 + fi echo "Backing up the bundled database..." dump() { $COMPOSE_CMD exec -T db pg_dump -U jobsync jobsync; } fi diff --git a/docker-compose.yml b/docker-compose.yml index 3dd33dbd8..4669cc5c8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,7 +3,7 @@ services: # Stock Postgres 18 plus the vector extension, which nothing enables yet image: pgvector/pgvector:pg18 container_name: jobsync_db - # BUNDLED_DB=0 in .env when DATABASE_URL points at your own Postgres + # Optional BUNDLED_DB=0 in .env stops it when using your own Postgres scale: ${BUNDLED_DB:-1} # No ports published. docker-compose.lan-db.yml opens 5432 on request. environment: @@ -34,7 +34,10 @@ services: environment: - NODE_ENV=production - MCP_ENABLED=${MCP_ENABLED:-true} - - DATABASE_URL=${DATABASE_URL:-postgres://jobsync:${POSTGRES_PASSWORD:-jobsync}@db:5432/jobsync} + # As found; the entrypoint swaps unset or a leftover file: for bundled + - DATABASE_URL=${DATABASE_URL:-} + - BUNDLED_DATABASE_URL=postgres://jobsync:${POSTGRES_PASSWORD:-jobsync}@db:5432/jobsync + - BUNDLED_DB=${BUNDLED_DB:-1} - CREATE_DATABASE_IF_MISSING=${CREATE_DATABASE_IF_MISSING:-} - SQLITE_LEGACY_PATH=${SQLITE_LEGACY_PATH:-/data/dev.db} - SKIP_IMPORT=${SKIP_IMPORT:-} diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index d18c8f62d..bdba40dd8 100755 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -7,6 +7,11 @@ if [ -z "$AUTH_SECRET" ]; then echo "AUTH_SECRET was not set — generated a temporary secret for this container." fi +# Resolve DATABASE_URL once for every step below. A separate assignment, so +# set -e sees a failure that `export VAR="$(...)"` would hide. +RESOLVED_DATABASE_URL="$(node scripts/dist/resolve-db-url.js)" +export DATABASE_URL="$RESOLVED_DATABASE_URL" + # Wait for Postgres; exits non-zero at once on a permanent misconfiguration node scripts/dist/wait-for-db.js diff --git a/restore-db.sh b/restore-db.sh index 2a9854954..83e397500 100755 --- a/restore-db.sh +++ b/restore-db.sh @@ -46,16 +46,29 @@ fi # psql on stdin, in whichever mode .env selects. ON_ERROR_STOP is required: # without it psql carries on past errors and still exits 0. -if [ "$BUNDLED_DB" = "0" ]; then - if [ -z "$DATABASE_URL" ]; then - echo "BUNDLED_DB=0 is set in .env but DATABASE_URL is not: there is no database to restore into." - exit 1 - fi +# The app's rule: unset, a leftover file: or host db all mean bundled +db_mode() { + case "$DATABASE_URL" in + postgres://*|postgresql://*) ;; + *) echo bundled; return ;; + esac + local host="${DATABASE_URL#*://}" + host="${host%%/*}" + host="${host##*@}" + host="${host%%:*}" + if [ "$host" = "db" ]; then echo bundled; else echo external; fi +} + +if [ "$(db_mode)" = "external" ]; then run_psql() { docker run --rm -i -e DATABASE_URL --add-host=host.docker.internal:host-gateway \ "$PG_DUMP_IMAGE" sh -c 'psql -v ON_ERROR_STOP=1 "$@" "$DATABASE_URL"' sh "$@" } else + if [ "$BUNDLED_DB" = "0" ]; then + echo "BUNDLED_DB=0 is set in .env but DATABASE_URL does not point at your own Postgres: there is no database to restore into." + exit 1 + fi run_psql() { $COMPOSE_CMD exec -T db psql -v ON_ERROR_STOP=1 -U jobsync -d jobsync "$@"; } fi diff --git a/scripts/resolve-db-url.ts b/scripts/resolve-db-url.ts new file mode 100644 index 000000000..b3526e83f --- /dev/null +++ b/scripts/resolve-db-url.ts @@ -0,0 +1,6 @@ +import { resolveDatabaseUrl } from "./wait/resolve"; + +// stdout is captured by the entrypoint; only the warning reaches the log +const { url, warning } = resolveDatabaseUrl(process.env); +if (warning) console.error(warning); +process.stdout.write(url); diff --git a/scripts/tsconfig.json b/scripts/tsconfig.json index 7f127dee1..f10af8139 100644 --- a/scripts/tsconfig.json +++ b/scripts/tsconfig.json @@ -11,5 +11,5 @@ "rootDir": ".", "outDir": "dist" }, - "files": ["wait-for-db.ts", "import-sqlite.ts"] + "files": ["resolve-db-url.ts", "wait-for-db.ts", "import-sqlite.ts"] } diff --git a/scripts/wait/classify.ts b/scripts/wait/classify.ts index e8df605b7..92bdc9395 100644 --- a/scripts/wait/classify.ts +++ b/scripts/wait/classify.ts @@ -16,14 +16,15 @@ export function checkUrl(raw: string | undefined): UrlCheck { if (!raw || raw.trim() === "") return { ok: false, message: "DATABASE_URL is not set." }; // Checked before Prisma sees it: P1012 covers this and every other bad - // string, and an upgrading operator needs the specific message. + // string. Reached only with BUNDLED_DB=0 or an older compose file. if (raw.startsWith("file:")) { return { ok: false, message: [ `DATABASE_URL still points at SQLite (${raw}).`, `This release requires Postgres — see ${UPGRADE_GUIDE_URL}.`, - "Delete the DATABASE_URL line from .env to use the bundled database.", + "Set DATABASE_URL to your own Postgres, or remove BUNDLED_DB=0", + "to use the bundled database.", "Your existing database has NOT been modified.", ].join("\n"), }; diff --git a/scripts/wait/resolve.ts b/scripts/wait/resolve.ts new file mode 100644 index 000000000..95cfe3272 --- /dev/null +++ b/scripts/wait/resolve.ts @@ -0,0 +1,23 @@ +export interface Resolved { + url: string; + warning: string | null; +} + +// A file: url can never be valid on Postgres, so a leftover one means "unset" +export function resolveDatabaseUrl(env: Record): Resolved { + const raw = env.DATABASE_URL ?? ""; + const bundled = env.BUNDLED_DATABASE_URL; + // An older compose file passes no bundled address: leave it to checkUrl + if (!bundled) return { url: raw, warning: null }; + if (raw.trim() === "") return { url: bundled, warning: null }; + if (raw.startsWith("file:") && env.BUNDLED_DB !== "0") { + return { + url: bundled, + warning: [ + `Ignoring DATABASE_URL=${raw} — SQLite is no longer supported.`, + "Using the bundled Postgres database. You can delete that line from .env.", + ].join("\n"), + }; + } + return { url: raw, warning: null }; +} From 45c0b36bb4691ede604463b746ea93cbdb010dc7 Mon Sep 17 00:00:00 2001 From: Khuram Niaz Date: Sun, 18 Oct 2026 07:10:11 -0600 Subject: [PATCH 10/14] docs: add the Postgres upgrade guide, point configuration at .env, update contributor setup, fix the README backup link and reword SQLite-era comments - wiki/upgrading-to-postgres.md: the operator upgrade guide, linked from the wiki index and from a new "whole instance" question in wiki/backup.md - README: configuration lives in .env, a DATABASE_URL row, an upgrade note, Postgres in the stack, and the Data Backup link now points at wiki/Backup - CONTRIBUTING: Postgres 17+ prerequisite and DATABASE_URL local setup - pgvector noted as needed for upcoming resume embeddings - src/ and playwright.config.ts: comments only, SQLite reasons replaced by the reasons that still hold Co-Authored-By: Claude Opus 5.5 --- CONTRIBUTING.md | 15 ++++++- README.md | 11 +++-- playwright.config.ts | 4 +- src/actions/interview.actions.ts | 5 +-- src/actions/job/shared.ts | 3 +- src/actions/jobStage/prep.ts | 4 +- src/actions/profile/resumeCopy.ts | 2 +- src/lib/agent/resumeLookup.ts | 4 +- src/lib/aiUsage/summarize.ts | 2 +- src/lib/backup/import.ts | 1 + src/lib/backup/snapshot.ts | 9 ++-- src/lib/constants.ts | 4 +- src/lib/jobs/sortStages.ts | 4 +- src/lib/jobs/updateJobFromNames.ts | 2 +- src/lib/listSort.ts | 4 +- src/lib/mcp/tools/addJobsBatch.ts | 4 +- wiki/backup.md | 4 ++ wiki/index.md | 1 + wiki/upgrading-to-postgres.md | 67 ++++++++++++++++++++++++++++++ 19 files changed, 120 insertions(+), 30 deletions(-) create mode 100644 wiki/upgrading-to-postgres.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 267b75911..29c462cc5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -44,6 +44,7 @@ This project follows a [Code of Conduct](./CODE_OF_CONDUCT.md). By participating - Node.js 20+ - npm - Git +- A Postgres database (any reachable Postgres 17+; Docker is one way to get one, not a requirement). Prefer one with the `pgvector` extension available — the bundled `pgvector/pgvector:pg18` image has it — since resume embeddings will need it ### Local Setup @@ -65,10 +66,22 @@ This project follows a [Code of Conduct](./CODE_OF_CONDUCT.md). By participating ```bash cp .env.example .env ``` -6. **Set up the database:** +6. **Point the app at a Postgres database.** Set `DATABASE_URL` in `.env`. Either use a Postgres you already have: + ``` + DATABASE_URL=postgres://user:password@localhost:5432/jobsync + ``` + or start only the bundled database from this repo and use it: + ```bash + docker compose -f docker-compose.yml -f docker-compose.lan-db.yml up -d db + ``` + ``` + DATABASE_URL=postgres://jobsync:jobsync@localhost:5432/jobsync + ``` + Then create the schema: ```bash npx prisma migrate dev ``` + The role needs `CREATEDB` so Prisma can make its temporary shadow database (the bundled one has it). Tests need no database: `npm test` mocks Prisma. 7. **Start the development server:** ```bash npm run dev diff --git a/README.md b/README.md index 193f97171..12cce5bd0 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ Track every application, get AI resume reviews and job matches, and discover new - **Contacts:** Keep recruiters, interviewers and references, linked to the jobs they touched. [Details](https://github.com/Gsync/jobsync/wiki/Contacts) - **Company Pages:** One page per company with its jobs, people and links, plus a watchlist. [Details](https://github.com/Gsync/jobsync/wiki/Companies) - **Question Bank:** Build a library of interview questions and answers, tagged by skill. -- **Data Backup & Restore:** Export everything as one zip, import it anywhere, and undo an import. [Details](https://github.com/Gsync/jobsync/wiki/Data-Backup) +- **Data Backup & Restore:** Export everything as one zip, import it anywhere, and undo an import. [Details](https://github.com/Gsync/jobsync/wiki/Backup) - **AI Assistant:** A docked chat that reviews resumes, scores job matches, writes cover letters and adds jobs you paste in. [Details](#ai-assistant-in-app-chat) - **AI Agent Integration (MCP):** Add jobs and questions from Claude Desktop and other agents. [Details](#mcp-server-ai-agent-integration) @@ -69,7 +69,7 @@ API keys for AI providers can be configured in **Settings** after signing in. ### Configuration (Optional) -Environment variables can be set in `docker-compose.yml`: +Environment variables are set in a `.env` file beside `docker-compose.yml` (copy `.env.example`). Do not edit `docker-compose.yml` itself — updates replace it. | Variable | Description | |---|---| @@ -77,10 +77,13 @@ Environment variables can be set in `docker-compose.yml`: | `AUTH_SECRET` | Auto-generated if not set. To set manually: `openssl rand -base64 32` | | `ENCRYPTION_KEY` | Encrypts the API keys you save in Settings. **Set your own** (`openssl rand -base64 32`) before adding any keys, and never change it afterwards — a new value makes every stored API key unrecoverable. | | `NEXTAUTH_URL` | The address you open JobSync at. Defaults to `http://localhost:3737`; on a homelab server, use the server's IP or hostname. | -| `MCP_ENABLED` | Set to `false` to turn off the [MCP server](#mcp-server-ai-agent-integration). Enabled by default in `docker-compose.yml`. | +| `MCP_ENABLED` | Set to `false` to turn off the [MCP server](#mcp-server-ai-agent-integration). Enabled by default. | +| `DATABASE_URL` | Leave unset to use the bundled Postgres. To use your own, set it to your server's address; optionally add `BUNDLED_DB=0` to stop the then-idle bundled container. The bundled image includes the `pgvector` extension, which an upcoming release uses for resume embeddings; your own server will need it then too. See [Upgrading to Postgres](https://github.com/Gsync/jobsync/wiki/Upgrading-To-Postgres). | ### Updating +**Upgrading from a SQLite release?** Read [Upgrading to Postgres](https://github.com/Gsync/jobsync/wiki/Upgrading-To-Postgres) first — the upgrade is one-way. + From the project directory, run the deploy script to pull the latest image and restart: ```sh @@ -226,7 +229,7 @@ We welcome contributions! Please read our [Contributing Guidelines](./CONTRIBUTI - Tailwind - Tiptap - Nivo -- Sqlite +- Postgres - Vercel AI-SDK - Ollama diff --git a/playwright.config.ts b/playwright.config.ts index 732a62f17..e8ddd5d45 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -19,8 +19,8 @@ export default defineConfig({ /* Retry on CI only */ retries: process.env.CI ? 2 : 0, /* Cap local concurrency: too many parallel contexts (workers x 3 browsers) - starve the single dev server + SQLite, making combobox "Create" server - actions slow enough to time out. */ + starve the single dev server and its database, making combobox + "Create" server actions slow enough to time out. */ workers: process.env.CI ? 1 : 4, /* Heavier flows (e.g. adding a resume experience makes several sequential server-action "Create" calls) need headroom under that load. */ diff --git a/src/actions/interview.actions.ts b/src/actions/interview.actions.ts index 1a84a2548..058b1f9f1 100644 --- a/src/actions/interview.actions.ts +++ b/src/actions/interview.actions.ts @@ -59,9 +59,8 @@ export const getInterviewList = async ( const where = buildInterviewWhere(user.id, search, stageTypeId, companyId); // Ordering stays in the query because the list is server-paged. - // Undated rounds sort last. SQLite has no NULLS LAST and - // Prisma's `nulls` option is unsupported there, so the dated rows are - // paged first and the undated ones continue the list once they run out. + // Undated rounds sort last: the dated rows are paged first and the undated + // ones continue the list. Kept from SQLite, which had no NULLS LAST. const and = where.AND ?? []; const datedWhere = { ...where, AND: [...and, { occurredAt: { not: null } }] }; const undatedWhere = { ...where, AND: [...and, { occurredAt: null }] }; diff --git a/src/actions/job/shared.ts b/src/actions/job/shared.ts index 3625fe5eb..1d6f0fc87 100644 --- a/src/actions/job/shared.ts +++ b/src/actions/job/shared.ts @@ -29,7 +29,8 @@ type StatusStageTx = Parameters[0] & { // // stageTypeId is an argument, never resolved here: resolveStageTypeForStatusId // writes through the base prisma client, and calling it inside the caller's -// transaction contends for SQLite's write lock (D4). +// transaction would write outside that transaction, so a rollback would not +// undo it (D4). export async function appendStatusStage( tx: StatusStageTx, jobId: string, diff --git a/src/actions/jobStage/prep.ts b/src/actions/jobStage/prep.ts index 626bf6ed4..ae6dda00d 100644 --- a/src/actions/jobStage/prep.ts +++ b/src/actions/jobStage/prep.ts @@ -28,8 +28,8 @@ export const addStagePrepQuestions = async ( assertInterviewStage(stage); if (owned !== ids.length) throw new Error("Question not found"); - // SQLite has no skipDuplicates, so the already-linked ids are subtracted - // here rather than left to the unique index. + // The already-linked ids are subtracted here rather than left to the + // unique index. skipDuplicates is available now; switching is later work. const linked = await prisma.jobStagePrepQuestion.findMany({ where: { stageId, questionId: { in: ids } }, select: { questionId: true }, diff --git a/src/actions/profile/resumeCopy.ts b/src/actions/profile/resumeCopy.ts index f10ce360a..8ffed91f6 100644 --- a/src/actions/profile/resumeCopy.ts +++ b/src/actions/profile/resumeCopy.ts @@ -48,7 +48,7 @@ export const copyResume = async ( try { const user = await requireUser(); - // Read outside the transaction so the SQLite write lock is held briefly + // Read outside the transaction so it stays short const source = await prisma.resume.findUnique({ where: { id: resumeId, profile: { userId: user.id } }, select: resumeCopySelect, diff --git a/src/lib/agent/resumeLookup.ts b/src/lib/agent/resumeLookup.ts index 8588357ed..eee512978 100644 --- a/src/lib/agent/resumeLookup.ts +++ b/src/lib/agent/resumeLookup.ts @@ -8,8 +8,8 @@ export type ResumeLookup = | { status: "needs_selection"; resumes: { id: string; title: string }[] } | { status: "no_resumes" }; -// SQLite has no `mode: "insensitive"` in Prisma, so title matching happens -// in JS over this list. It is also exactly what the picker renders, so the +// Title matching happens in JS over this list (it predates Postgres and +// mode: "insensitive"). It is also exactly what the picker renders, so the // query is not extra work. async function listTitles(userId: string) { return prisma.resume.findMany({ diff --git a/src/lib/aiUsage/summarize.ts b/src/lib/aiUsage/summarize.ts index c8e9f2875..0a0e9d4fa 100644 --- a/src/lib/aiUsage/summarize.ts +++ b/src/lib/aiUsage/summarize.ts @@ -23,7 +23,7 @@ export type AiCallRecord = { const DAY_MS = 24 * 60 * 60 * 1000; -// Nearest rank, in JS: SQLite has no percentile function. +// Nearest rank, in JS: kept engine-independent rather than percentile_cont. export function percentile(values: number[], p: number): number | null { if (values.length === 0) return null; const sorted = [...values].sort((a, b) => a - b); diff --git a/src/lib/backup/import.ts b/src/lib/backup/import.ts index 1749ef37f..4580af296 100644 --- a/src/lib/backup/import.ts +++ b/src/lib/backup/import.ts @@ -429,6 +429,7 @@ export async function importBackup( ); if (error instanceof BackupError) throw error; const message = error instanceof Error ? error.message : ""; + // Dead on Postgres (a SQLite lock message); removed with the import shim. if (/database is locked|SQLITE_BUSY/i.test(message)) { throw new BackupError("The database is busy. Please try the import again."); } diff --git a/src/lib/backup/snapshot.ts b/src/lib/backup/snapshot.ts index 32092b1be..4a669a2d6 100644 --- a/src/lib/backup/snapshot.ts +++ b/src/lib/backup/snapshot.ts @@ -86,10 +86,11 @@ export async function readSnapshot( } // Prunes on count and on total bytes. The count alone is not a disk bound: -// nothing caps how large one snapshot is, these sit on the same volume as the -// SQLite database, and an import/rollback loop writes one every time. The -// newest is always kept, even if it alone exceeds the byte budget — dropping -// the only record of the state a user just left is worse than overshooting. +// nothing caps how large one snapshot is, these sit on the /data bind mount +// alongside uploaded resumes, and an import/rollback loop writes one every +// time. The newest is always kept, even if it alone exceeds the byte budget — +// dropping the only record of the state a user just left is worse than +// overshooting. export async function pruneSnapshots( userId: string, keep: number, diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 15d29dba3..08650cb19 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -245,8 +245,8 @@ export const APP_CONSTANTS = { BACKUP_MAX_UNCOMPRESSED_BYTES: 100 * 1024 * 1024, BACKUP_MAX_ENTRIES: 5000, // Pre-import snapshots retained per user. The count alone does not bound - // disk — nothing caps one snapshot's size — and these live on the same - // volume as the SQLite database, so prune on total bytes as well. + // disk — nothing caps one snapshot's size — and these live on the /data + // bind mount, alongside uploaded resumes, so prune on total bytes as well. BACKUP_SNAPSHOT_KEEP: 5, BACKUP_SNAPSHOT_MAX_TOTAL_BYTES: 250 * 1024 * 1024, diff --git a/src/lib/jobs/sortStages.ts b/src/lib/jobs/sortStages.ts index 1c4a64117..902bfd325 100644 --- a/src/lib/jobs/sortStages.ts +++ b/src/lib/jobs/sortStages.ts @@ -2,8 +2,8 @@ // chronology: a job is routinely added days after it was applied to, so the // New stage's date (when the job entered JobSync) postdates the Applied // stage's. Dates break ties within one sortOrder. Undated stages sort last -// among their peers — SQLite sorts NULL first on ASC and Prisma's -// nulls:"last" is unsupported there, so none of this can live in the query. +// among their peers. This ordering predates Postgres and stays in JavaScript +// so the Timeline tab can share it. // Both the server actions and the Timeline tab sort through this one // comparator: the job-details page loads stages with no orderBy at all. export function sortStages< diff --git a/src/lib/jobs/updateJobFromNames.ts b/src/lib/jobs/updateJobFromNames.ts index 3896fda67..212507164 100644 --- a/src/lib/jobs/updateJobFromNames.ts +++ b/src/lib/jobs/updateJobFromNames.ts @@ -175,7 +175,7 @@ export async function updateJobFromNames( } // D4: this resolve can create a row and writes through the base client, so - // it must not run while the transaction below holds SQLite's write lock. + // it stays outside the transaction below, which could not roll it back. const stageTypeId = statusId ? await resolveStageTypeForStatusId(statusId, userId) : null; diff --git a/src/lib/listSort.ts b/src/lib/listSort.ts index acb29aba7..fa2e3f6ef 100644 --- a/src/lib/listSort.ts +++ b/src/lib/listSort.ts @@ -51,8 +51,8 @@ const withCondition = (where: Where, extra: Where): Where => { return { ...where, AND: [...and, extra] }; }; -// SQLite has no NULLS LAST and Prisma's `nulls` option is unsupported there, -// so valued rows page first and the list continues into the blank ones. +// Valued rows page first and the list continues into the blank ones. Kept +// from SQLite, which had no NULLS LAST; one nulls:"last" query is later work. export async function findManySorted( model: ListSortDelegate, query: { where: Where; skip: number; take: number; select?: unknown }, diff --git a/src/lib/mcp/tools/addJobsBatch.ts b/src/lib/mcp/tools/addJobsBatch.ts index 8df22cc5c..8bc9afdf7 100644 --- a/src/lib/mcp/tools/addJobsBatch.ts +++ b/src/lib/mcp/tools/addJobsBatch.ts @@ -2,8 +2,8 @@ import { z } from "zod"; import { McpAddJobSchema } from "@/models/mcp.schema"; import { handleAddJob } from "@/lib/mcp/tools/addJob"; -// Sequential, not Promise.all: entity resolution is check-then-act, and -// SQLite serializes writes anyway — parallelism here only buys races. +// Sequential, not Promise.all: entity resolution is check-then-act, so +// parallel calls naming the same new entity race to create it twice. export async function handleAddJobsBatch( input: { jobs: Array> }, userId: string, diff --git a/wiki/backup.md b/wiki/backup.md index 0f59f5d80..829575e6e 100644 --- a/wiki/backup.md +++ b/wiki/backup.md @@ -19,6 +19,10 @@ Open the avatar menu at the bottom of the sidebar, choose **Settings**, then **D API keys, MCP tokens and your password. The file is safe to store anywhere for that reason, but it does contain the full text of every job description, note and resume, so keep it somewhere private. +## Does a Data Backup cover my whole instance? + +No — it covers one account's data and leaves out accounts, API keys and MCP tokens. To back up the whole instance, including every account, run `./backup-db.sh` on the server; [Upgrading to Postgres](./upgrading-to-postgres.md) explains how it and `restore-db.sh` work. + ## How do I restore a backup? Under **Settings → Data**, choose the zip in **Import a backup**. JobSync reads it and shows when it was exported, which JobSync version and account it came from, and how many of each record it holds. Click **Replace my data with this backup** to confirm. diff --git a/wiki/index.md b/wiki/index.md index 86a1b3a4f..d8537b1d7 100644 --- a/wiki/index.md +++ b/wiki/index.md @@ -16,6 +16,7 @@ JobSync tracks job applications, resumes and the work you do around them. These - [Tasks](./tasks.md) — add to-dos with a priority and due date, find them with the status filter and activity-type panel, mark them done, and start a timed activity from one. - [Activities](./activities.md) — time the work you do with a start/stop timer and breaks, log time after the fact, and see your hours on the Dashboard. - [Data Backup](./backup.md) — download everything as one zip, import it on any instance, and undo an import. +- [Upgrading to Postgres](./upgrading-to-postgres.md) — what to do before upgrading from a SQLite release, how the one-time import works, backing up with `backup-db.sh`, and using your own Postgres. - [MCP Access](./mcp.md) — connect an external AI agent such as Claude Desktop, generate and revoke access tokens, and what the agent can do once connected. Installation, Docker and environment variables are in the [project README](https://github.com/Gsync/jobsync#readme). diff --git a/wiki/upgrading-to-postgres.md b/wiki/upgrading-to-postgres.md new file mode 100644 index 000000000..e43b00aa2 --- /dev/null +++ b/wiki/upgrading-to-postgres.md @@ -0,0 +1,67 @@ +--- +type: how-to +title: Upgrading to Postgres +description: What changes when JobSync moves from SQLite to Postgres, what to do before upgrading, how the one-time import works, and how to recover if something goes wrong. +feature: setup +tags: [upgrade, postgres, sqlite, database, migration, import, docker, backup, restore, external database] +aliases: [upgrade to postgres, database migration, DATABASE_URL still points at SQLite, the database it was imported into appears to be missing, use my own postgres] +status: stable +stale_after: 2027-10-18 +--- + +# Upgrading to Postgres + +## What do I have to do to upgrade? + +For the default setup, nothing beyond two checks before you run the update. JobSync now stores its data in Postgres, and a Postgres container is bundled and starts by itself. On the first start your existing data is copied across automatically. + +Before updating: + +1. **Move any edits you made to `docker-compose.yml` into `.env`.** If you set `ENCRYPTION_KEY`, `AUTH_SECRET`, `TZ` or `NEXTAUTH_URL` inside `docker-compose.yml`, copy each one into `.env` as `NAME=value`, and only then discard the edit with `git checkout -- docker-compose.yml`. Do not discard it first: without your `ENCRYPTION_KEY` in `.env`, saved API keys can no longer be read. +2. **Copy `jobsyncdb/data/dev.db` somewhere safe.** It is your complete fallback copy. + +Then update as usual with the deploy script. If your `.env` still has an old `DATABASE_URL=file:...` line, the app ignores it, uses the bundled database and says so in its log; you can delete the line whenever you like. + +## Is the upgrade reversible? + +No, the upgrade is one-way and there is no supported way back to the SQLite release. A failed upgrade is fixed by correcting the cause and restarting. Your old `dev.db` is never written to, renamed or deleted, so the import can be retried from it as often as needed. + +## What happens to my existing data? + +It is copied into Postgres once, on the first start after the upgrade, before the app begins serving. Accounts, jobs, resumes, tags, API keys and MCP tokens all come across with the same ids, so you stay logged in. The log shows one line per table, such as `Job: 93 → 93 ✓`. Afterwards a small file, `jobsyncdb/data/dev.db.imported-at`, records that the import happened; leave it in place. + +If your `dev.db` was last used by a release older than 1.1.22, the import stops before copying anything and prints one command to run. That command starts the last SQLite release once so it can bring `dev.db` up to date; press Ctrl+C when it reports it is ready, then start JobSync again. + +## What do I do if the import fails? + +Read the last lines of `docker compose logs app`, fix the cause it names, and restart. A failed import leaves Postgres empty and `dev.db` untouched, so restarting retries it from the start. To get into the app while you investigate, add `SKIP_IMPORT=1` to `.env` and restart; the app starts with no data. Remove that line again as soon as the cause is fixed. It is a one-time troubleshooting switch, not a setting. + +To rehearse the import without writing anything, add `IMPORT_DRY_RUN=1`, restart, read the per-table summary in the log, then remove the line and restart. + +## Why does the app say the database it was imported into appears to be missing? + +Because Postgres is empty but your old `dev.db` was already imported earlier, which means the database volume was lost, or `DATABASE_URL` now points at a different, empty database. JobSync stops instead of quietly restoring months-old data. Restore a backup with `./restore-db.sh `, or, to deliberately start again from the old SQLite data, delete `jobsyncdb/data/dev.db.imported-at` and restart. + +## How do I back up JobSync now? + +Run `./backup-db.sh`, which writes a dated dump into `jobsyncdb/data/backups/`. On Windows run it from WSL or Git Bash; there is no PowerShell version yet. The database no longer lives in the `jobsyncdb` folder, so a folder-level backup on its own no longer covers it; the dump puts a copy back inside that folder. On Linux you may need `sudo`, and the script says so when it does. Restore with `./restore-db.sh `, which only loads into an empty database. + +Copying Postgres's data directory while it is running is not a valid backup. The in-app Data Backup is still the way to move one account's data, but it leaves out accounts, API keys and MCP tokens, so it does not replace a dump. Take a dump before you ever delete `dev.db`. + +## Where is my data stored now? + +In a Docker volume named `jobsync_pgdata`, under Docker's own storage, not in `jobsyncdb/data/`. If you placed `jobsyncdb` on a particular disk on purpose, the database is no longer on it; to keep it there, declare the `pgdata` volume as a bind mount. Uploaded resume files stay in `jobsyncdb/data/` as before. The volume holds Postgres 18 data, so a future move to Postgres 19 is a dump and restore, not an image tag change. + +## How do I use my own Postgres instead of the bundled one? + +Set `DATABASE_URL` in `.env` to your server's address; never edit `docker-compose.yml`. That alone switches the app and the backup scripts to your server. The bundled container then sits idle; add `BUNDLED_DB=0` to `.env` if you want it stopped. Point `DATABASE_URL` at a dedicated, empty database. JobSync creates the database if it is missing and the role is allowed to; check the "created it" line on first start, because a typo in the name creates a new empty database rather than an error. Set `CREATE_DATABASE_IF_MISSING=0` to make a missing database an error instead. When a start-up message tells you to empty the Postgres database, drop and recreate that database on your server; the `docker volume rm` command it shows applies to the bundled database only. + +Checklist for your server: + +- Postgres 17 or newer. +- A role that can log in and either owns the database or has `GRANT ALL ON SCHEMA public`. +- Managed providers usually need `?sslmode=require`, and the direct endpoint rather than a pooled one. +- From inside the container use `host.docker.internal`, not `localhost`, for a Postgres on the same machine. +- Percent-encode a password containing `@`, `:`, `/` or `#`. +- If your server is newer than Postgres 18, set `PG_DUMP_IMAGE` in `.env` to its version, for example `postgres:19`, so backups work. +- Upcoming: a future release uses the `pgvector` extension for resume embeddings. If you run your own Postgres, plan to have it available; the bundled database already includes it. From dd0b54184417e1c3be396bcd6fade903503199e3 Mon Sep 17 00:00:00 2001 From: Khuram Niaz Date: Sun, 18 Oct 2026 07:49:44 -0600 Subject: [PATCH 11/14] fix: skip an empty imported dev.db, idle after a dry run, strip Prisma URL options in backup and restore, strip NULs from new text, keep copied resume order and unscored discovered jobs last, guard password redaction and move scripts to node16 resolution - Import: an already-imported dev.db with no users skips instead of stopping every boot; hostPath is shared by decide.ts and run.ts - Entrypoint: IMPORT_DRY_RUN exit 2 idles (TERM-aware) so the restart policy does not repeat the rehearsal - backup-db.sh / restore-db.sh: drop schema, connection_limit and other Prisma-only options before pg_dump/psql; sslmode is kept - New src/lib/postgresText.ts strips NUL and lone surrogates in resume text extraction, scraped descriptions and add_job descriptions - Resume copy carries createdAt so entries keep the source order - Discovered jobs sort with nulls last, pinned in postgresQueryParity.spec.ts - redact() survives a password with an invalid % escape - scripts/tsconfig.json: module and moduleResolution node16 (node10 is deprecated); compiled output is unchanged Co-Authored-By: Claude Opus 5.5 --- __tests__/importDecide.spec.ts | 9 ++++++++ __tests__/importRun.spec.ts | 9 +++++++- __tests__/postgresQueryParity.spec.ts | 6 ++++++ __tests__/postgresText.spec.ts | 11 ++++++++++ __tests__/resumeCopy.actions.spec.ts | 3 +++ __tests__/waitForDb.spec.ts | 6 +++++- backup-db.sh | 7 ++++++ docker-entrypoint.sh | 10 ++++++++- restore-db.sh | 7 ++++++ scripts/import/decide.ts | 10 ++++++++- scripts/import/run.ts | 27 ++++++++++++++++++------ scripts/tsconfig.json | 4 ++-- scripts/wait/classify.ts | 12 ++++++----- src/actions/automation/discoveredJobs.ts | 5 ++++- src/actions/profile/resumeCopy.ts | 3 +++ src/lib/agent/tools/addJob.ts | 5 ++++- src/lib/ai/import/extract-text.ts | 14 ++++++------ src/lib/postgresText.ts | 9 ++++++++ src/lib/scraper/mapper.ts | 3 ++- 19 files changed, 134 insertions(+), 26 deletions(-) create mode 100644 __tests__/postgresText.spec.ts create mode 100644 src/lib/postgresText.ts diff --git a/__tests__/importDecide.spec.ts b/__tests__/importDecide.spec.ts index 7c12ee230..026b33656 100644 --- a/__tests__/importDecide.spec.ts +++ b/__tests__/importDecide.spec.ts @@ -13,6 +13,7 @@ const facts = (over: Partial = {}): ImportFacts => ({ legacyFileExists: true, skipImport: false, postgresHasUsers: false, + legacyHasUsers: true, sentinelExists: false, sentinelText: "", ...over, @@ -48,6 +49,14 @@ describe("decideImport", () => { expect(message).toContain("Nothing has been modified. Exiting."); }); + it("skips instead of stopping when the imported dev.db had no users", () => { + const d = decideImport(facts({ sentinelExists: true, legacyHasUsers: false })); + expect(d).toEqual({ + action: "skip", + message: "/data/dev.db was already imported and has no users, skipping import.", + }); + }); + it.each(["", " \n", "not a date"])("counts sentinel text %j as present", (sentinelText) => { const d = decideImport(facts({ sentinelExists: true, sentinelText })); expect(d.action).toBe("stop"); diff --git a/__tests__/importRun.spec.ts b/__tests__/importRun.spec.ts index 938d2ce5a..bee51a9e1 100644 --- a/__tests__/importRun.spec.ts +++ b/__tests__/importRun.spec.ts @@ -103,7 +103,14 @@ describe("runImport", () => { const h = harness({ files: { "/data/dev.db.imported-at": "2026-11-02T14:02:11.000Z\n" } }); expect(await runImport(h.deps)).toBe(1); expect(h.log.join("\n")).toContain("The database it was imported into appears to be missing."); - expect(h.opened()).toBe(0); + expect(h.opened()).toBe(1); + }); + + it("boots when the imported dev.db had no users", async () => { + const h = harness({ sourceUsers: [], files: { "/data/dev.db.imported-at": "2026-11-02T14:02:11.000Z\n" } }); + expect(await runImport(h.deps)).toBe(0); + expect(h.log.join("\n")).toContain("has no users, skipping import."); + expect(h.targetRows).toHaveLength(0); }); it("stops on data with no sentinel", async () => { diff --git a/__tests__/postgresQueryParity.spec.ts b/__tests__/postgresQueryParity.spec.ts index 53bae203e..5c517e274 100644 --- a/__tests__/postgresQueryParity.spec.ts +++ b/__tests__/postgresQueryParity.spec.ts @@ -144,6 +144,12 @@ describe("NULL ordering parity", () => { const [call] = findManyCalls("task"); expect(call.args.orderBy[0]).toEqual({ dueDate: { sort: "asc", nulls: "first" } }); }); + + it("keeps unscored discovered jobs last", async () => { + await getDiscoveredJobs(); + const [call] = findManyCalls("job"); + expect(call.args.orderBy[0]).toEqual({ matchScore: { sort: "desc", nulls: "last" } }); + }); }); describe("pagination tiebreaker: every paged sort ends on id", () => { diff --git a/__tests__/postgresText.spec.ts b/__tests__/postgresText.spec.ts new file mode 100644 index 000000000..7d163e49b --- /dev/null +++ b/__tests__/postgresText.spec.ts @@ -0,0 +1,11 @@ +import { sanitizeString } from "@/lib/postgresText"; + +describe("sanitizeString", () => { + it("removes NUL and lone surrogates Postgres cannot store", () => { + expect(sanitizeString("a\u0000b\uD800c\uDC00d")).toBe("abcd"); + }); + + it("keeps valid surrogate pairs and ordinary text", () => { + expect(sanitizeString("Résumé 😀\n")).toBe("Résumé 😀\n"); + }); +}); diff --git a/__tests__/resumeCopy.actions.spec.ts b/__tests__/resumeCopy.actions.spec.ts index 0475ae4c6..bfa5387a8 100644 --- a/__tests__/resumeCopy.actions.spec.ts +++ b/__tests__/resumeCopy.actions.spec.ts @@ -131,6 +131,7 @@ describe("copyResume", () => { startDate: new Date("2020-01-01"), endDate: null, description: "

Work

", + createdAt: new Date("2024-03-01T10:00:00Z"), }, ], educations: [], @@ -209,6 +210,8 @@ describe("copyResume", () => { startDate: new Date("2020-01-01"), endDate: null, description: "

Work

", + // Kept so the copy lists entries in the source's order + createdAt: new Date("2024-03-01T10:00:00Z"), }, ]); }); diff --git a/__tests__/waitForDb.spec.ts b/__tests__/waitForDb.spec.ts index 6038958da..557063fab 100644 --- a/__tests__/waitForDb.spec.ts +++ b/__tests__/waitForDb.spec.ts @@ -1,4 +1,4 @@ -import { checkUrl, classify, maintenanceUrl, quoteIdent, UPGRADE_GUIDE_URL } from "../scripts/wait/classify"; +import { checkUrl, classify, maintenanceUrl, quoteIdent, redact, UPGRADE_GUIDE_URL } from "../scripts/wait/classify"; import { waitForDb, type WaitDeps } from "../scripts/wait/run"; const SECRET = "s3cr@t:pa/ss#word"; @@ -103,6 +103,10 @@ describe("helpers", () => { it("quotes an identifier", () => { expect(quoteIdent('job"sync')).toBe('"job""sync"'); }); + + it("redacts a password whose % is not a valid escape", () => { + expect(redact("auth failed for p%zz", "postgres://u:p%zz@h/db")).toBe("auth failed for ***"); + }); }); describe("waitForDb", () => { diff --git a/backup-db.sh b/backup-db.sh index 1ae5e3790..54ee9921a 100755 --- a/backup-db.sh +++ b/backup-db.sh @@ -57,7 +57,14 @@ db_mode() { if [ "$host" = "db" ]; then echo bundled; else echo external; fi } +# pg_dump and psql reject Prisma-only URL options such as ?schema=public; +# real libpq options like sslmode are kept +libpq_url() { + printf '%s' "$DATABASE_URL" | sed -E 's/([?&])(schema|connection_limit|pool_timeout|pgbouncer|statement_cache_size|socket_timeout)=[^&]*/\1/g; s/\?&+/?/; s/&&+/\&/g; s/[?&]$//' +} + if [ "$(db_mode)" = "external" ]; then + DATABASE_URL="$(libpq_url)" echo "Backing up the external database with $PG_DUMP_IMAGE..." # pg_dump comes from a container: a Docker-only host has no client tools dump() { diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index bdba40dd8..8e782e24c 100755 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -20,7 +20,15 @@ npx -y prisma@6.19.0 migrate deploy # One-time SQLite -> Postgres import. A no-op without a legacy dev.db, and it # must finish before the server (and its scheduler) starts. -node scripts/dist/import-sqlite.js +IMPORT_STATUS=0 +node scripts/dist/import-sqlite.js || IMPORT_STATUS=$? +# Exit 2 is a finished dry run: idle, or the restart policy would repeat it +if [ "$IMPORT_STATUS" -eq 2 ]; then + echo "Dry run finished. Remove IMPORT_DRY_RUN from .env and restart." + trap 'exit 0' TERM INT + while :; do sleep 3600 & wait $!; done +fi +[ "$IMPORT_STATUS" -eq 0 ] || exit "$IMPORT_STATUS" # Fix /data permissions and run app as nextjs user chown -R nextjs:nodejs /data diff --git a/restore-db.sh b/restore-db.sh index 83e397500..906d66c6a 100755 --- a/restore-db.sh +++ b/restore-db.sh @@ -59,7 +59,14 @@ db_mode() { if [ "$host" = "db" ]; then echo bundled; else echo external; fi } +# pg_dump and psql reject Prisma-only URL options such as ?schema=public; +# real libpq options like sslmode are kept +libpq_url() { + printf '%s' "$DATABASE_URL" | sed -E 's/([?&])(schema|connection_limit|pool_timeout|pgbouncer|statement_cache_size|socket_timeout)=[^&]*/\1/g; s/\?&+/?/; s/&&+/\&/g; s/[?&]$//' +} + if [ "$(db_mode)" = "external" ]; then + DATABASE_URL="$(libpq_url)" run_psql() { docker run --rm -i -e DATABASE_URL --add-host=host.docker.internal:host-gateway \ "$PG_DUMP_IMAGE" sh -c 'psql -v ON_ERROR_STOP=1 "$@" "$DATABASE_URL"' sh "$@" diff --git a/scripts/import/decide.ts b/scripts/import/decide.ts index 2612b4244..bc4a40118 100644 --- a/scripts/import/decide.ts +++ b/scripts/import/decide.ts @@ -11,6 +11,7 @@ export interface ImportFacts { legacyFileExists: boolean; skipImport: boolean; postgresHasUsers: boolean; + legacyHasUsers: boolean; sentinelExists: boolean; sentinelText: string; } @@ -23,7 +24,7 @@ export type ImportDecision = export const sentinelPath = (legacyPath: string) => `${legacyPath}.imported-at`; // Operators see the bind mount, not the container path -const hostPath = (containerPath: string) => +export const hostPath = (containerPath: string) => containerPath.startsWith("/data/") ? `jobsyncdb/data/${containerPath.slice("/data/".length)}` : containerPath; @@ -64,6 +65,13 @@ export function decideImport(facts: ImportFacts): ImportDecision { } if (!facts.postgresHasUsers && facts.sentinelExists) { + // An empty dev.db has nothing a lost volume could have taken + if (!facts.legacyHasUsers) { + return { + action: "skip", + message: `${legacyPath} was already imported and has no users, skipping import.`, + }; + } return { action: "stop", message: [ diff --git a/scripts/import/run.ts b/scripts/import/run.ts index 9fc3a7eab..97bd4950e 100644 --- a/scripts/import/run.ts +++ b/scripts/import/run.ts @@ -1,5 +1,5 @@ import { copyAll, type CopyClient, type CopyResult } from "./copy"; -import { checkLegacySchema, decideImport, sentinelPath } from "./decide"; +import { checkLegacySchema, decideImport, hostPath, sentinelPath } from "./decide"; import { buildImportPlan, type DmmfModel } from "./order"; export interface ImportDeps { @@ -37,6 +37,15 @@ function report(result: CopyResult, log: (line: string) => void) { ); } +async function countLegacyUsers(deps: ImportDeps, legacyPath: string) { + const source = deps.openSource(`file:${legacyPath}`); + try { + return await source.user.count(); + } finally { + await source.$disconnect?.(); + } +} + // Returns the process exit code: 0 continue to boot, 1 stop, 2 dry run done export async function runImport(deps: ImportDeps): Promise { const { env, log } = deps; @@ -44,12 +53,21 @@ export async function runImport(deps: ImportDeps): Promise { const sentinel = sentinelPath(legacyPath); const legacyFileExists = deps.fileExists(legacyPath); const sentinelExists = legacyFileExists && deps.fileExists(sentinel); + const postgresHasUsers = legacyFileExists + ? (await deps.target.user.count()) > 0 + : false; + // Only the lost-volume case reads dev.db before deciding + const legacyHasUsers = + sentinelExists && !postgresHasUsers + ? (await countLegacyUsers(deps, legacyPath)) > 0 + : true; const decision = decideImport({ legacyPath, legacyFileExists, skipImport: env.SKIP_IMPORT === "1", - postgresHasUsers: legacyFileExists ? (await deps.target.user.count()) > 0 : false, + postgresHasUsers, + legacyHasUsers, sentinelExists, sentinelText: sentinelExists ? deps.readText(sentinel) : "", }); @@ -97,11 +115,8 @@ export async function runImport(deps: ImportDeps): Promise { try { deps.writeText(sentinel, `${deps.now().toISOString()}\n`); } catch (error) { - const shown = sentinel.startsWith("/data/") - ? `jobsyncdb/data/${sentinel.slice("/data/".length)}` - : sentinel; log(`The import succeeded, but its record could not be written (${reason(error)}).`); - log(`Create an empty file named ${shown} and restart. Without it the next start cannot tell that dev.db was already imported.`); + log(`Create an empty file named ${hostPath(sentinel)} and restart. Without it the next start cannot tell that dev.db was already imported.`); return 1; } return 0; diff --git a/scripts/tsconfig.json b/scripts/tsconfig.json index f10af8139..e7f011d07 100644 --- a/scripts/tsconfig.json +++ b/scripts/tsconfig.json @@ -1,8 +1,8 @@ { "compilerOptions": { "target": "ES2022", - "module": "commonjs", - "moduleResolution": "node", + "module": "node16", + "moduleResolution": "node16", "lib": ["ES2022"], "types": ["node"], "strict": true, diff --git a/scripts/wait/classify.ts b/scripts/wait/classify.ts index 92bdc9395..a6c56f4c1 100644 --- a/scripts/wait/classify.ts +++ b/scripts/wait/classify.ts @@ -77,11 +77,13 @@ export function redact(message: string, raw: string): string { return message; } if (!password) return message; - return message - .split(password) - .join("***") - .split(decodeURIComponent(password)) - .join("***"); + const masked = message.split(password).join("***"); + // A stray % (p%zz) is a valid URL but not a valid escape + try { + return masked.split(decodeURIComponent(password)).join("***"); + } catch { + return masked; + } } export const MALFORMED_MESSAGE = MALFORMED; diff --git a/src/actions/automation/discoveredJobs.ts b/src/actions/automation/discoveredJobs.ts index 8ebd53952..69b534a65 100644 --- a/src/actions/automation/discoveredJobs.ts +++ b/src/actions/automation/discoveredJobs.ts @@ -61,7 +61,10 @@ export async function getDiscoveredJobs(options?: { where, skip, take: limit, - orderBy: [{ [sortBy]: sortOrder }, { id: "desc" }], + orderBy: [ + { [sortBy]: { sort: sortOrder, nulls: "last" } }, + { id: "desc" }, + ], include: { automation: { select: { id: true, name: true }, diff --git a/src/actions/profile/resumeCopy.ts b/src/actions/profile/resumeCopy.ts index 8ffed91f6..d15beb8a3 100644 --- a/src/actions/profile/resumeCopy.ts +++ b/src/actions/profile/resumeCopy.ts @@ -122,6 +122,7 @@ export const copyResume = async ( startDate: w.startDate, endDate: w.endDate, description: w.description, + createdAt: w.createdAt, })), }); } @@ -137,6 +138,7 @@ export const copyResume = async ( startDate: e.startDate, endDate: e.endDate, description: e.description, + createdAt: e.createdAt, })), }); } @@ -160,6 +162,7 @@ export const copyResume = async ( resumeSectionId: newSection.id, title: o.title, content: o.content, + createdAt: o.createdAt, })), }); } diff --git a/src/lib/agent/tools/addJob.ts b/src/lib/agent/tools/addJob.ts index 57469542b..17d763432 100644 --- a/src/lib/agent/tools/addJob.ts +++ b/src/lib/agent/tools/addJob.ts @@ -5,6 +5,7 @@ import { AgentAddJobParseSchema, AgentAddJobSchema } from "@/models/agent.schema import { createJobFromNames } from "@/lib/jobs/createJobFromNames"; import { JobResolutionError } from "@/lib/jobs/resolve"; import { log } from "@/lib/telemetry"; +import { sanitizeString } from "@/lib/postgresText"; import type { AgentAddJobResult } from "@/models/agent.model"; // The model usually reaches here having omitted jobDescription because the @@ -53,7 +54,9 @@ export function buildAddJobTool(userId: string, pastedText?: string) { } const input = parsed.data; - const jobDescription = pastedText ?? input.jobDescription; + const jobDescription = sanitizeString( + pastedText ?? input.jobDescription ?? "", + ); if (!jobDescription) return failed(NO_DESCRIPTION, NO_DESCRIPTION_DISPLAY); try { diff --git a/src/lib/ai/import/extract-text.ts b/src/lib/ai/import/extract-text.ts index ba33bfd65..b6c6d9b62 100644 --- a/src/lib/ai/import/extract-text.ts +++ b/src/lib/ai/import/extract-text.ts @@ -1,6 +1,7 @@ // Text extraction from PDF and DOCX resume uploads import { APP_CONSTANTS } from "@/lib/constants"; +import { sanitizeString } from "@/lib/postgresText"; // Magic bytes for file-type sniffing (Buffer — server-only, not in constants.ts) export const PDF_MAGIC = Buffer.from([0x25, 0x50, 0x44, 0x46]); // %PDF @@ -86,7 +87,7 @@ async function extractPdf(buf: Buffer): Promise { const { text: pages } = await pdfExtractText(pdf, { mergePages: false }); const pageArray = Array.isArray(pages) ? pages : [pages as string]; - const text = pageArray.slice(0, pagesToRead).join("\n"); + const text = sanitizeString(pageArray.slice(0, pagesToRead).join("\n")); if (!text.trim()) { throw Object.assign(new Error("NO_TEXT"), { code: "NO_TEXT" }); @@ -128,18 +129,19 @@ async function extractDocx(buf: Buffer): Promise { const mammoth = await import("mammoth"); const result = await mammoth.extractRawText({ buffer: buf }); - if (!result.value.trim()) { + const value = sanitizeString(result.value); + if (!value.trim()) { throw Object.assign(new Error("NO_TEXT"), { code: "NO_TEXT" }); } const text = - result.value.length > APP_CONSTANTS.RESUME_IMPORT_MAX_EXTRACTED_CHARS - ? result.value.slice(0, APP_CONSTANTS.RESUME_IMPORT_MAX_EXTRACTED_CHARS) - : result.value; + value.length > APP_CONSTANTS.RESUME_IMPORT_MAX_EXTRACTED_CHARS + ? value.slice(0, APP_CONSTANTS.RESUME_IMPORT_MAX_EXTRACTED_CHARS) + : value; return { text, - truncated: result.value.length > APP_CONSTANTS.RESUME_IMPORT_MAX_EXTRACTED_CHARS, + truncated: value.length > APP_CONSTANTS.RESUME_IMPORT_MAX_EXTRACTED_CHARS, }; } diff --git a/src/lib/postgresText.ts b/src/lib/postgresText.ts new file mode 100644 index 000000000..03ae22f8b --- /dev/null +++ b/src/lib/postgresText.ts @@ -0,0 +1,9 @@ +// Postgres text rejects NUL and lone surrogates; PDF extraction, scraped +// postings and pasted text can all carry them. Same rule as the one-time +// import's scripts/import/sanitize.ts, which scripts/ cannot import from here. +const UNSTORABLE = + /\u0000|[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(? Date: Sun, 18 Oct 2026 08:14:42 -0600 Subject: [PATCH 12/14] fix: refuse a restore into a non-public schema, strip NULs from imported backups, sort undated recent jobs and watched companies last, idle after a failed SQLite import, mask the database password only in URL form and use strict equality in the import copy Co-Authored-By: Claude Opus 5.5 --- __tests__/backupImportGuards.spec.ts | 14 ++++++++++++++ __tests__/postgresQueryParity.spec.ts | 17 ++++++++++++++++- __tests__/waitForDb.spec.ts | 11 ++++++++++- docker-entrypoint.sh | 6 ++++-- restore-db.sh | 9 +++++++++ scripts/import/copy.ts | 2 +- scripts/wait/classify.ts | 7 ++++--- src/actions/company/queries.ts | 5 ++++- src/actions/dashboard/recent.ts | 7 ++++--- src/lib/backup/import.ts | 7 ++++++- 10 files changed, 72 insertions(+), 13 deletions(-) diff --git a/__tests__/backupImportGuards.spec.ts b/__tests__/backupImportGuards.spec.ts index b25992f21..da14523f2 100644 --- a/__tests__/backupImportGuards.spec.ts +++ b/__tests__/backupImportGuards.spec.ts @@ -106,6 +106,20 @@ describe("importBackup guards", () => { expect(syncSchedulerState).toHaveBeenCalled(); }); + it("strips NULs a SQLite-era backup can carry before inserting", async () => { + const data = BackupDataSchema.parse({ + Company: [{ id: "c1", label: "Acme\u0000 Corp", value: "acme corp", logoUrl: null }], + }); + const zip = new JSZip(); + zip.file("manifest.json", JSON.stringify(buildManifest(data, EMAIL))); + zip.file("data.json", JSON.stringify(data)); + await importBackup(await zip.generateAsync({ type: "nodebuffer" }), "user-1", EMAIL, true); + + expect(mockDb.company.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ label: "Acme Corp" }), + }); + }); + it("wipes ChatConversation but never ApiKey or McpAccessToken", async () => { await importBackup(await emptyBackup(), "user-1", EMAIL, true); expect(mockDb.chatConversation.deleteMany).toHaveBeenCalled(); diff --git a/__tests__/postgresQueryParity.spec.ts b/__tests__/postgresQueryParity.spec.ts index 5c517e274..88e623bc4 100644 --- a/__tests__/postgresQueryParity.spec.ts +++ b/__tests__/postgresQueryParity.spec.ts @@ -14,6 +14,8 @@ import { getCoverLetterList } from "@/actions/coverLetter.actions"; import { getContactRoleList } from "@/actions/contactRole.actions"; import { getJobStageTypeList } from "@/actions/jobStageType.actions"; import { getJobsList, getJobsIterator } from "@/actions/job/queries"; +import { getRecentJobs } from "@/actions/dashboard/recent"; +import { getCompanyList } from "@/actions/company/queries"; import prisma from "@/lib/db"; type Call = { model: string; method: string; args: any }; @@ -150,6 +152,19 @@ describe("NULL ordering parity", () => { const [call] = findManyCalls("job"); expect(call.args.orderBy[0]).toEqual({ matchScore: { sort: "desc", nulls: "last" } }); }); + + it("keeps undated applied jobs last on the Recent Jobs card", async () => { + await getRecentJobs(); + const [call] = findManyCalls("job"); + expect(call.args.orderBy[0]).toEqual({ appliedDate: { sort: "desc", nulls: "last" } }); + expect(lastKey(call.args.orderBy)).toEqual({ id: "desc" }); + }); + + it("keeps undated watched companies last", async () => { + await getCompanyList(1, 10, undefined, undefined, "watchlist"); + const [call] = findManyCalls("company"); + expect(call.args.orderBy[0]).toEqual({ watchedAt: { sort: "desc", nulls: "last" } }); + }); }); describe("pagination tiebreaker: every paged sort ends on id", () => { @@ -186,7 +201,7 @@ describe("pagination tiebreaker: every paged sort ends on id", () => { userId: "user-1", fkField: "companyId", appliedRelation: "jobsApplied", - orderBy: [{ watchedAt: "desc" }, { label: "asc" }], + orderBy: [{ watchedAt: { sort: "desc", nulls: "last" } }, { label: "asc" }], page: 1, limit: 10, }), diff --git a/__tests__/waitForDb.spec.ts b/__tests__/waitForDb.spec.ts index 557063fab..d66e7ec9a 100644 --- a/__tests__/waitForDb.spec.ts +++ b/__tests__/waitForDb.spec.ts @@ -105,7 +105,16 @@ describe("helpers", () => { }); it("redacts a password whose % is not a valid escape", () => { - expect(redact("auth failed for p%zz", "postgres://u:p%zz@h/db")).toBe("auth failed for ***"); + expect(redact("auth failed for postgres://u:p%zz@h/db", "postgres://u:p%zz@h/db")).toBe( + "auth failed for postgres://u:***@h/db", + ); + }); + + it("keeps a default password readable where it is the user or db name", () => { + const raw = "postgres://jobsync:jobsync@db:5432/jobsync"; + expect(redact(`User \`jobsync\` denied on \`jobsync.public\` via ${raw}`, raw)).toBe( + "User `jobsync` denied on `jobsync.public` via postgres://jobsync:***@db:5432/jobsync", + ); }); }); diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index 8e782e24c..44ccb4e7c 100755 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -22,13 +22,15 @@ npx -y prisma@6.19.0 migrate deploy # must finish before the server (and its scheduler) starts. IMPORT_STATUS=0 node scripts/dist/import-sqlite.js || IMPORT_STATUS=$? -# Exit 2 is a finished dry run: idle, or the restart policy would repeat it +# Exit 2 is a finished dry run and 1 a failed or refused import: idle, or the +# restart policy would repeat it until someone stops the container if [ "$IMPORT_STATUS" -eq 2 ]; then echo "Dry run finished. Remove IMPORT_DRY_RUN from .env and restart." +fi +if [ "$IMPORT_STATUS" -ne 0 ]; then trap 'exit 0' TERM INT while :; do sleep 3600 & wait $!; done fi -[ "$IMPORT_STATUS" -eq 0 ] || exit "$IMPORT_STATUS" # Fix /data permissions and run app as nextjs user chown -R nextjs:nodejs /data diff --git a/restore-db.sh b/restore-db.sh index 906d66c6a..608af22e8 100755 --- a/restore-db.sh +++ b/restore-db.sh @@ -65,6 +65,15 @@ libpq_url() { printf '%s' "$DATABASE_URL" | sed -E 's/([?&])(schema|connection_limit|pool_timeout|pgbouncer|statement_cache_size|socket_timeout)=[^&]*/\1/g; s/\?&+/?/; s/&&+/\&/g; s/[?&]$//' } +# The reset below drops public, which would hit another app's tables when +# JobSync lives in its own schema of a shared database +SCHEMA="$(printf '%s' "$DATABASE_URL" | sed -nE 's/.*[?&]schema=([^&]*).*/\1/p')" +if [ -n "$SCHEMA" ] && [ "$SCHEMA" != "public" ]; then + echo "DATABASE_URL sets ?schema=$SCHEMA. Restore supports only the public schema." + echo "Nothing was changed." + exit 1 +fi + if [ "$(db_mode)" = "external" ]; then DATABASE_URL="$(libpq_url)" run_psql() { diff --git a/scripts/import/copy.ts b/scripts/import/copy.ts index 0c1433c5b..c888f74d6 100644 --- a/scripts/import/copy.ts +++ b/scripts/import/copy.ts @@ -56,7 +56,7 @@ export async function copyAll( if (deferred.length === 0) return clean; const copy: Row = { ...clean }; for (const field of deferred) { - if (copy[field] != null) pending.push({ model, id: row.id, field, value: copy[field] }); + if (copy[field] !== null) pending.push({ model, id: row.id, field, value: copy[field] }); copy[field] = null; } return copy; diff --git a/scripts/wait/classify.ts b/scripts/wait/classify.ts index a6c56f4c1..a483753fa 100644 --- a/scripts/wait/classify.ts +++ b/scripts/wait/classify.ts @@ -68,7 +68,8 @@ export function maintenanceUrl(raw: string): string { export const quoteIdent = (name: string) => `"${name.replace(/"/g, '""')}"`; -// Driver messages can echo the connection string +// Driver messages can echo the connection string. Only the :password@ form +// is masked, since the default password "jobsync" is also the user and db name export function redact(message: string, raw: string): string { let password = ""; try { @@ -77,10 +78,10 @@ export function redact(message: string, raw: string): string { return message; } if (!password) return message; - const masked = message.split(password).join("***"); + const masked = message.split(`:${password}@`).join(":***@"); // A stray % (p%zz) is a valid URL but not a valid escape try { - return masked.split(decodeURIComponent(password)).join("***"); + return masked.split(`:${decodeURIComponent(password)}@`).join(":***@"); } catch { return masked; } diff --git a/src/actions/company/queries.ts b/src/actions/company/queries.ts index 2202db04b..1064113fb 100644 --- a/src/actions/company/queries.ts +++ b/src/actions/company/queries.ts @@ -43,7 +43,10 @@ export const getCompanyList = async ( ...(watchlist ? { extraWhere: { watched: true }, - orderBy: [{ watchedAt: "desc" }, { label: "asc" }], + orderBy: [ + { watchedAt: { sort: "desc", nulls: "last" } }, + { label: "asc" }, + ], } : {}), searchFields: ["label", "atsToken"], diff --git a/src/actions/dashboard/recent.ts b/src/actions/dashboard/recent.ts index 1f4dcecdc..09ee27c4f 100644 --- a/src/actions/dashboard/recent.ts +++ b/src/actions/dashboard/recent.ts @@ -17,9 +17,10 @@ export const getRecentJobs = async (): Promise => { Status: true, Location: true, }, - orderBy: { - appliedDate: "desc", - }, + orderBy: [ + { appliedDate: { sort: "desc", nulls: "last" } }, + { id: "desc" }, + ], take: APP_CONSTANTS.RECENT_NUM_JOBS_ACTIVITIES, }); return list; diff --git a/src/lib/backup/import.ts b/src/lib/backup/import.ts index 4580af296..f11e4b068 100644 --- a/src/lib/backup/import.ts +++ b/src/lib/backup/import.ts @@ -22,6 +22,7 @@ import { } from "./ordering"; import { writeSnapshot } from "./snapshot"; import { log } from "@/lib/telemetry"; +import { sanitizeString } from "@/lib/postgresText"; import { isResumeFilePath } from "@/lib/resumeFiles"; import { BackupDataSchema, type BackupData, type BackupManifest } from "./schema"; @@ -87,7 +88,11 @@ async function readData(zip: JSZip): Promise { if (!entry) throw new BackupError("Backup is missing data.json."); let raw: unknown; try { - raw = JSON.parse(await entry.async("string")); + // A backup taken on SQLite can hold NULs, and one would fail the whole + // restore transaction on Postgres + raw = JSON.parse(await entry.async("string"), (_key, value) => + typeof value === "string" ? sanitizeString(value) : value, + ); } catch { throw new BackupError("data.json is not valid JSON."); } From d003a68fab07de8763c1a4d390941f513522d873 Mon Sep 17 00:00:00 2001 From: Khuram Niaz Date: Sun, 18 Oct 2026 08:20:45 -0600 Subject: [PATCH 13/14] fix: skip undefined deferred fields in the SQLite import copy and update the company and recent-jobs specs for nulls-last sorting - copy.ts: the strict-equality change queued an update for every row whose deferred field was undefined; skip both null and undefined - company.actions.spec.ts / dashboard.actions.spec.ts: expect the nulls-last orderBy and id tiebreaker from 2f2a6b2 Co-Authored-By: Claude Opus 5.5 --- __tests__/company.actions.spec.ts | 6 +++++- __tests__/dashboard.actions.spec.ts | 7 ++++--- scripts/import/copy.ts | 2 +- 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/__tests__/company.actions.spec.ts b/__tests__/company.actions.spec.ts index 1490a0df7..066947891 100644 --- a/__tests__/company.actions.spec.ts +++ b/__tests__/company.actions.spec.ts @@ -291,7 +291,11 @@ describe("Company Actions", () => { expect(prisma.company.findMany).toHaveBeenCalledWith( expect.objectContaining({ where: { createdBy: mockUser.id, watched: true }, - orderBy: [{ watchedAt: "desc" }, { label: "asc" }, { id: "desc" }], + orderBy: [ + { watchedAt: { sort: "desc", nulls: "last" } }, + { label: "asc" }, + { id: "desc" }, + ], }) ); }); diff --git a/__tests__/dashboard.actions.spec.ts b/__tests__/dashboard.actions.spec.ts index c25ea81aa..ff91195ae 100644 --- a/__tests__/dashboard.actions.spec.ts +++ b/__tests__/dashboard.actions.spec.ts @@ -72,9 +72,10 @@ describe("Dashboard Actions", () => { Status: true, Location: true, }, - orderBy: { - appliedDate: "desc", - }, + orderBy: [ + { appliedDate: { sort: "desc", nulls: "last" } }, + { id: "desc" }, + ], take: APP_CONSTANTS.RECENT_NUM_JOBS_ACTIVITIES, }); }); diff --git a/scripts/import/copy.ts b/scripts/import/copy.ts index c888f74d6..13de05075 100644 --- a/scripts/import/copy.ts +++ b/scripts/import/copy.ts @@ -56,7 +56,7 @@ export async function copyAll( if (deferred.length === 0) return clean; const copy: Row = { ...clean }; for (const field of deferred) { - if (copy[field] !== null) pending.push({ model, id: row.id, field, value: copy[field] }); + if (copy[field] !== null && copy[field] !== undefined) pending.push({ model, id: row.id, field, value: copy[field] }); copy[field] = null; } return copy; From 73bd49eca7acbac5c0643852a63a43320f2d55f7 Mon Sep 17 00:00:00 2001 From: Khuram Niaz Date: Mon, 19 Oct 2026 05:13:54 -0600 Subject: [PATCH 14/14] chore(compose): stop passing SQLITE_LEGACY_PATH to the app container Its only useful in-container value is the default /data/dev.db, which the importer already falls back to; any other path would need a new mount. Co-Authored-By: Claude Opus 5.5 --- docker-compose.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/docker-compose.yml b/docker-compose.yml index 4669cc5c8..b0cfa1be2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -39,7 +39,6 @@ services: - BUNDLED_DATABASE_URL=postgres://jobsync:${POSTGRES_PASSWORD:-jobsync}@db:5432/jobsync - BUNDLED_DB=${BUNDLED_DB:-1} - CREATE_DATABASE_IF_MISSING=${CREATE_DATABASE_IF_MISSING:-} - - SQLITE_LEGACY_PATH=${SQLITE_LEGACY_PATH:-/data/dev.db} - SKIP_IMPORT=${SKIP_IMPORT:-} - IMPORT_DRY_RUN=${IMPORT_DRY_RUN:-} - AUTH_SECRET=${AUTH_SECRET:-your-auth-secret}