careermate 0.0.9 → 0.0.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/AGENTS.md +3 -2
  2. package/INSTALL.md +6 -6
  3. package/LICENSE +1 -1
  4. package/README.md +18 -16
  5. package/dist/career-os/README.md +2 -2
  6. package/dist/doctor.mjs +17857 -6595
  7. package/dist/mcp.mjs +14545 -3839
  8. package/dist/migrate.mjs +14258 -3709
  9. package/dist/pdf.worker.min.mjs +4 -4
  10. package/dist/public/app.js +3 -18
  11. package/dist/public/i18n/en/documents.js +1 -0
  12. package/dist/public/i18n/en/home.js +8 -1
  13. package/dist/public/i18n/en/jobs.js +2 -0
  14. package/dist/public/i18n/en/nav.js +1 -0
  15. package/dist/public/i18n/en/settings.js +1 -6
  16. package/dist/public/i18n/ko/documents.js +1 -0
  17. package/dist/public/i18n/ko/home.js +8 -1
  18. package/dist/public/i18n/ko/jobs.js +2 -0
  19. package/dist/public/i18n/ko/nav.js +1 -0
  20. package/dist/public/i18n/ko/settings.js +1 -6
  21. package/dist/public/index.html +0 -1
  22. package/dist/public/lib.js +13 -4
  23. package/dist/public/pages/documents.js +27 -10
  24. package/dist/public/pages/home.js +15 -5
  25. package/dist/public/pages/insights.js +156 -0
  26. package/dist/public/pages/jobs.js +21 -3
  27. package/dist/public/pages/settings.js +13 -12
  28. package/dist/public/styles.css +33 -0
  29. package/dist/web.mjs +16890 -5492
  30. package/docs/FAQ.md +3 -4
  31. package/docs/LOCAL_FIRST.md +32 -0
  32. package/docs/MCP_TOOLS.md +6 -3
  33. package/docs/REVIEW_IMPLEMENTATION_PLAN.md +22 -0
  34. package/docs/career-os/README.md +2 -2
  35. package/package.json +7 -12
  36. package/packages/core/src/data-health.ts +161 -0
  37. package/packages/core/src/fit-explain.ts +63 -0
  38. package/packages/core/src/index.ts +4 -0
  39. package/packages/core/src/job-categories.data.ts +140 -0
  40. package/packages/core/src/job-categories.ts +106 -0
  41. package/packages/core/src/jobsearch.ts +374 -66
  42. package/packages/core/src/reuse-insights.ts +126 -0
  43. package/packages/db/src/connection.ts +46 -26
  44. package/packages/db/src/index.ts +3 -0
  45. package/packages/db/src/interview-retrospectives.ts +145 -0
  46. package/packages/db/src/job-search.ts +100 -0
  47. package/packages/db/src/provenance.ts +125 -0
  48. package/packages/db/src/repositories.ts +1 -0
  49. package/packages/mcp-tools/src/tools.ts +115 -11
  50. package/packages/prompts/src/system.ts +21 -64
  51. package/scripts/init.ts +2 -0
  52. package/tsconfig.json +9 -10
@@ -0,0 +1,126 @@
1
+ import { coverLetterRepo, experienceRepo, projectRepo, jobRepo } from '@careermate/db';
2
+
3
+ export interface ReuseReference {
4
+ kind: 'experience' | 'project';
5
+ entity_id: string;
6
+ label: string;
7
+ cover_letters: Array<{ id: string; title: string; job_id: string | null; company: string | null; position: string | null }>;
8
+ reuse_count: number;
9
+ }
10
+
11
+ export interface DiffLine {
12
+ type: 'equal' | 'add' | 'remove';
13
+ text: string;
14
+ }
15
+
16
+ function containsNormalized(content: string, needle: string): boolean {
17
+ const normalize = (s: string) => s.toLowerCase().replace(/\s+/g, ' ').trim();
18
+ const n = normalize(needle);
19
+ return n.length >= 2 && normalize(content).includes(n);
20
+ }
21
+
22
+ export function getExperienceReuseMap(): ReuseReference[] {
23
+ const letters = coverLetterRepo.list().map((letter) => coverLetterRepo.get(letter.id, true)!).filter(Boolean);
24
+ const jobs = new Map(jobRepo.list().map((job) => [job.id, job]));
25
+ const refs: ReuseReference[] = [];
26
+
27
+ for (const exp of experienceRepo.list()) {
28
+ const needles = [exp.company, exp.role].filter((v): v is string => !!v && v.trim().length >= 2);
29
+ const matches = letters.filter((letter) => {
30
+ const body = letter.current_content ?? '';
31
+ return needles.some((needle) => containsNormalized(body, needle));
32
+ });
33
+ refs.push({
34
+ kind: 'experience',
35
+ entity_id: exp.id,
36
+ label: [exp.company, exp.role].filter(Boolean).join(' · '),
37
+ cover_letters: matches.map((letter) => {
38
+ const job = letter.job_id ? jobs.get(letter.job_id) : null;
39
+ return {
40
+ id: letter.id,
41
+ title: letter.title,
42
+ job_id: letter.job_id,
43
+ company: job?.company ?? null,
44
+ position: job?.position ?? null,
45
+ };
46
+ }),
47
+ reuse_count: matches.length,
48
+ });
49
+ }
50
+
51
+ for (const project of projectRepo.list()) {
52
+ const matches = letters.filter((letter) => containsNormalized(letter.current_content ?? '', project.name));
53
+ refs.push({
54
+ kind: 'project',
55
+ entity_id: project.id,
56
+ label: project.name,
57
+ cover_letters: matches.map((letter) => {
58
+ const job = letter.job_id ? jobs.get(letter.job_id) : null;
59
+ return {
60
+ id: letter.id,
61
+ title: letter.title,
62
+ job_id: letter.job_id,
63
+ company: job?.company ?? null,
64
+ position: job?.position ?? null,
65
+ };
66
+ }),
67
+ reuse_count: matches.length,
68
+ });
69
+ }
70
+
71
+ return refs.sort((a, b) => b.reuse_count - a.reuse_count || a.label.localeCompare(b.label));
72
+ }
73
+
74
+ export function diffText(before: string, after: string): DiffLine[] {
75
+ const a = before.replace(/\r\n/g, '\n').split('\n');
76
+ const b = after.replace(/\r\n/g, '\n').split('\n');
77
+ const rows = a.length + 1;
78
+ const cols = b.length + 1;
79
+ const dp = Array.from({ length: rows }, () => new Uint32Array(cols));
80
+
81
+ for (let i = a.length - 1; i >= 0; i--) {
82
+ for (let j = b.length - 1; j >= 0; j--) {
83
+ dp[i]![j] = a[i] === b[j] ? dp[i + 1]![j + 1]! + 1 : Math.max(dp[i + 1]![j]!, dp[i]![j + 1]!);
84
+ }
85
+ }
86
+
87
+ const result: DiffLine[] = [];
88
+ let i = 0;
89
+ let j = 0;
90
+ while (i < a.length && j < b.length) {
91
+ if (a[i] === b[j]) {
92
+ result.push({ type: 'equal', text: a[i]! });
93
+ i++;
94
+ j++;
95
+ } else if (dp[i + 1]![j]! >= dp[i]![j + 1]!) {
96
+ result.push({ type: 'remove', text: a[i]! });
97
+ i++;
98
+ } else {
99
+ result.push({ type: 'add', text: b[j]! });
100
+ j++;
101
+ }
102
+ }
103
+ while (i < a.length) result.push({ type: 'remove', text: a[i++]! });
104
+ while (j < b.length) result.push({ type: 'add', text: b[j++]! });
105
+ return result;
106
+ }
107
+
108
+ export function diffCoverLetterVersions(coverLetterId: string, beforeVersionId: string, afterVersionId: string) {
109
+ const letter = coverLetterRepo.get(coverLetterId, true);
110
+ if (!letter?.versions) return null;
111
+ const before = letter.versions.find((version) => version.id === beforeVersionId);
112
+ const after = letter.versions.find((version) => version.id === afterVersionId);
113
+ if (!before || !after) return null;
114
+ const lines = diffText(before.content, after.content);
115
+ return {
116
+ cover_letter_id: coverLetterId,
117
+ before: { id: before.id, version_no: before.version_no, source: before.source, created_at: before.created_at },
118
+ after: { id: after.id, version_no: after.version_no, source: after.source, created_at: after.created_at },
119
+ summary: {
120
+ added: lines.filter((line) => line.type === 'add').length,
121
+ removed: lines.filter((line) => line.type === 'remove').length,
122
+ unchanged: lines.filter((line) => line.type === 'equal').length,
123
+ },
124
+ lines,
125
+ };
126
+ }
@@ -19,7 +19,8 @@ const PRE_MIGRATE_KEEP = 5;
19
19
  * `VACUUM INTO` a destination path, with the guard both backup callers need. The
20
20
  * data dir can be set via env, so `destPath` isn't fully trusted: reject NUL/newline
21
21
  * (can't be embedded safely in the SQL string literal) and single-quote-escape the
22
- * rest. Throws on an unsafe path so the caller can fall back to fs.copyFileSync.
22
+ * rest. A failure is intentionally surfaced: copying only the live main SQLite
23
+ * file while WAL is active can produce a stale or incomplete snapshot.
23
24
  */
24
25
  export function vacuumInto(db: DatabaseSync, destPath: string): void {
25
26
  if (/[\0\n\r]/.test(destPath)) throw new Error('backup path not safe for SQL literal');
@@ -28,10 +29,9 @@ export function vacuumInto(db: DatabaseSync, destPath: string): void {
28
29
 
29
30
  /**
30
31
  * Snapshot the DB into backups/ right before a schema upgrade runs. The DB is
31
- * the user's only copy, so even though each migration is transactional, a bad
32
- * migration shipped in an update must never be able to destroy data without a
33
- * restore point. Best-effort: a failed backup logs to stderr but does not block
34
- * boot (blocking would take both the dashboard and the MCP server down).
32
+ * the user's only copy, so a schema upgrade must not proceed without a restore
33
+ * point. Operators may explicitly opt out for emergency recovery with
34
+ * CAREERMATE_ALLOW_UNBACKED_MIGRATION=1.
35
35
  */
36
36
  function preMigrationBackup(db: DatabaseSync): void {
37
37
  try {
@@ -48,11 +48,9 @@ function preMigrationBackup(db: DatabaseSync): void {
48
48
  const dir = getBackupsDir();
49
49
  const stamp = new Date().toISOString().replace(/[:.]/g, '-');
50
50
  const dest = path.join(dir, `pre-migrate-v${from}-${stamp}.sqlite`);
51
- try {
52
- vacuumInto(db, dest);
53
- } catch {
54
- fs.copyFileSync(getDbPath(), dest); // WAL is checkpointed on close; copy is a coarse fallback
55
- }
51
+ // Never fall back to copying only the main file while WAL is active. If a
52
+ // consistent snapshot cannot be created, the migration must not start.
53
+ vacuumInto(db, dest);
56
54
 
57
55
  // Prune old snapshots (ISO stamp in the name → lexicographic sort == time sort).
58
56
  // fs.unlinkSync only — rmSync hard-crashes on Node 25/Windows.
@@ -70,9 +68,17 @@ function preMigrationBackup(db: DatabaseSync): void {
70
68
  }
71
69
  }
72
70
  } catch (err) {
73
- // stderr only — stdout is the MCP transport channel.
74
- console.error(
75
- `[careermate] 업그레이드 전 자동 백업 실패(마이그레이션은 계속 진행): ${err instanceof Error ? err.message : String(err)}`,
71
+ const reason = err instanceof Error ? err.message : String(err);
72
+ if (process.env.CAREERMATE_ALLOW_UNBACKED_MIGRATION === '1') {
73
+ console.error(
74
+ `[careermate] 업그레이드 전 자동 백업 실패 — 명시적 우회로 마이그레이션을 계속합니다: ${reason}`,
75
+ );
76
+ return;
77
+ }
78
+ throw new Error(
79
+ `데이터베이스 업그레이드 전 백업을 만들지 못해 시작을 중단했습니다. ` +
80
+ `데이터 위치를 확인하고 다시 시도하세요. 긴급 복구 시에만 ` +
81
+ `CAREERMATE_ALLOW_UNBACKED_MIGRATION=1을 설정할 수 있습니다. 원인: ${reason}`,
76
82
  );
77
83
  }
78
84
  }
@@ -80,13 +86,22 @@ function preMigrationBackup(db: DatabaseSync): void {
80
86
  export function getDb(): DatabaseSync {
81
87
  if (_db) return _db;
82
88
  const db = new DatabaseSync(getDbPath());
83
- db.exec('PRAGMA journal_mode = WAL;');
84
- db.exec('PRAGMA foreign_keys = ON;');
85
- db.exec('PRAGMA busy_timeout = 5000;');
86
- preMigrationBackup(db);
87
- migrate(db);
88
- _db = db;
89
- return db;
89
+ try {
90
+ db.exec('PRAGMA journal_mode = WAL;');
91
+ db.exec('PRAGMA foreign_keys = ON;');
92
+ db.exec('PRAGMA busy_timeout = 5000;');
93
+ preMigrationBackup(db);
94
+ migrate(db);
95
+ _db = db;
96
+ return db;
97
+ } catch (err) {
98
+ try {
99
+ db.close();
100
+ } catch {
101
+ /* best-effort */
102
+ }
103
+ throw err;
104
+ }
90
105
  }
91
106
 
92
107
  export function closeDb(): void {
@@ -99,19 +114,24 @@ export function closeDb(): void {
99
114
  let _inTx = false;
100
115
 
101
116
  /**
102
- * Run `fn` inside a single transaction so multi-statement writes are atomic:
103
- * any thrown error rolls back every statement instead of leaving a half-written
104
- * row. node:sqlite has no transaction() helper, so we drive BEGIN/COMMIT/ROLLBACK
105
- * directly. Re-entrant: a nested tx() call joins the outer transaction (SQLite
106
- * has no nested transactions), so only the outermost call commits or rolls back.
117
+ * Run `fn` inside a single synchronous transaction so multi-statement writes are
118
+ * atomic. Promise-returning callbacks are rejected because committing before an
119
+ * async callback settles would silently break transaction guarantees.
107
120
  */
108
121
  export function tx<T>(fn: () => T): T {
109
- if (_inTx) return fn();
122
+ if (_inTx) {
123
+ const nested = fn();
124
+ if (nested instanceof Promise) throw new TypeError('tx() does not accept async callbacks');
125
+ return nested;
126
+ }
110
127
  const db = getDb();
111
128
  db.exec('BEGIN');
112
129
  _inTx = true;
113
130
  try {
114
131
  const result = fn();
132
+ if (result instanceof Promise) {
133
+ throw new TypeError('tx() does not accept async callbacks');
134
+ }
115
135
  db.exec('COMMIT');
116
136
  return result;
117
137
  } catch (err) {
@@ -7,6 +7,9 @@ export * from './paths.ts';
7
7
  export * from './runtime.ts';
8
8
  export * from './connection.ts';
9
9
  export * from './settings.ts';
10
+ export * from './provenance.ts';
11
+ export * from './interview-retrospectives.ts';
12
+ export * from './job-search.ts';
10
13
  export { migrate, MIGRATIONS } from './schema.ts';
11
14
  export {
12
15
  profileRepo,
@@ -0,0 +1,145 @@
1
+ import { getDb } from './connection.ts';
2
+
3
+ export interface InterviewRetrospective {
4
+ id: string;
5
+ job_id: string;
6
+ interview_round: string | null;
7
+ occurred_at: string | null;
8
+ questions: string[];
9
+ strong_answers: string[];
10
+ weak_answers: string[];
11
+ follow_up_actions: string[];
12
+ notes: string | null;
13
+ created_at: string;
14
+ updated_at: string;
15
+ }
16
+
17
+ function ensureTable(): void {
18
+ getDb().exec(`
19
+ CREATE TABLE IF NOT EXISTS interview_retrospectives (
20
+ id TEXT PRIMARY KEY,
21
+ job_id TEXT NOT NULL REFERENCES jobs(id) ON DELETE CASCADE,
22
+ interview_round TEXT,
23
+ occurred_at TEXT,
24
+ questions TEXT NOT NULL DEFAULT '[]',
25
+ strong_answers TEXT NOT NULL DEFAULT '[]',
26
+ weak_answers TEXT NOT NULL DEFAULT '[]',
27
+ follow_up_actions TEXT NOT NULL DEFAULT '[]',
28
+ notes TEXT,
29
+ created_at TEXT NOT NULL,
30
+ updated_at TEXT NOT NULL
31
+ );
32
+ CREATE INDEX IF NOT EXISTS idx_interview_retrospectives_job
33
+ ON interview_retrospectives(job_id, occurred_at DESC, updated_at DESC);
34
+ DROP TRIGGER IF EXISTS trg_interview_retrospectives_delete_job;
35
+ `);
36
+ }
37
+
38
+ function parseList(value: unknown): string[] {
39
+ if (typeof value !== 'string') return [];
40
+ try {
41
+ const parsed = JSON.parse(value);
42
+ return Array.isArray(parsed) ? parsed.filter((item) => typeof item === 'string') : [];
43
+ } catch {
44
+ return [];
45
+ }
46
+ }
47
+
48
+ function map(row: any): InterviewRetrospective {
49
+ return {
50
+ id: row.id,
51
+ job_id: row.job_id,
52
+ interview_round: row.interview_round,
53
+ occurred_at: row.occurred_at,
54
+ questions: parseList(row.questions),
55
+ strong_answers: parseList(row.strong_answers),
56
+ weak_answers: parseList(row.weak_answers),
57
+ follow_up_actions: parseList(row.follow_up_actions),
58
+ notes: row.notes,
59
+ created_at: row.created_at,
60
+ updated_at: row.updated_at,
61
+ };
62
+ }
63
+
64
+ function id(): string {
65
+ return `retro_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
66
+ }
67
+
68
+ export const interviewRetrospectiveRepo = {
69
+ listAll(limit = 100): InterviewRetrospective[] {
70
+ ensureTable();
71
+ const safeLimit = Math.max(1, Math.min(500, Math.floor(limit)));
72
+ return (
73
+ getDb()
74
+ .prepare(`SELECT * FROM interview_retrospectives ORDER BY occurred_at DESC, updated_at DESC LIMIT ?`)
75
+ .all(safeLimit) as any[]
76
+ ).map(map);
77
+ },
78
+
79
+ listByJob(jobId: string): InterviewRetrospective[] {
80
+ ensureTable();
81
+ return (
82
+ getDb()
83
+ .prepare(`SELECT * FROM interview_retrospectives WHERE job_id = ? ORDER BY occurred_at DESC, updated_at DESC`)
84
+ .all(jobId) as any[]
85
+ ).map(map);
86
+ },
87
+
88
+ save(input: {
89
+ id?: string;
90
+ job_id: string;
91
+ interview_round?: string | null;
92
+ occurred_at?: string | null;
93
+ questions?: string[];
94
+ strong_answers?: string[];
95
+ weak_answers?: string[];
96
+ follow_up_actions?: string[];
97
+ notes?: string | null;
98
+ }): InterviewRetrospective {
99
+ ensureTable();
100
+ const now = new Date().toISOString();
101
+ const recordId = input.id ?? id();
102
+ const existing = getDb().prepare(`SELECT * FROM interview_retrospectives WHERE id = ?`).get(recordId) as any;
103
+ getDb()
104
+ .prepare(`
105
+ INSERT INTO interview_retrospectives (
106
+ id, job_id, interview_round, occurred_at, questions, strong_answers,
107
+ weak_answers, follow_up_actions, notes, created_at, updated_at
108
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
109
+ ON CONFLICT(id) DO UPDATE SET
110
+ job_id = excluded.job_id,
111
+ interview_round = excluded.interview_round,
112
+ occurred_at = excluded.occurred_at,
113
+ questions = excluded.questions,
114
+ strong_answers = excluded.strong_answers,
115
+ weak_answers = excluded.weak_answers,
116
+ follow_up_actions = excluded.follow_up_actions,
117
+ notes = excluded.notes,
118
+ updated_at = excluded.updated_at
119
+ `)
120
+ .run(
121
+ recordId,
122
+ input.job_id,
123
+ input.interview_round ?? existing?.interview_round ?? null,
124
+ input.occurred_at ?? existing?.occurred_at ?? null,
125
+ JSON.stringify(input.questions ?? parseList(existing?.questions)),
126
+ JSON.stringify(input.strong_answers ?? parseList(existing?.strong_answers)),
127
+ JSON.stringify(input.weak_answers ?? parseList(existing?.weak_answers)),
128
+ JSON.stringify(input.follow_up_actions ?? parseList(existing?.follow_up_actions)),
129
+ input.notes ?? existing?.notes ?? null,
130
+ existing?.created_at ?? now,
131
+ now,
132
+ );
133
+ return map(getDb().prepare(`SELECT * FROM interview_retrospectives WHERE id = ?`).get(recordId));
134
+ },
135
+
136
+ remove(retrospectiveId: string): boolean {
137
+ ensureTable();
138
+ return getDb().prepare(`DELETE FROM interview_retrospectives WHERE id = ?`).run(retrospectiveId).changes > 0;
139
+ },
140
+ };
141
+
142
+ // Keep the table available before settings import/export enumerates sqlite_master.
143
+ // This also installs the job-deletion cleanup trigger for databases created before
144
+ // interview retrospectives were introduced.
145
+ ensureTable();
@@ -0,0 +1,100 @@
1
+ import { getDb, tx } from './connection.ts';
2
+
3
+ /**
4
+ * Job-search state, in real tables (not `_meta`) so backup, export and reset cover it:
5
+ * - job_search_seen: first/last sighting of each posting search_jobs returned, keyed by
6
+ * (source, external_id). Wanted/Jumpit lists carry no posting date, so "posted since"
7
+ * for them falls back to first_seen_at. Only board ids and dates, never content.
8
+ * - job_search_preferences: the saved conditions search_jobs uses when called without
9
+ * its own keyword/category (single row).
10
+ */
11
+ function ensureTables(): void {
12
+ getDb().exec(`
13
+ CREATE TABLE IF NOT EXISTS job_search_seen (
14
+ source TEXT NOT NULL,
15
+ external_id TEXT NOT NULL,
16
+ first_seen_at TEXT NOT NULL,
17
+ last_seen_at TEXT NOT NULL,
18
+ PRIMARY KEY (source, external_id)
19
+ );
20
+ CREATE TABLE IF NOT EXISTS job_search_preferences (
21
+ id INTEGER PRIMARY KEY CHECK (id = 1),
22
+ keyword TEXT NOT NULL DEFAULT '',
23
+ remember_categories TEXT NOT NULL DEFAULT '[]',
24
+ saramin_categories TEXT NOT NULL DEFAULT '[]',
25
+ updated_at TEXT NOT NULL
26
+ );
27
+ `);
28
+ }
29
+
30
+ /** A posting not returned by any search for this long is forgotten (and counts as new if it reappears). */
31
+ const FORGET_AFTER_DAYS = 120;
32
+
33
+ /** Record the given postings as seen today and return each one's first-seen date (YYYY-MM-DD), keyed "source:external_id". */
34
+ export function markJobsSeen(items: { source: string; external_id: string }[], today: string): Map<string, string> {
35
+ const seen = new Map<string, string>();
36
+ if (!items.length) return seen;
37
+ const db = getDb();
38
+ const upsert = db.prepare(
39
+ `INSERT INTO job_search_seen (source, external_id, first_seen_at, last_seen_at) VALUES (?, ?, ?, ?)
40
+ ON CONFLICT(source, external_id) DO UPDATE SET last_seen_at = excluded.last_seen_at
41
+ RETURNING first_seen_at`,
42
+ );
43
+ tx(() => {
44
+ for (const it of items) {
45
+ const row = upsert.get(it.source, it.external_id, today, today) as { first_seen_at: string } | undefined;
46
+ seen.set(`${it.source}:${it.external_id}`, row?.first_seen_at ?? today);
47
+ }
48
+ db.prepare(`DELETE FROM job_search_seen WHERE last_seen_at < date(?, ?)`).run(today, `-${FORGET_AFTER_DAYS} days`);
49
+ });
50
+ return seen;
51
+ }
52
+
53
+ export interface JobSearchPreferences {
54
+ keyword: string;
55
+ remember_categories: string[];
56
+ saramin_categories: string[];
57
+ updated_at: string | null;
58
+ }
59
+
60
+ function parseList(value: unknown): string[] {
61
+ try {
62
+ const parsed = JSON.parse(String(value));
63
+ return Array.isArray(parsed) ? parsed.filter((v) => typeof v === 'string') : [];
64
+ } catch {
65
+ return [];
66
+ }
67
+ }
68
+
69
+ export function getJobSearchPreferences(): JobSearchPreferences {
70
+ const row = getDb().prepare(`SELECT * FROM job_search_preferences WHERE id = 1`).get() as
71
+ | Record<string, unknown>
72
+ | undefined;
73
+ return {
74
+ keyword: typeof row?.keyword === 'string' ? row.keyword : '',
75
+ remember_categories: parseList(row?.remember_categories ?? '[]'),
76
+ saramin_categories: parseList(row?.saramin_categories ?? '[]'),
77
+ updated_at: typeof row?.updated_at === 'string' ? row.updated_at : null,
78
+ };
79
+ }
80
+
81
+ /** Only the given fields change; an empty string/array clears that field. */
82
+ export function setJobSearchPreferences(
83
+ prefs: Partial<Omit<JobSearchPreferences, 'updated_at'>>,
84
+ ): JobSearchPreferences {
85
+ const next = { ...getJobSearchPreferences(), ...prefs, updated_at: new Date().toISOString() };
86
+ getDb()
87
+ .prepare(
88
+ `INSERT INTO job_search_preferences (id, keyword, remember_categories, saramin_categories, updated_at)
89
+ VALUES (1, ?, ?, ?, ?)
90
+ ON CONFLICT(id) DO UPDATE SET keyword = excluded.keyword, remember_categories = excluded.remember_categories,
91
+ saramin_categories = excluded.saramin_categories, updated_at = excluded.updated_at`,
92
+ )
93
+ .run(next.keyword, JSON.stringify(next.remember_categories), JSON.stringify(next.saramin_categories), next.updated_at);
94
+ return next;
95
+ }
96
+
97
+ // Backup import accepts only tables that already exist, so create these when @careermate/db
98
+ // loads (same as provenance.ts): a backup from a machine that has searched must restore on a
99
+ // fresh install that never has.
100
+ ensureTables();
@@ -0,0 +1,125 @@
1
+ import { getDb } from './connection.ts';
2
+
3
+ export type ProvenanceSource = 'human' | 'ai' | 'import' | 'document';
4
+
5
+ export interface EntityProvenance {
6
+ entity_type: string;
7
+ entity_id: string;
8
+ source: ProvenanceSource;
9
+ source_document_id: string | null;
10
+ source_excerpt: string | null;
11
+ verified_by_human: boolean;
12
+ verified_at: string | null;
13
+ created_at: string;
14
+ updated_at: string;
15
+ }
16
+
17
+ function ensureTable(): void {
18
+ getDb().exec(`
19
+ CREATE TABLE IF NOT EXISTS entity_provenance (
20
+ entity_type TEXT NOT NULL,
21
+ entity_id TEXT NOT NULL,
22
+ source TEXT NOT NULL CHECK(source IN ('human','ai','import','document')),
23
+ source_document_id TEXT,
24
+ source_excerpt TEXT,
25
+ verified_by_human INTEGER NOT NULL DEFAULT 0,
26
+ verified_at TEXT,
27
+ created_at TEXT NOT NULL,
28
+ updated_at TEXT NOT NULL,
29
+ PRIMARY KEY(entity_type, entity_id)
30
+ );
31
+ CREATE INDEX IF NOT EXISTS idx_entity_provenance_verified
32
+ ON entity_provenance(verified_by_human, updated_at DESC);
33
+ `);
34
+ }
35
+
36
+ function map(row: any): EntityProvenance {
37
+ return {
38
+ entity_type: row.entity_type,
39
+ entity_id: row.entity_id,
40
+ source: row.source,
41
+ source_document_id: row.source_document_id,
42
+ source_excerpt: row.source_excerpt,
43
+ verified_by_human: Number(row.verified_by_human) === 1,
44
+ verified_at: row.verified_at,
45
+ created_at: row.created_at,
46
+ updated_at: row.updated_at,
47
+ };
48
+ }
49
+
50
+ export const provenanceRepo = {
51
+ get(entityType: string, entityId: string): EntityProvenance | null {
52
+ ensureTable();
53
+ const row = getDb()
54
+ .prepare(`SELECT * FROM entity_provenance WHERE entity_type = ? AND entity_id = ?`)
55
+ .get(entityType, entityId);
56
+ return row ? map(row) : null;
57
+ },
58
+
59
+ listUnverified(): EntityProvenance[] {
60
+ ensureTable();
61
+ return (
62
+ getDb()
63
+ .prepare(`SELECT * FROM entity_provenance WHERE verified_by_human = 0 ORDER BY updated_at DESC`)
64
+ .all() as any[]
65
+ ).map(map);
66
+ },
67
+
68
+ save(input: {
69
+ entity_type: string;
70
+ entity_id: string;
71
+ source: ProvenanceSource;
72
+ source_document_id?: string | null;
73
+ source_excerpt?: string | null;
74
+ verified_by_human?: boolean;
75
+ }): EntityProvenance {
76
+ ensureTable();
77
+ const existing = this.get(input.entity_type, input.entity_id);
78
+ const now = new Date().toISOString();
79
+ const verified = input.verified_by_human ?? existing?.verified_by_human ?? input.source === 'human';
80
+ const verifiedAt = verified ? existing?.verified_at ?? now : null;
81
+ getDb()
82
+ .prepare(`
83
+ INSERT INTO entity_provenance (
84
+ entity_type, entity_id, source, source_document_id, source_excerpt,
85
+ verified_by_human, verified_at, created_at, updated_at
86
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
87
+ ON CONFLICT(entity_type, entity_id) DO UPDATE SET
88
+ source = excluded.source,
89
+ source_document_id = excluded.source_document_id,
90
+ source_excerpt = excluded.source_excerpt,
91
+ verified_by_human = excluded.verified_by_human,
92
+ verified_at = excluded.verified_at,
93
+ updated_at = excluded.updated_at
94
+ `)
95
+ .run(
96
+ input.entity_type,
97
+ input.entity_id,
98
+ input.source,
99
+ input.source_document_id ?? existing?.source_document_id ?? null,
100
+ input.source_excerpt ?? existing?.source_excerpt ?? null,
101
+ verified ? 1 : 0,
102
+ verifiedAt,
103
+ existing?.created_at ?? now,
104
+ now,
105
+ );
106
+ return this.get(input.entity_type, input.entity_id)!;
107
+ },
108
+
109
+ setHumanVerified(entityType: string, entityId: string, verified: boolean): EntityProvenance {
110
+ const existing = this.get(entityType, entityId);
111
+ return this.save({
112
+ entity_type: entityType,
113
+ entity_id: entityId,
114
+ source: existing?.source ?? 'human',
115
+ source_document_id: existing?.source_document_id,
116
+ source_excerpt: existing?.source_excerpt,
117
+ verified_by_human: verified,
118
+ });
119
+ },
120
+ };
121
+
122
+ // Backup import discovers allowed tables from sqlite_master before any provenance
123
+ // endpoint is called. Create this sidecar table when @careermate/db loads so a
124
+ // backup made on another machine is accepted on a completely fresh installation.
125
+ ensureTable();
@@ -758,6 +758,7 @@ export const jobRepo = {
758
758
  db.prepare(`DELETE FROM fit_analyses WHERE job_id=?`).run(id);
759
759
  db.prepare(`DELETE FROM applications WHERE job_id=?`).run(id);
760
760
  db.prepare(`DELETE FROM interview_preps WHERE job_id=?`).run(id);
761
+ db.prepare(`DELETE FROM interview_retrospectives WHERE job_id=?`).run(id);
761
762
  db.prepare(`DELETE FROM application_timeline_events WHERE job_id=?`).run(id);
762
763
  // offers has ON DELETE CASCADE, but delete explicitly like the siblings above
763
764
  // (consistent pattern + safe even if PRAGMA foreign_keys is ever off).