careermate 0.0.7 → 0.0.9
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.
- package/AGENTS.md +2 -2
- package/LICENSE +1 -1
- package/README.md +108 -91
- package/THIRD_PARTY_NOTICES.md +2 -1
- package/bin/careermate.mjs +11 -2
- package/dist/career-os/eop/cover-letter.md +13 -6
- package/dist/career-os/eop/interview-prep.md +1 -0
- package/dist/career-os/knowledge/cover-letter.md +7 -3
- package/dist/career-os/knowledge/fit-matching.md +2 -0
- package/dist/career-os/knowledge/verifiers/ai-detection-risk.md +57 -0
- package/dist/career-os/knowledge/verifiers/human-voice.md +7 -2
- package/dist/career-os/knowledge/verifiers/truthfulness.md +1 -0
- package/dist/doctor.mjs +19760 -1018
- package/dist/mcp.mjs +19783 -1035
- package/dist/migrate.mjs +289 -20
- package/dist/public/i18n/en/applications.js +19 -0
- package/dist/public/i18n/en/common.js +10 -0
- package/dist/public/i18n/en/documents.js +0 -10
- package/dist/public/i18n/en/home.js +5 -0
- package/dist/public/i18n/en/jobs.js +18 -1
- package/dist/public/i18n/en/profile.js +0 -5
- package/dist/public/i18n/en/settings.js +5 -2
- package/dist/public/i18n/ko/applications.js +19 -0
- package/dist/public/i18n/ko/common.js +9 -0
- package/dist/public/i18n/ko/documents.js +0 -10
- package/dist/public/i18n/ko/home.js +5 -0
- package/dist/public/i18n/ko/jobs.js +18 -1
- package/dist/public/i18n/ko/profile.js +0 -5
- package/dist/public/i18n/ko/settings.js +5 -2
- package/dist/public/lib.js +101 -0
- package/dist/public/pages/applications.js +95 -15
- package/dist/public/pages/documents.js +20 -14
- package/dist/public/pages/home.js +38 -6
- package/dist/public/pages/interview.js +11 -4
- package/dist/public/pages/jobs.js +71 -1
- package/dist/public/pages/profile.js +2 -3
- package/dist/public/pages/settings.js +2 -2
- package/dist/public/styles.css +52 -4
- package/dist/web.mjs +19001 -758
- package/docs/FAQ.md +13 -3
- package/docs/MCP_TOOLS.md +32 -12
- package/docs/career-os/eop/cover-letter.md +13 -6
- package/docs/career-os/eop/interview-prep.md +1 -0
- package/docs/career-os/knowledge/cover-letter.md +7 -3
- package/docs/career-os/knowledge/fit-matching.md +2 -0
- package/docs/career-os/knowledge/verifiers/ai-detection-risk.md +57 -0
- package/docs/career-os/knowledge/verifiers/human-voice.md +7 -2
- package/docs/career-os/knowledge/verifiers/truthfulness.md +1 -0
- package/package.json +5 -2
- package/packages/core/src/index.ts +2 -0
- package/packages/core/src/jobsearch.ts +190 -0
- package/packages/core/src/offers.ts +41 -0
- package/packages/core/src/services.ts +29 -3
- package/packages/core/src/summary.ts +431 -9
- package/packages/core/src/verify/charcount.ts +87 -0
- package/packages/core/src/verify/index.ts +1 -0
- package/packages/core/src/verify/lint.ts +5 -0
- package/packages/db/src/connection.ts +70 -2
- package/packages/db/src/index.ts +1 -0
- package/packages/db/src/repositories.ts +179 -13
- package/packages/db/src/schema.ts +169 -8
- package/packages/exporters/src/docx.ts +165 -0
- package/packages/exporters/src/index.ts +142 -9
- package/packages/exporters/src/markdown.ts +16 -11
- package/packages/knowledge/src/index.ts +8 -3
- package/packages/mcp-tools/src/result.ts +9 -0
- package/packages/mcp-tools/src/tools.ts +303 -60
- package/packages/parsers/src/documents.ts +7 -1
- package/packages/prompts/src/humanize.ts +4 -0
- package/packages/shared/src/schemas.ts +198 -0
- package/scripts/init.ts +7 -0
|
@@ -4,18 +4,86 @@
|
|
|
4
4
|
* Both the web server and the MCP server import this. SQLite with WAL handles
|
|
5
5
|
* the two-process concurrency (dashboard editing + AI writing) cleanly.
|
|
6
6
|
*/
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import path from 'node:path';
|
|
7
9
|
import { DatabaseSync } from 'node:sqlite';
|
|
8
|
-
import { getDbPath } from './paths.ts';
|
|
9
|
-
import { migrate } from './schema.ts';
|
|
10
|
+
import { getBackupsDir, getDbPath } from './paths.ts';
|
|
11
|
+
import { migrate, MIGRATIONS } from './schema.ts';
|
|
10
12
|
|
|
11
13
|
let _db: DatabaseSync | null = null;
|
|
12
14
|
|
|
15
|
+
/** How many automatic pre-migration snapshots to keep (newest first). */
|
|
16
|
+
const PRE_MIGRATE_KEEP = 5;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* `VACUUM INTO` a destination path, with the guard both backup callers need. The
|
|
20
|
+
* data dir can be set via env, so `destPath` isn't fully trusted: reject NUL/newline
|
|
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.
|
|
23
|
+
*/
|
|
24
|
+
export function vacuumInto(db: DatabaseSync, destPath: string): void {
|
|
25
|
+
if (/[\0\n\r]/.test(destPath)) throw new Error('backup path not safe for SQL literal');
|
|
26
|
+
db.exec(`VACUUM INTO '${destPath.replace(/'/g, "''")}'`);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* 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).
|
|
35
|
+
*/
|
|
36
|
+
function preMigrationBackup(db: DatabaseSync): void {
|
|
37
|
+
try {
|
|
38
|
+
const hasMeta = db
|
|
39
|
+
.prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name='_meta'`)
|
|
40
|
+
.get();
|
|
41
|
+
if (!hasMeta) return; // fresh file — nothing to protect yet
|
|
42
|
+
const row = db.prepare(`SELECT value FROM _meta WHERE key = 'schema_version'`).get() as
|
|
43
|
+
| { value: string }
|
|
44
|
+
| undefined;
|
|
45
|
+
const from = row ? Number(row.value) : 0;
|
|
46
|
+
if (from === 0 || from >= MIGRATIONS.length) return; // fresh, current, or newer — no upgrade will run
|
|
47
|
+
|
|
48
|
+
const dir = getBackupsDir();
|
|
49
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
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
|
+
}
|
|
56
|
+
|
|
57
|
+
// Prune old snapshots (ISO stamp in the name → lexicographic sort == time sort).
|
|
58
|
+
// fs.unlinkSync only — rmSync hard-crashes on Node 25/Windows.
|
|
59
|
+
const stale = fs
|
|
60
|
+
.readdirSync(dir)
|
|
61
|
+
.filter((f) => f.startsWith('pre-migrate-') && f.endsWith('.sqlite'))
|
|
62
|
+
.sort()
|
|
63
|
+
.reverse()
|
|
64
|
+
.slice(PRE_MIGRATE_KEEP);
|
|
65
|
+
for (const f of stale) {
|
|
66
|
+
try {
|
|
67
|
+
fs.unlinkSync(path.join(dir, f));
|
|
68
|
+
} catch {
|
|
69
|
+
/* best-effort */
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
} catch (err) {
|
|
73
|
+
// stderr only — stdout is the MCP transport channel.
|
|
74
|
+
console.error(
|
|
75
|
+
`[careermate] 업그레이드 전 자동 백업 실패(마이그레이션은 계속 진행): ${err instanceof Error ? err.message : String(err)}`,
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
13
80
|
export function getDb(): DatabaseSync {
|
|
14
81
|
if (_db) return _db;
|
|
15
82
|
const db = new DatabaseSync(getDbPath());
|
|
16
83
|
db.exec('PRAGMA journal_mode = WAL;');
|
|
17
84
|
db.exec('PRAGMA foreign_keys = ON;');
|
|
18
85
|
db.exec('PRAGMA busy_timeout = 5000;');
|
|
86
|
+
preMigrationBackup(db);
|
|
19
87
|
migrate(db);
|
|
20
88
|
_db = db;
|
|
21
89
|
return db;
|
package/packages/db/src/index.ts
CHANGED
|
@@ -21,11 +21,14 @@ import {
|
|
|
21
21
|
type CoverLetterVersionRecord,
|
|
22
22
|
type JobInput,
|
|
23
23
|
type JobRecord,
|
|
24
|
+
type OfferInput,
|
|
25
|
+
type OfferRecord,
|
|
24
26
|
type FitAnalysisInput,
|
|
25
27
|
type FitAnalysisRecord,
|
|
26
28
|
type ApplicationInput,
|
|
27
29
|
type ApplicationRecord,
|
|
28
30
|
type ApplicationStatus,
|
|
31
|
+
type RejectedStage,
|
|
29
32
|
type InterviewPrepInput,
|
|
30
33
|
type InterviewPrepRecord,
|
|
31
34
|
type ActivityRecord,
|
|
@@ -64,6 +67,10 @@ function mapProfile(r: any): ProfileRecord {
|
|
|
64
67
|
preferred_tone: r.preferred_tone,
|
|
65
68
|
emphasis_points: fromJson(r.emphasis_points, []),
|
|
66
69
|
links: fromJson(r.links, []),
|
|
70
|
+
education: fromJson(r.education, []),
|
|
71
|
+
certifications: fromJson(r.certifications, []),
|
|
72
|
+
language_scores: fromJson(r.language_scores, []),
|
|
73
|
+
awards: fromJson(r.awards, []),
|
|
67
74
|
created_at: r.created_at,
|
|
68
75
|
updated_at: r.updated_at,
|
|
69
76
|
};
|
|
@@ -92,21 +99,31 @@ export const profileRepo = {
|
|
|
92
99
|
preferred_tone: input.preferred_tone ?? existing?.preferred_tone ?? null,
|
|
93
100
|
emphasis_points: input.emphasis_points ?? existing?.emphasis_points ?? [],
|
|
94
101
|
links: input.links ?? existing?.links ?? [],
|
|
102
|
+
education: input.education ?? existing?.education ?? [],
|
|
103
|
+
certifications: input.certifications ?? existing?.certifications ?? [],
|
|
104
|
+
language_scores: input.language_scores ?? existing?.language_scores ?? [],
|
|
105
|
+
awards: input.awards ?? existing?.awards ?? [],
|
|
95
106
|
created_at: existing?.created_at ?? ts,
|
|
96
107
|
updated_at: ts,
|
|
97
108
|
};
|
|
98
109
|
db.prepare(
|
|
99
|
-
`INSERT INTO profile (id,name,email,phone,location,headline,summary,desired_roles,desired_conditions,preferred_tone,emphasis_points,links,created_at,updated_at)
|
|
100
|
-
VALUES (@id,@name,@email,@phone,@location,@headline,@summary,@desired_roles,@desired_conditions,@preferred_tone,@emphasis_points,@links,@created_at,@updated_at)
|
|
110
|
+
`INSERT INTO profile (id,name,email,phone,location,headline,summary,desired_roles,desired_conditions,preferred_tone,emphasis_points,links,education,certifications,language_scores,awards,created_at,updated_at)
|
|
111
|
+
VALUES (@id,@name,@email,@phone,@location,@headline,@summary,@desired_roles,@desired_conditions,@preferred_tone,@emphasis_points,@links,@education,@certifications,@language_scores,@awards,@created_at,@updated_at)
|
|
101
112
|
ON CONFLICT(id) DO UPDATE SET
|
|
102
113
|
name=@name,email=@email,phone=@phone,location=@location,headline=@headline,summary=@summary,
|
|
103
114
|
desired_roles=@desired_roles,desired_conditions=@desired_conditions,preferred_tone=@preferred_tone,
|
|
104
|
-
emphasis_points=@emphasis_points,links=@links,
|
|
115
|
+
emphasis_points=@emphasis_points,links=@links,
|
|
116
|
+
education=@education,certifications=@certifications,language_scores=@language_scores,awards=@awards,
|
|
117
|
+
updated_at=@updated_at`,
|
|
105
118
|
).run({
|
|
106
119
|
...merged,
|
|
107
120
|
desired_roles: toJson(merged.desired_roles),
|
|
108
121
|
emphasis_points: toJson(merged.emphasis_points),
|
|
109
122
|
links: toJson(merged.links),
|
|
123
|
+
education: toJson(merged.education),
|
|
124
|
+
certifications: toJson(merged.certifications),
|
|
125
|
+
language_scores: toJson(merged.language_scores),
|
|
126
|
+
awards: toJson(merged.awards),
|
|
110
127
|
});
|
|
111
128
|
return merged;
|
|
112
129
|
},
|
|
@@ -642,6 +659,12 @@ function mapJob(r: any): JobRecord {
|
|
|
642
659
|
keywords: fromJson(r.keywords, []),
|
|
643
660
|
deadline: r.deadline,
|
|
644
661
|
source: r.source,
|
|
662
|
+
company_overview: r.company_overview ?? null,
|
|
663
|
+
talent_profile: r.talent_profile ?? null,
|
|
664
|
+
core_values: fromJson(r.core_values, []),
|
|
665
|
+
reputation: r.reputation ?? null,
|
|
666
|
+
questions: fromJson(r.questions, []),
|
|
667
|
+
schedule: fromJson(r.schedule, []),
|
|
645
668
|
created_at: r.created_at,
|
|
646
669
|
updated_at: r.updated_at,
|
|
647
670
|
};
|
|
@@ -674,7 +697,7 @@ export const jobRepo = {
|
|
|
674
697
|
if (existing) {
|
|
675
698
|
const m = { ...existing, ...input } as JobRecord;
|
|
676
699
|
db.prepare(
|
|
677
|
-
`UPDATE jobs SET company=?,position=?,url=?,location=?,employment_type=?,description=?,requirements=?,keywords=?,deadline=?,source=?,updated_at=? WHERE id=?`,
|
|
700
|
+
`UPDATE jobs SET company=?,position=?,url=?,location=?,employment_type=?,description=?,requirements=?,keywords=?,deadline=?,source=?,company_overview=?,talent_profile=?,core_values=?,reputation=?,questions=?,schedule=?,updated_at=? WHERE id=?`,
|
|
678
701
|
).run(
|
|
679
702
|
m.company,
|
|
680
703
|
m.position,
|
|
@@ -686,6 +709,15 @@ export const jobRepo = {
|
|
|
686
709
|
toJson(m.keywords ?? []),
|
|
687
710
|
m.deadline ?? null,
|
|
688
711
|
m.source ?? null,
|
|
712
|
+
m.company_overview ?? null,
|
|
713
|
+
m.talent_profile ?? null,
|
|
714
|
+
toJson(m.core_values ?? []),
|
|
715
|
+
m.reputation ?? null,
|
|
716
|
+
// questions omitted from input → m keeps existing.questions (preserved);
|
|
717
|
+
// provided → replaced. Same merge semantics as the other JSON fields.
|
|
718
|
+
toJson(m.questions ?? []),
|
|
719
|
+
// schedule follows the same preserve-on-omit merge (v11 전형 일정).
|
|
720
|
+
toJson(m.schedule ?? []),
|
|
689
721
|
ts,
|
|
690
722
|
existing.id,
|
|
691
723
|
);
|
|
@@ -693,8 +725,8 @@ export const jobRepo = {
|
|
|
693
725
|
}
|
|
694
726
|
const newJobId = id ?? newId('job_');
|
|
695
727
|
db.prepare(
|
|
696
|
-
`INSERT INTO jobs (id,company,position,url,location,employment_type,description,requirements,keywords,deadline,source,created_at,updated_at)
|
|
697
|
-
VALUES (
|
|
728
|
+
`INSERT INTO jobs (id,company,position,url,location,employment_type,description,requirements,keywords,deadline,source,company_overview,talent_profile,core_values,reputation,questions,schedule,created_at,updated_at)
|
|
729
|
+
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
|
698
730
|
).run(
|
|
699
731
|
newJobId,
|
|
700
732
|
input.company,
|
|
@@ -707,6 +739,12 @@ export const jobRepo = {
|
|
|
707
739
|
toJson(input.keywords ?? []),
|
|
708
740
|
input.deadline ?? null,
|
|
709
741
|
input.source ?? null,
|
|
742
|
+
input.company_overview ?? null,
|
|
743
|
+
input.talent_profile ?? null,
|
|
744
|
+
toJson(input.core_values ?? []),
|
|
745
|
+
input.reputation ?? null,
|
|
746
|
+
toJson(input.questions ?? []),
|
|
747
|
+
toJson(input.schedule ?? []),
|
|
710
748
|
ts,
|
|
711
749
|
ts,
|
|
712
750
|
);
|
|
@@ -721,6 +759,9 @@ export const jobRepo = {
|
|
|
721
759
|
db.prepare(`DELETE FROM applications WHERE job_id=?`).run(id);
|
|
722
760
|
db.prepare(`DELETE FROM interview_preps WHERE job_id=?`).run(id);
|
|
723
761
|
db.prepare(`DELETE FROM application_timeline_events WHERE job_id=?`).run(id);
|
|
762
|
+
// offers has ON DELETE CASCADE, but delete explicitly like the siblings above
|
|
763
|
+
// (consistent pattern + safe even if PRAGMA foreign_keys is ever off).
|
|
764
|
+
db.prepare(`DELETE FROM offers WHERE job_id=?`).run(id);
|
|
724
765
|
return db.prepare(`DELETE FROM jobs WHERE id=?`).run(id).changes > 0;
|
|
725
766
|
});
|
|
726
767
|
},
|
|
@@ -804,6 +845,8 @@ function mapApplication(r: any): ApplicationRecord {
|
|
|
804
845
|
cover_letter_id: r.cover_letter_id,
|
|
805
846
|
applied_at: r.applied_at,
|
|
806
847
|
notes: r.notes,
|
|
848
|
+
// 탈락 회고: 탈락이 아니거나 미기록이면 NULL(v10 이전 DB는 컬럼이 없어 undefined → null 정규화).
|
|
849
|
+
rejected_stage: r.rejected_stage ?? null,
|
|
807
850
|
created_at: r.created_at,
|
|
808
851
|
updated_at: r.updated_at,
|
|
809
852
|
};
|
|
@@ -846,25 +889,39 @@ export const applicationRepo = {
|
|
|
846
889
|
// A status change through upsert must respect the same lifecycle gate as setStatus.
|
|
847
890
|
if (input.status && input.status !== app.status) assertStatusTransition(app.status, input.status);
|
|
848
891
|
const m = { ...app, ...input } as ApplicationRecord;
|
|
892
|
+
// rejected_stage is only meaningful while status is 'rejected' (same invariant as
|
|
893
|
+
// setStatus): clear it on any transition back out, preserve it when staying
|
|
894
|
+
// rejected. ApplicationInput carries no rejected_stage, so m keeps the stored value.
|
|
895
|
+
const rejectedStageValue = m.status === 'rejected' ? m.rejected_stage ?? null : null;
|
|
849
896
|
getDb()
|
|
850
897
|
.prepare(
|
|
851
|
-
`UPDATE applications SET status=?,resume_id=?,cover_letter_id=?,applied_at=?,notes=?,updated_at=? WHERE id=?`,
|
|
898
|
+
`UPDATE applications SET status=?,resume_id=?,cover_letter_id=?,applied_at=?,notes=?,rejected_stage=?,updated_at=? WHERE id=?`,
|
|
852
899
|
)
|
|
853
900
|
// Coerce undefined → null: node:sqlite rejects undefined bindings, and a
|
|
854
901
|
// caller that spreads explicit `undefined` fields (e.g. updateApplicationStatus
|
|
855
902
|
// with a doc-less submission) would otherwise crash here mid-write.
|
|
856
|
-
.run(m.status, m.resume_id ?? null, m.cover_letter_id ?? null, m.applied_at ?? null, m.notes ?? null, now(), app.id);
|
|
903
|
+
.run(m.status, m.resume_id ?? null, m.cover_letter_id ?? null, m.applied_at ?? null, m.notes ?? null, rejectedStageValue, now(), app.id);
|
|
857
904
|
return this.get(app.id)!;
|
|
858
905
|
},
|
|
859
|
-
setStatus(
|
|
906
|
+
setStatus(
|
|
907
|
+
jobId: string,
|
|
908
|
+
status: ApplicationStatus,
|
|
909
|
+
note?: string,
|
|
910
|
+
rejectedStage?: RejectedStage,
|
|
911
|
+
): ApplicationRecord {
|
|
860
912
|
const app = this.ensure(jobId);
|
|
861
913
|
assertStatusTransition(app.status, status);
|
|
862
914
|
const appliedAt =
|
|
863
915
|
status === 'applied' && !app.applied_at ? now() : app.applied_at;
|
|
864
916
|
const notes = note ? [app.notes, note].filter(Boolean).join('\n') : app.notes;
|
|
917
|
+
// 탈락 회고: 'rejected'일 때만 단계를 저장한다. 명시 값이 오면 갱신하고, 없으면 기존 값을
|
|
918
|
+
// 보존해(같은 rejected에 메모만 추가하는 등) 이미 남긴 회고를 지우지 않는다. rejected에서 다른
|
|
919
|
+
// 상태로 되살아나면 NULL로 초기화한다(정합성 — 탈락이 아닌데 단계가 남아 있으면 안 됨).
|
|
920
|
+
const rejectedStageValue =
|
|
921
|
+
status === 'rejected' ? rejectedStage ?? app.rejected_stage ?? null : null;
|
|
865
922
|
getDb()
|
|
866
|
-
.prepare(`UPDATE applications SET status=?, applied_at=?, notes=?, updated_at=? WHERE id=?`)
|
|
867
|
-
.run(status, appliedAt, notes, now(), app.id);
|
|
923
|
+
.prepare(`UPDATE applications SET status=?, applied_at=?, notes=?, rejected_stage=?, updated_at=? WHERE id=?`)
|
|
924
|
+
.run(status, appliedAt, notes, rejectedStageValue, now(), app.id);
|
|
868
925
|
return this.get(app.id)!;
|
|
869
926
|
},
|
|
870
927
|
};
|
|
@@ -872,6 +929,9 @@ export const applicationRepo = {
|
|
|
872
929
|
/* --------------------------------------------------------- Interview preps */
|
|
873
930
|
|
|
874
931
|
function mapInterviewPrep(r: any): InterviewPrepRecord {
|
|
932
|
+
// debrief defaults to '{}' at the DB level; surface an empty debrief as null so
|
|
933
|
+
// consumers can treat "no debrief yet" uniformly.
|
|
934
|
+
const debrief = fromJson(r.debrief, null);
|
|
875
935
|
return {
|
|
876
936
|
id: r.id,
|
|
877
937
|
job_id: r.job_id,
|
|
@@ -879,6 +939,7 @@ function mapInterviewPrep(r: any): InterviewPrepRecord {
|
|
|
879
939
|
star_guides: fromJson(r.star_guides, []),
|
|
880
940
|
self_introduction: r.self_introduction,
|
|
881
941
|
notes: r.notes,
|
|
942
|
+
debrief: debrief && Object.keys(debrief).length ? debrief : null,
|
|
882
943
|
created_at: r.created_at,
|
|
883
944
|
updated_at: r.updated_at,
|
|
884
945
|
};
|
|
@@ -900,13 +961,19 @@ export const interviewRepo = {
|
|
|
900
961
|
const ts = now();
|
|
901
962
|
if (existing) {
|
|
902
963
|
const m = { ...existing, ...input } as InterviewPrepRecord;
|
|
964
|
+
// Debrief is shallow-merged (not replaced) so a later partial debrief save
|
|
965
|
+
// doesn't wipe earlier fields; prep (pre) and debrief (post) coexist per job.
|
|
966
|
+
const mergedDebrief = input.debrief
|
|
967
|
+
? { ...(existing.debrief ?? {}), ...input.debrief }
|
|
968
|
+
: existing.debrief;
|
|
903
969
|
db.prepare(
|
|
904
|
-
`UPDATE interview_preps SET questions=?,star_guides=?,self_introduction=?,notes=?,updated_at=? WHERE id=?`,
|
|
970
|
+
`UPDATE interview_preps SET questions=?,star_guides=?,self_introduction=?,notes=?,debrief=?,updated_at=? WHERE id=?`,
|
|
905
971
|
).run(
|
|
906
972
|
toJson(m.questions ?? []),
|
|
907
973
|
toJson(m.star_guides ?? []),
|
|
908
974
|
m.self_introduction ?? null,
|
|
909
975
|
m.notes ?? null,
|
|
976
|
+
toJson(mergedDebrief ?? {}),
|
|
910
977
|
ts,
|
|
911
978
|
existing.id,
|
|
912
979
|
);
|
|
@@ -914,7 +981,7 @@ export const interviewRepo = {
|
|
|
914
981
|
}
|
|
915
982
|
const id = newId('itv_');
|
|
916
983
|
db.prepare(
|
|
917
|
-
`INSERT INTO interview_preps (id,job_id,questions,star_guides,self_introduction,notes,created_at,updated_at) VALUES (
|
|
984
|
+
`INSERT INTO interview_preps (id,job_id,questions,star_guides,self_introduction,notes,debrief,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,?)`,
|
|
918
985
|
).run(
|
|
919
986
|
id,
|
|
920
987
|
input.job_id,
|
|
@@ -922,6 +989,7 @@ export const interviewRepo = {
|
|
|
922
989
|
toJson(input.star_guides ?? []),
|
|
923
990
|
input.self_introduction ?? null,
|
|
924
991
|
input.notes ?? null,
|
|
992
|
+
toJson(input.debrief ?? {}),
|
|
925
993
|
ts,
|
|
926
994
|
ts,
|
|
927
995
|
);
|
|
@@ -1001,6 +1069,102 @@ export const timelineRepo = {
|
|
|
1001
1069
|
},
|
|
1002
1070
|
};
|
|
1003
1071
|
|
|
1072
|
+
/* -------------------------------------------------------------------- Offers */
|
|
1073
|
+
|
|
1074
|
+
function mapOffer(r: any): OfferRecord {
|
|
1075
|
+
const base = r.base_salary ?? null;
|
|
1076
|
+
const bonus = r.bonus_amount ?? null;
|
|
1077
|
+
const welfare = r.welfare_amount ?? null;
|
|
1078
|
+
// Annual cash estimate = base + bonus + welfare (signing is one-time → excluded).
|
|
1079
|
+
// null when no numeric cash piece was provided (can't estimate).
|
|
1080
|
+
const hasAny = base != null || bonus != null || welfare != null;
|
|
1081
|
+
const total = hasAny ? (base ?? 0) + (bonus ?? 0) + (welfare ?? 0) : null;
|
|
1082
|
+
return {
|
|
1083
|
+
id: r.id,
|
|
1084
|
+
job_id: r.job_id,
|
|
1085
|
+
offer_status: r.offer_status ?? 'open',
|
|
1086
|
+
base_salary: base,
|
|
1087
|
+
bonus_amount: bonus,
|
|
1088
|
+
welfare_amount: welfare,
|
|
1089
|
+
signing_amount: r.signing_amount ?? null,
|
|
1090
|
+
equity_note: r.equity_note ?? null,
|
|
1091
|
+
comp_note: r.comp_note ?? null,
|
|
1092
|
+
work_arrangement: r.work_arrangement ?? null,
|
|
1093
|
+
contract_type: r.contract_type ?? null,
|
|
1094
|
+
accept_deadline: r.accept_deadline ?? null,
|
|
1095
|
+
ai_score: r.ai_score ?? null,
|
|
1096
|
+
verdict: r.verdict ?? null,
|
|
1097
|
+
notes: r.notes ?? null,
|
|
1098
|
+
total_comp_annual_est: total,
|
|
1099
|
+
created_at: r.created_at,
|
|
1100
|
+
updated_at: r.updated_at,
|
|
1101
|
+
};
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
export const offerRepo = {
|
|
1105
|
+
getByJob(jobId: string): OfferRecord | null {
|
|
1106
|
+
const r = getDb().prepare(`SELECT * FROM offers WHERE job_id=?`).get(jobId);
|
|
1107
|
+
return r ? mapOffer(r) : null;
|
|
1108
|
+
},
|
|
1109
|
+
list(): OfferRecord[] {
|
|
1110
|
+
return (getDb().prepare(`SELECT * FROM offers ORDER BY ai_score IS NULL, ai_score DESC, updated_at DESC`).all() as any[]).map(mapOffer);
|
|
1111
|
+
},
|
|
1112
|
+
/** Upsert by job_id (one offer per job). Missing fields are preserved on update (partial-safe). */
|
|
1113
|
+
save(input: OfferInput): OfferRecord {
|
|
1114
|
+
const db = getDb();
|
|
1115
|
+
const existing = this.getByJob(input.job_id);
|
|
1116
|
+
const ts = now();
|
|
1117
|
+
const n = (v: number | undefined, prev: number | null | undefined) => (v ?? prev ?? null);
|
|
1118
|
+
const s = (v: string | undefined, prev: string | null | undefined) => (v ?? prev ?? null);
|
|
1119
|
+
if (existing) {
|
|
1120
|
+
db.prepare(
|
|
1121
|
+
`UPDATE offers SET offer_status=?,base_salary=?,bonus_amount=?,welfare_amount=?,signing_amount=?,equity_note=?,comp_note=?,work_arrangement=?,contract_type=?,accept_deadline=?,ai_score=?,verdict=?,notes=?,updated_at=? WHERE id=?`,
|
|
1122
|
+
).run(
|
|
1123
|
+
input.offer_status ?? existing.offer_status,
|
|
1124
|
+
n(input.base_salary, existing.base_salary),
|
|
1125
|
+
n(input.bonus_amount, existing.bonus_amount),
|
|
1126
|
+
n(input.welfare_amount, existing.welfare_amount),
|
|
1127
|
+
n(input.signing_amount, existing.signing_amount),
|
|
1128
|
+
s(input.equity_note, existing.equity_note),
|
|
1129
|
+
s(input.comp_note, existing.comp_note),
|
|
1130
|
+
s(input.work_arrangement, existing.work_arrangement),
|
|
1131
|
+
s(input.contract_type, existing.contract_type),
|
|
1132
|
+
s(input.accept_deadline, existing.accept_deadline),
|
|
1133
|
+
n(input.ai_score, existing.ai_score),
|
|
1134
|
+
s(input.verdict, existing.verdict),
|
|
1135
|
+
s(input.notes, existing.notes),
|
|
1136
|
+
ts,
|
|
1137
|
+
existing.id,
|
|
1138
|
+
);
|
|
1139
|
+
return this.getByJob(input.job_id)!;
|
|
1140
|
+
}
|
|
1141
|
+
const id = newId('ofr_');
|
|
1142
|
+
db.prepare(
|
|
1143
|
+
`INSERT INTO offers (id,job_id,offer_status,base_salary,bonus_amount,welfare_amount,signing_amount,equity_note,comp_note,work_arrangement,contract_type,accept_deadline,ai_score,verdict,notes,created_at,updated_at)
|
|
1144
|
+
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
|
1145
|
+
).run(
|
|
1146
|
+
id,
|
|
1147
|
+
input.job_id,
|
|
1148
|
+
input.offer_status ?? 'open',
|
|
1149
|
+
input.base_salary ?? null,
|
|
1150
|
+
input.bonus_amount ?? null,
|
|
1151
|
+
input.welfare_amount ?? null,
|
|
1152
|
+
input.signing_amount ?? null,
|
|
1153
|
+
input.equity_note ?? null,
|
|
1154
|
+
input.comp_note ?? null,
|
|
1155
|
+
input.work_arrangement ?? null,
|
|
1156
|
+
input.contract_type ?? null,
|
|
1157
|
+
input.accept_deadline ?? null,
|
|
1158
|
+
input.ai_score ?? null,
|
|
1159
|
+
input.verdict ?? null,
|
|
1160
|
+
input.notes ?? null,
|
|
1161
|
+
ts,
|
|
1162
|
+
ts,
|
|
1163
|
+
);
|
|
1164
|
+
return this.getByJob(input.job_id)!;
|
|
1165
|
+
},
|
|
1166
|
+
};
|
|
1167
|
+
|
|
1004
1168
|
/* ------------------------------------------------------------------ Counts */
|
|
1005
1169
|
|
|
1006
1170
|
/**
|
|
@@ -1024,6 +1188,7 @@ export function getEntityCounts(): Record<string, number> {
|
|
|
1024
1188
|
'applications',
|
|
1025
1189
|
'interview_preps',
|
|
1026
1190
|
'application_timeline_events',
|
|
1191
|
+
'offers',
|
|
1027
1192
|
]);
|
|
1028
1193
|
const c = (t: string): number => {
|
|
1029
1194
|
if (!ALLOWED_TABLES.has(t)) throw new Error(`getEntityCounts: 허용되지 않은 테이블 이름: ${t}`);
|
|
@@ -1040,5 +1205,6 @@ export function getEntityCounts(): Record<string, number> {
|
|
|
1040
1205
|
applications: c('applications'),
|
|
1041
1206
|
interview_preps: c('interview_preps'),
|
|
1042
1207
|
application_timeline_events: c('application_timeline_events'),
|
|
1208
|
+
offers: c('offers'),
|
|
1043
1209
|
};
|
|
1044
1210
|
}
|
|
@@ -5,6 +5,16 @@
|
|
|
5
5
|
* in `_meta`. Applying is idempotent and safe to run on every process start, so
|
|
6
6
|
* both the web server and the MCP server can call `migrate()` on boot and share
|
|
7
7
|
* the same database file without coordination.
|
|
8
|
+
*
|
|
9
|
+
* Crash safety (this DB is the user's only copy):
|
|
10
|
+
* - Each migration runs in its own transaction and `schema_version` is stamped
|
|
11
|
+
* inside that same transaction, so a step either fully applies (including its
|
|
12
|
+
* version bump) or fully rolls back — a process death mid-upgrade can never
|
|
13
|
+
* leave "applied but unrecorded" work that re-throws on the next boot.
|
|
14
|
+
* - `ALTER TABLE … ADD COLUMN` statements are skipped when the column already
|
|
15
|
+
* exists (SQLite has no IF NOT EXISTS for columns), so a DB damaged by the
|
|
16
|
+
* pre-hardening runner self-heals instead of crash-looping on boot.
|
|
17
|
+
* - A DB stamped by a NEWER CareerMate is left untouched (never stamped down).
|
|
8
18
|
*/
|
|
9
19
|
import type { DatabaseSync } from 'node:sqlite';
|
|
10
20
|
|
|
@@ -300,6 +310,96 @@ export const MIGRATIONS: string[] = [
|
|
|
300
310
|
|
|
301
311
|
CREATE INDEX IF NOT EXISTS idx_timeline_job ON application_timeline_events(job_id, occurred_at ASC);
|
|
302
312
|
`,
|
|
313
|
+
|
|
314
|
+
// v4 — structured quantitative credentials on the profile (학력·자격증·어학점수·수상).
|
|
315
|
+
// Stored as JSON arrays in TEXT columns (same pattern as profile.links), so the AI
|
|
316
|
+
// can cite exact values (GPA, TOEIC 920) without re-asking or hallucinating, and
|
|
317
|
+
// they feed the verify corpus. ALTER ADD COLUMN is non-destructive and idempotent
|
|
318
|
+
// across the ordered migration runner.
|
|
319
|
+
`
|
|
320
|
+
ALTER TABLE profile ADD COLUMN education TEXT NOT NULL DEFAULT '[]';
|
|
321
|
+
ALTER TABLE profile ADD COLUMN certifications TEXT NOT NULL DEFAULT '[]';
|
|
322
|
+
ALTER TABLE profile ADD COLUMN language_scores TEXT NOT NULL DEFAULT '[]';
|
|
323
|
+
ALTER TABLE profile ADD COLUMN awards TEXT NOT NULL DEFAULT '[]';
|
|
324
|
+
`,
|
|
325
|
+
|
|
326
|
+
// v5 — company research on the job posting (사업개요·인재상·핵심가치). Korea-centric: cover
|
|
327
|
+
// letters and interview prep align to a company's 인재상/핵심가치, so store it where the AI
|
|
328
|
+
// already reads it (the job context) rather than a separate entity. Additive ALTER, idempotent.
|
|
329
|
+
`
|
|
330
|
+
ALTER TABLE jobs ADD COLUMN company_overview TEXT;
|
|
331
|
+
ALTER TABLE jobs ADD COLUMN talent_profile TEXT;
|
|
332
|
+
ALTER TABLE jobs ADD COLUMN core_values TEXT NOT NULL DEFAULT '[]';
|
|
333
|
+
`,
|
|
334
|
+
|
|
335
|
+
// v6 — post-interview debrief on interview_preps (받은 질문·잘된 점·개선점·다음 라운드 포커스·
|
|
336
|
+
// 체감 분위기). Closes the loop: prep is pre-interview, debrief is post-interview, but both belong
|
|
337
|
+
// to the same per-job entity, so store as one JSON column rather than a new table. Additive ALTER,
|
|
338
|
+
// idempotent. Feeds next-round prep and consistency (lessons carry into later interviews).
|
|
339
|
+
`
|
|
340
|
+
ALTER TABLE interview_preps ADD COLUMN debrief TEXT NOT NULL DEFAULT '{}';
|
|
341
|
+
`,
|
|
342
|
+
|
|
343
|
+
// v7 — offer comparison (한국 총보상 필드) + company reputation research field on jobs.
|
|
344
|
+
// offers: one per job (UNIQUE job_id, mirrors interview_preps). Cash pieces are numeric (만원) so
|
|
345
|
+
// the server can deterministically sum an annual estimate; equity/other stay free-text (comp is
|
|
346
|
+
// messy). ai_score/verdict are written by the connected AI at save time (no LLM here) and rendered
|
|
347
|
+
// by the dashboard, so it never needs to call an LLM. jobs.reputation: AI-researched company
|
|
348
|
+
// reputation summary (same pattern as company_overview) — feeds offer verdicts + offer-evaluation.
|
|
349
|
+
`
|
|
350
|
+
CREATE TABLE IF NOT EXISTS offers (
|
|
351
|
+
id TEXT PRIMARY KEY,
|
|
352
|
+
job_id TEXT NOT NULL UNIQUE REFERENCES jobs(id) ON DELETE CASCADE,
|
|
353
|
+
base_salary INTEGER,
|
|
354
|
+
bonus_amount INTEGER,
|
|
355
|
+
welfare_amount INTEGER,
|
|
356
|
+
signing_amount INTEGER,
|
|
357
|
+
equity_note TEXT,
|
|
358
|
+
comp_note TEXT,
|
|
359
|
+
work_arrangement TEXT,
|
|
360
|
+
contract_type TEXT,
|
|
361
|
+
accept_deadline TEXT,
|
|
362
|
+
ai_score INTEGER,
|
|
363
|
+
verdict TEXT,
|
|
364
|
+
notes TEXT,
|
|
365
|
+
created_at TEXT NOT NULL,
|
|
366
|
+
updated_at TEXT NOT NULL
|
|
367
|
+
);
|
|
368
|
+
CREATE INDEX IF NOT EXISTS idx_offers_job ON offers(job_id);
|
|
369
|
+
ALTER TABLE jobs ADD COLUMN reputation TEXT;
|
|
370
|
+
`,
|
|
371
|
+
|
|
372
|
+
// v8 — offer lifecycle status. 지원 상태(전형 깔때기 = 회사의 프로세스)와 직교하는
|
|
373
|
+
// "내 응답" 축: open(진행)/accepted(수락)/declined(거절). declined여도 행을 지우지
|
|
374
|
+
// 않는다 — 조건(연봉·처우) 히스토리는 다음 협상의 자산이라, 대시보드가 접힌
|
|
375
|
+
// '지난 오퍼'로 강등해 보여줄 뿐이다. Additive ALTER (runner가 멱등 처리).
|
|
376
|
+
`
|
|
377
|
+
ALTER TABLE offers ADD COLUMN offer_status TEXT NOT NULL DEFAULT 'open';
|
|
378
|
+
`,
|
|
379
|
+
|
|
380
|
+
// v9 — 한국 문항형 자소서: 회사가 지정한 문항·글자수를 공고에 구조화 보관. 한국 자소서는
|
|
381
|
+
// 회사별 문항형(항목·글자수 제한)이 지배적이라, AI가 문항별로 답변을 쓰고 글자수를 점검할 때
|
|
382
|
+
// 참조할 원본을 공고 옆에 둔다(별도 엔티티가 아니라 job 컨텍스트에 함께 실린다). JSON 배열을
|
|
383
|
+
// TEXT에 저장(다른 JSON 필드와 동일). Additive ALTER (runner가 멱등 처리).
|
|
384
|
+
`
|
|
385
|
+
ALTER TABLE jobs ADD COLUMN questions TEXT NOT NULL DEFAULT '[]';
|
|
386
|
+
`,
|
|
387
|
+
|
|
388
|
+
// v10 — 탈락 회고: 어느 전형 단계에서 떨어졌는지 자가 기록. 탈락 사유를 고지하는 기업은 6%뿐이고
|
|
389
|
+
// 지원자 83%가 알고 싶어하므로, 이 자가 기록이 "서류는 붙는데 면접에서 떨어진다" 같은 패턴을 로컬
|
|
390
|
+
// 데이터로 드러내는 유일한 신호다. 탈락이 아니거나 미기록이면 NULL. Additive ALTER (runner가 멱등 처리).
|
|
391
|
+
`
|
|
392
|
+
ALTER TABLE applications ADD COLUMN rejected_stage TEXT;
|
|
393
|
+
`,
|
|
394
|
+
|
|
395
|
+
// v11 — 전형 일정: 서류마감→인적성·AI역검→1·2차 면접→발표로 이어지는 단계별 날짜를 공고에
|
|
396
|
+
// 구조화 보관. 일정을 jobs에 두는 이유: 서류마감(deadline)이 이미 jobs에 있고, save_job_posting
|
|
397
|
+
// 업서트를 그대로 재사용해 도구 신설 없이 갱신할 수 있다. 면접초대 메일·발표 공지를 사용자가
|
|
398
|
+
// 붙여넣으면 AI가 날짜를 추출해 저장하고, 서버가 D-day·같은 날짜 충돌을 결정론으로 계산한다.
|
|
399
|
+
// JSON 배열을 TEXT에 저장(다른 JSON 필드와 동일). Additive ALTER (runner가 멱등 처리).
|
|
400
|
+
`
|
|
401
|
+
ALTER TABLE jobs ADD COLUMN schedule TEXT NOT NULL DEFAULT '[]';
|
|
402
|
+
`,
|
|
303
403
|
];
|
|
304
404
|
|
|
305
405
|
export function migrate(db: DatabaseSync): { from: number; to: number } {
|
|
@@ -309,16 +409,77 @@ export function migrate(db: DatabaseSync): { from: number; to: number } {
|
|
|
309
409
|
| undefined;
|
|
310
410
|
const from = row ? Number(row.value) : 0;
|
|
311
411
|
|
|
412
|
+
// DB stamped by a newer CareerMate than this code: leave it alone. Stamping it
|
|
413
|
+
// back down would make the newer app re-run migrations it already applied.
|
|
414
|
+
if (from >= MIGRATIONS.length) return { from, to: from };
|
|
415
|
+
|
|
312
416
|
for (let v = from; v < MIGRATIONS.length; v++) {
|
|
313
|
-
db
|
|
417
|
+
applyMigration(db, v);
|
|
418
|
+
}
|
|
419
|
+
return { from, to: MIGRATIONS.length };
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function stampVersion(db: DatabaseSync, version: number): void {
|
|
423
|
+
db.prepare(
|
|
424
|
+
`INSERT INTO _meta (key, value) VALUES ('schema_version', ?)
|
|
425
|
+
ON CONFLICT(key) DO UPDATE SET value = excluded.value`,
|
|
426
|
+
).run(String(version));
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function columnExists(db: DatabaseSync, table: string, column: string): boolean {
|
|
430
|
+
const rows = db
|
|
431
|
+
.prepare(`PRAGMA table_info("${table.replaceAll('"', '""')}")`)
|
|
432
|
+
.all() as Array<{ name: string }>;
|
|
433
|
+
return rows.some((r) => r.name === column);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// Migrations that manage their own BEGIN/COMMIT, by zero-based index into MIGRATIONS.
|
|
437
|
+
// Only v2 (index 1) does: its FK rebuild toggles PRAGMA foreign_keys, a no-op inside
|
|
438
|
+
// an open transaction, so the runner must NOT wrap it. Register any future
|
|
439
|
+
// self-transactional migration here (a plain `sql.includes('BEGIN;')` sniff would
|
|
440
|
+
// misfire if a normal migration ever contained the substring, e.g. in a comment).
|
|
441
|
+
const SELF_TRANSACTIONAL_MIGRATIONS = new Set<number>([1]);
|
|
442
|
+
|
|
443
|
+
// Style contract for column-adding migrations: every `ALTER TABLE <t> ADD COLUMN <c>
|
|
444
|
+
// …;` MUST be a single statement on its own line so this regex can find it and the
|
|
445
|
+
// idempotent-runner can drop it when the column already exists. A multi-column or
|
|
446
|
+
// inline-with-other-DDL ALTER would slip past skipExistingColumns and crash a
|
|
447
|
+
// partial-migration re-run. scripts/test-migrate.ts guards this with a count check
|
|
448
|
+
// (loose ALTER…ADD COLUMN scan === strict per-line matches). Keep new ALTERs one-per-line.
|
|
449
|
+
const ALTER_ADD_COLUMN_RE = /^[ \t]*ALTER TABLE\s+(\w+)\s+ADD COLUMN\s+(\w+)[^;]*;[ \t]*$/gm;
|
|
450
|
+
export { ALTER_ADD_COLUMN_RE };
|
|
451
|
+
|
|
452
|
+
/** Drop ADD COLUMN statements whose column already exists (idempotent re-run). */
|
|
453
|
+
function skipExistingColumns(db: DatabaseSync, sql: string): string {
|
|
454
|
+
return sql.replace(ALTER_ADD_COLUMN_RE, (stmt, table: string, column: string) =>
|
|
455
|
+
columnExists(db, table, column) ? '' : stmt,
|
|
456
|
+
);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
function applyMigration(db: DatabaseSync, v: number): void {
|
|
460
|
+
const sql = MIGRATIONS[v]!;
|
|
461
|
+
|
|
462
|
+
if (SELF_TRANSACTIONAL_MIGRATIONS.has(v)) {
|
|
463
|
+
// Self-transactional migration (v2 toggles PRAGMA foreign_keys, which is a
|
|
464
|
+
// no-op inside an open transaction, so it must manage BEGIN/COMMIT itself).
|
|
465
|
+
// Stamp right after; if the process dies in the tiny window between the two
|
|
466
|
+
// statements, re-running the rebuild is safe (it copies the data again).
|
|
467
|
+
db.exec(sql);
|
|
468
|
+
stampVersion(db, v + 1);
|
|
469
|
+
return;
|
|
314
470
|
}
|
|
315
471
|
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
db.
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
472
|
+
db.exec('BEGIN');
|
|
473
|
+
try {
|
|
474
|
+
db.exec(skipExistingColumns(db, sql));
|
|
475
|
+
stampVersion(db, v + 1);
|
|
476
|
+
db.exec('COMMIT');
|
|
477
|
+
} catch (err) {
|
|
478
|
+
try {
|
|
479
|
+
db.exec('ROLLBACK');
|
|
480
|
+
} catch {
|
|
481
|
+
/* surface the original error */
|
|
482
|
+
}
|
|
483
|
+
throw err;
|
|
322
484
|
}
|
|
323
|
-
return { from, to };
|
|
324
485
|
}
|