rag-memory-epf-mcp 3.5.2 → 4.0.0

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.
@@ -0,0 +1,185 @@
1
+ import { existsSync, openSync, readSync, closeSync, linkSync, unlinkSync } from 'node:fs';
2
+ import { createHash } from 'node:crypto';
3
+ import Database from 'better-sqlite3';
4
+ // 이 파일이 지켜야 하는 것은 두 줄이다:
5
+ // ① 복구점을 절대 덮어쓰지 않는다
6
+ // ② 재시작을 막지 않는다
7
+ //
8
+ // 이걸 "남아 있는 .bak 이 live 와 같은 상태인가"를 증명해서 풀려고 세 판본을 썼고
9
+ // 세 번 다 틀렸다(스키마 버전을 신원으로 착각 → DB 내부 UUID+집계 지문 → 전체 논리
10
+ // 다이제스트). 등가 증명은 애초에 필요하지 않았다: **백업은 스키마를 바꾸기 전에
11
+ // 만들어지므로, 시도마다 다음 빈 슬롯에 하나 더 만들면 그 모든 파일이 정의상 유효한
12
+ // pre-migration 스냅샷이다.** 덮어쓰지 않으니 ①, 막지 않으니 ②. 비교가 없으니
13
+ // 비교 정확성 문제가 전부 사라진다. (advisor beta r2~r7 결론)
14
+ //
15
+ // 대가 = 디스크. 슬롯을 유한하게 두고 다 차면 fail-closed 한다.
16
+ const MAX_RECOVERY_POINTS = 3;
17
+ // spec §5.1: 마이그레이션 전 일관 스냅샷.
18
+ //
19
+ // **`VACUUM INTO` 를 쓰지 않는다.** SQLite 문서는 VACUUM 이 명시적
20
+ // `INTEGER PRIMARY KEY` 가 없는 테이블의 ROWID 를 바꿀 수 있다고 명시한다. 이 스키마의
21
+ // `entities` 는 `TEXT PRIMARY KEY` 라 hidden ROWID 테이블이고, `entities_fts` 는
22
+ // `content_rowid='rowid'` 로 그 hidden ROWID 를 참조한다 — 재번호가 일어나면 백업
23
+ // **자체가** external-content 불일치를 안고 태어나며, `quick_check` 는 그것을 통과시킨다.
24
+ // 현재 빌드(SQLite 3.51.3, 이 스키마)에서는 재번호가 관측되지 않았다. 하지만
25
+ // **관측되지 않은 것과 계약으로 금지된 것은 다르고**, `better-sqlite3` 는 `^12.8.0`
26
+ // 부동이며 이 파일은 유일한 복구선이다. 그래서 bitwise-identical 스냅샷을 보장하는
27
+ // Online Backup API(`db.backup()`)를 쓴다. (advisor beta r7 P0-1)
28
+ //
29
+ // 실패는 전부 throw = fail-closed. 백업 없이 스키마를 바꾸지 않는다:
30
+ // 이 프로젝트군은 non-git 환경(Google Drive 폴더)에 배포되어 git 롤백이 없다.
31
+ //
32
+ // **단일 writer 전제**: 백업과 마이그레이션 사이에 다른 프로세스가 커밋하면 그 커밋은
33
+ // 마이그레이션에는 들어가고 복구점에는 없다. 이 엔진은 프로젝트당 서버 하나로 배포되며
34
+ // (프로젝트별 `.mcp.json`), 마이그레이션은 `server.connect()` 전에 끝난다. 같은 DB 를
35
+ // 두 프로세스가 동시에 여는 것은 지원 대상이 아니다 — 상세 = docs/UPDATING.md.
36
+ export async function backupBeforeMigration(db, dbPath, pendingVersions, currentVersion) {
37
+ if (pendingVersions.length === 0)
38
+ return null; // 대기 없으면 no-op
39
+ if (!dbPath || dbPath === ':memory:')
40
+ return null; // 메모리 DB 는 대상 아님
41
+ // 백업 대상 판정 = "잃을 데이터가 있는가". 테이블 목록을 하드코딩하면 그 목록 밖의
42
+ // 데이터가 안 보인다 — `embedding_profiles` 에만 행이 있는 version-0 DB 가 그렇게
43
+ // 무백업으로 통과했다(advisor beta r6 P0-3). 그래서 사용자 테이블 전체를 본다.
44
+ if (currentVersion <= 0 && !hasAnyUserRows(db))
45
+ return null;
46
+ const base = `${dbPath}.v${currentVersion}.bak`;
47
+ // 임시 파일에 먼저 만든다 = 이 프로세스가 독점 소유한다. 검증까지 통과한 뒤에야
48
+ // 슬롯에 게시하므로, 슬롯에는 절대 반쯤 만들어진 파일이 놓이지 않는다.
49
+ const tmp = `${base}.partial-${process.pid}`;
50
+ if (existsSync(tmp))
51
+ unlinkSync(tmp);
52
+ await db.backup(tmp);
53
+ try {
54
+ verifyRecoveryPoint(tmp);
55
+ const target = publishNoClobber(tmp, base);
56
+ const sha256 = streamSha256(target);
57
+ console.error(` ├─ 🛟 backup ${target} (sha256 ${sha256.slice(0, 12)}…)`);
58
+ return { path: target, sha256 };
59
+ }
60
+ catch (e) {
61
+ try {
62
+ if (existsSync(tmp))
63
+ unlinkSync(tmp);
64
+ }
65
+ catch { /* 정리 실패는 원인을 가리지 않는다 */ }
66
+ throw e;
67
+ }
68
+ }
69
+ // 이 파일이 실제로 복구점인가. `quick_check` 는 페이지 구조만 본다 — external-content
70
+ // FTS5 의 rowid 불일치는 통과시킨다. FTS5 자신의 `integrity-check` 가 그 대조를 한다.
71
+ function verifyRecoveryPoint(path) {
72
+ // 우리가 독점 소유한 임시 파일이므로 쓰기 모드로 연다(integrity-check 는 명령 삽입이다).
73
+ const v = new Database(path);
74
+ try {
75
+ const ok = v.pragma('quick_check', { simple: true });
76
+ if (ok !== 'ok')
77
+ throw new Error(`migration backup failed quick_check: ${ok}`);
78
+ const n = v.prepare(`SELECT COUNT(*) c FROM sqlite_master`).get();
79
+ if (!n || n.c === 0)
80
+ throw new Error('migration backup has empty schema');
81
+ const fts = v.prepare(`SELECT name FROM sqlite_schema WHERE type='table' AND sql LIKE '%USING fts5%'`)
82
+ .all();
83
+ for (const { name } of fts) {
84
+ const q = `"${name.replace(/"/g, '""')}"`;
85
+ try {
86
+ // **`rank = 1` 이 필수다.** 인자 없는 integrity-check 는 인덱스가 자기 자신과
87
+ // 정합한지만 본다 — external-content 테이블과의 대조는 하지 않는다. 실측:
88
+ // 인덱스에 content 없는 rowid 를 심어 놓으면 `quick_check` 도, 인자 없는
89
+ // integrity-check 도 **통과**하고 `rank=1` 만 malformed 를 던진다.
90
+ // 즉 rank 없이 부르면 이 검사를 넣은 이유였던 실패 모드를 못 잡는다
91
+ // (advisor beta r8 P0, 같은 런타임에서 재현).
92
+ v.exec(`INSERT INTO ${q}(${q}, rank) VALUES('integrity-check', 1)`);
93
+ }
94
+ catch (e) {
95
+ throw new Error(`migration backup failed FTS5 integrity-check on ${name}: ${e.message}. ` +
96
+ `The snapshot would not be a usable recovery point.`);
97
+ }
98
+ }
99
+ }
100
+ finally {
101
+ v.close();
102
+ }
103
+ }
104
+ // 슬롯 게시. `link()` 는 목적지가 있으면 EEXIST 로 실패하므로 **원자적 no-clobber** 다
105
+ // (rename 은 조용히 덮어쓴다). 그래서 경쟁하는 프로세스가 있어도 복구점을 잃지 않는다.
106
+ function publishNoClobber(tmp, base) {
107
+ for (let attempt = 0; attempt < MAX_RECOVERY_POINTS; attempt++) {
108
+ const slot = pickRecoverySlot(base);
109
+ try {
110
+ linkSync(tmp, slot);
111
+ unlinkSync(tmp);
112
+ return slot;
113
+ }
114
+ catch (e) {
115
+ if (e.code !== 'EEXIST')
116
+ throw e;
117
+ // 그 슬롯을 누가 먼저 가져갔다 — 다음 빈 슬롯으로.
118
+ }
119
+ }
120
+ throw slotsFullError(base);
121
+ }
122
+ // 다음 빈 복구점 슬롯. 기존 파일은 읽지도, 검증하지도, 건드리지도 않는다 —
123
+ // 그것들이 무엇인지 판정하려는 시도가 앞선 세 판본의 결함 전부였다.
124
+ function pickRecoverySlot(base) {
125
+ if (!existsSync(base))
126
+ return base;
127
+ for (let i = 1; i < MAX_RECOVERY_POINTS; i++) {
128
+ const candidate = `${base}.${i}`;
129
+ if (!existsSync(candidate))
130
+ return candidate;
131
+ }
132
+ throw slotsFullError(base);
133
+ }
134
+ // 슬롯이 찬 상태는 **이 스키마 버전에 대한 circuit breaker** 다. 전역 용량 상한이 아니고
135
+ // (버전마다 자기 세트를 갖는다), "3회 실패"의 증거도 아니다 — 손상 파일이나 남의 파일이
136
+ // 슬롯을 차지할 수도 있다. 그리고 이 오류는 `server.connect()` **전에** 나가므로
137
+ // 클라이언트에는 MCP unavailable 로 보인다. 복구 절차를 아는 유일한 경로가 stderr 다.
138
+ function slotsFullError(base) {
139
+ return new Error(`migration refused: all ${MAX_RECOVERY_POINTS} recovery-point slots for this schema version ` +
140
+ `are taken (${base} plus ${MAX_RECOVERY_POINTS - 1} numbered siblings). Nothing was written ` +
141
+ `and no existing file was touched. This is a circuit breaker for this version, not a disk ` +
142
+ `quota, and the files are not necessarily failed attempts — a stale or unrelated file occupies ` +
143
+ `a slot just the same. Inspect them, move the ones you do not need aside, then start the ` +
144
+ `server again. Runbook: docs/UPDATING.md "Recovery-point slots are full".`);
145
+ }
146
+ // 사용자 테이블 중 하나라도 행이 있으면 잃을 데이터가 있다.
147
+ // 목록은 스키마에서 얻는다(하드코딩하면 새 테이블이 조용히 검사 밖에 남는다).
148
+ // `schema_migrations` 는 순수 메타데이터라 제외한다 — 그것만 있는 DB 는 빈 DB 다.
149
+ // 가상 테이블은 자기 shadow 테이블을 통해 이미 세어진다.
150
+ function hasAnyUserRows(db) {
151
+ const tables = db.prepare(`SELECT name, sql FROM sqlite_schema WHERE type='table'
152
+ AND name <> 'schema_migrations'
153
+ ORDER BY name`).all();
154
+ for (const t of tables) {
155
+ if (/CREATE VIRTUAL TABLE/i.test(t.sql ?? ''))
156
+ continue;
157
+ const qt = `"${t.name.replace(/"/g, '""')}"`;
158
+ try {
159
+ const n = db.prepare(`SELECT EXISTS(SELECT 1 FROM ${qt}) e`).get();
160
+ if (n.e)
161
+ return true;
162
+ }
163
+ catch {
164
+ // 읽을 수 없는 테이블이 있으면 "빈 DB"라고 단정하지 않는다 = 백업한다.
165
+ return true;
166
+ }
167
+ }
168
+ return false;
169
+ }
170
+ // 스트리밍 해시. readFileSync 로 전체를 메모리에 올리면 fleet 의 큰 DB 에서
171
+ // OOM 위험이 있다(advisor 구현리뷰 r1 발견 6).
172
+ function streamSha256(path) {
173
+ const h = createHash('sha256');
174
+ const fd = openSync(path, 'r');
175
+ try {
176
+ const buf = Buffer.allocUnsafe(1 << 20);
177
+ let n;
178
+ while ((n = readSync(fd, buf, 0, buf.length, null)) > 0)
179
+ h.update(buf.subarray(0, n));
180
+ }
181
+ finally {
182
+ closeSync(fd);
183
+ }
184
+ return h.digest('hex');
185
+ }
@@ -0,0 +1,68 @@
1
+ export type ModelState = 'disabled' | 'idle' | 'loading' | 'downloading' | 'ready' | 'failed';
2
+ export type EmbedPriority = 'interactive' | 'bulk' | 'backfill';
3
+ export type EmbedFn = (text: string, dims: number, isQuery: boolean) => Promise<Float32Array>;
4
+ export declare class GateDisabledError extends Error {
5
+ readonly code = "EMBEDDINGS_DISABLED";
6
+ readonly state = "disabled";
7
+ constructor();
8
+ }
9
+ export declare class GateNotReadyError extends Error {
10
+ readonly state: ModelState;
11
+ readonly retryAfterMs?: number | undefined;
12
+ readonly code = "MODEL_NOT_READY";
13
+ constructor(state: ModelState, retryAfterMs?: number | undefined);
14
+ }
15
+ export declare class TerminalConfigError extends Error {
16
+ readonly terminal = true;
17
+ constructor(message: string);
18
+ }
19
+ export interface GateOptions {
20
+ mode: 'lazy' | 'eager' | 'off';
21
+ loadModel: () => Promise<EmbedFn>;
22
+ onReady?: () => void;
23
+ onStateChange?: (s: ModelState) => void;
24
+ backoffMs?: number[];
25
+ }
26
+ export declare class EmbeddingGate {
27
+ private readonly opts;
28
+ private state;
29
+ private embedFn;
30
+ private startPromise;
31
+ private queue;
32
+ private running;
33
+ private seq;
34
+ private generation;
35
+ private shuttingDown;
36
+ private retryTimer;
37
+ private attempt;
38
+ private failedInputs;
39
+ private demotions;
40
+ private terminalFailure;
41
+ readonly abort: AbortController;
42
+ private readySince?;
43
+ private lastError?;
44
+ private retryAt?;
45
+ private readonly backoff;
46
+ constructor(opts: GateOptions);
47
+ get status(): {
48
+ state: ModelState;
49
+ readySince: string | undefined;
50
+ lastError: string | undefined;
51
+ retryAt: string | undefined;
52
+ };
53
+ get isReady(): boolean;
54
+ get isDisabled(): boolean;
55
+ private setState;
56
+ start(): Promise<void>;
57
+ markDownloading(): void;
58
+ private loadOnce;
59
+ private scheduleRetry;
60
+ embed(text: string, o: {
61
+ dims?: number;
62
+ isQuery?: boolean;
63
+ priority: EmbedPriority;
64
+ }): Promise<Float32Array>;
65
+ private pump;
66
+ get loadInFlight(): boolean;
67
+ shutdown(deadlineMs?: number): Promise<void>;
68
+ }
@@ -0,0 +1,227 @@
1
+ // v3.6 lite install (spec 2026-07-18 v5 §3): EmbeddingGate owns the embedding
2
+ // model lifecycle — nothing else. State machine + single-flight load/retry +
3
+ // prioritized serial inference queue. It does NOT touch the DB, backfill
4
+ // policy, FTS SQL, or tool response shapes (A′ boundary).
5
+ //
6
+ // Consumers never read state and then call separately (TOCTOU): embed() decides
7
+ // and executes inside one boundary, rejecting with typed errors when the model
8
+ // is unavailable so each tool can honor its own not-ready contract.
9
+ export class GateDisabledError extends Error {
10
+ code = 'EMBEDDINGS_DISABLED';
11
+ state = 'disabled';
12
+ constructor() {
13
+ super('Embeddings are disabled (RAG_MEMORY_EMBEDDINGS=off)');
14
+ this.name = 'GateDisabledError';
15
+ }
16
+ }
17
+ export class GateNotReadyError extends Error {
18
+ state;
19
+ retryAfterMs;
20
+ code = 'MODEL_NOT_READY';
21
+ constructor(state, retryAfterMs) {
22
+ super(`Embedding model not ready (state=${state})`);
23
+ this.state = state;
24
+ this.retryAfterMs = retryAfterMs;
25
+ this.name = 'GateNotReadyError';
26
+ }
27
+ }
28
+ // Configuration incompatibility (e.g. a non-1024-dim custom model): retrying or
29
+ // quarantining the cache cannot fix it — the gate parks in terminal 'failed'
30
+ // with no reload schedule (beta 2R B3).
31
+ export class TerminalConfigError extends Error {
32
+ terminal = true;
33
+ constructor(message) {
34
+ super(message);
35
+ this.name = 'TerminalConfigError';
36
+ }
37
+ }
38
+ const PRIO = { interactive: 0, bulk: 1, backfill: 2 };
39
+ const DEFAULT_BACKOFF_MS = [30_000, 120_000, 600_000, 3_600_000];
40
+ export class EmbeddingGate {
41
+ opts;
42
+ state;
43
+ embedFn = null;
44
+ startPromise = null;
45
+ queue = [];
46
+ running = false;
47
+ seq = 0;
48
+ generation = 0;
49
+ shuttingDown = false;
50
+ retryTimer = null;
51
+ attempt = 0;
52
+ // Systemic-failure detector (beta 2R B4): DISTINCT failed inputs within the
53
+ // current load epoch (cleared on successful load AND on any inference
54
+ // success). Same-input retries are data-specific (poison row) and never
55
+ // demote. `demotions` escalates the reload backoff across repeated
56
+ // demote-reload cycles and only resets on a real inference success.
57
+ failedInputs = new Set();
58
+ demotions = 0;
59
+ terminalFailure = false;
60
+ abort = new AbortController(); // loader passes this to lock waits / fetches
61
+ readySince;
62
+ lastError;
63
+ retryAt;
64
+ backoff;
65
+ constructor(opts) {
66
+ this.opts = opts;
67
+ this.backoff = opts.backoffMs ?? DEFAULT_BACKOFF_MS;
68
+ this.state = opts.mode === 'off' ? 'disabled' : 'idle';
69
+ }
70
+ get status() {
71
+ return { state: this.state, readySince: this.readySince, lastError: this.lastError, retryAt: this.retryAt };
72
+ }
73
+ get isReady() { return this.state === 'ready'; }
74
+ get isDisabled() { return this.state === 'disabled'; }
75
+ setState(s) {
76
+ if (this.state !== s) {
77
+ this.state = s;
78
+ this.opts.onStateChange?.(s);
79
+ }
80
+ }
81
+ // Single-flight: concurrent calls share one load; after a failure the next
82
+ // scheduled retry (or explicit call) re-enters loadOnce.
83
+ start() {
84
+ if (this.state === 'disabled' || this.shuttingDown)
85
+ return Promise.resolve();
86
+ if (!this.startPromise)
87
+ this.startPromise = this.loadOnce();
88
+ return this.startPromise;
89
+ }
90
+ // loading -> downloading is reported by the loader via markDownloading().
91
+ markDownloading() {
92
+ if (this.state === 'loading')
93
+ this.setState('downloading');
94
+ }
95
+ async loadOnce() {
96
+ this.setState('loading');
97
+ try {
98
+ this.embedFn = await this.opts.loadModel();
99
+ this.attempt = 0;
100
+ this.retryAt = undefined;
101
+ this.lastError = undefined;
102
+ this.failedInputs.clear(); // fresh load epoch (beta 2R B4)
103
+ this.readySince = new Date().toISOString();
104
+ this.setState('ready');
105
+ this.opts.onReady?.();
106
+ this.pump();
107
+ }
108
+ catch (e) {
109
+ this.lastError = e instanceof Error ? e.message : String(e);
110
+ this.setState('failed');
111
+ this.startPromise = null;
112
+ if (e?.terminal === true) {
113
+ // Config incompatibility (beta 2R B3): retrying or quarantining cannot
114
+ // fix it — no reload schedule; a restart with a fixed config is the
115
+ // only recovery.
116
+ this.terminalFailure = true;
117
+ this.retryAt = undefined;
118
+ console.error(`❌ embeddings disabled for this run (config): ${this.lastError}`);
119
+ throw e;
120
+ }
121
+ this.scheduleRetry(this.attempt++);
122
+ throw e;
123
+ }
124
+ }
125
+ // Shared retry scheduler with jitter; escalation index picks the backoff slot.
126
+ scheduleRetry(escalation) {
127
+ if (this.shuttingDown || this.terminalFailure)
128
+ return;
129
+ const base = this.backoff[Math.min(escalation, this.backoff.length - 1)];
130
+ const delay = Math.round(base * (0.8 + Math.random() * 0.4)); // jitter ±20%
131
+ this.retryAt = new Date(Date.now() + delay).toISOString();
132
+ this.retryTimer = setTimeout(() => {
133
+ this.retryTimer = null;
134
+ void this.start().catch(() => { });
135
+ }, delay);
136
+ this.retryTimer.unref?.();
137
+ }
138
+ embed(text, o) {
139
+ if (this.state === 'disabled')
140
+ return Promise.reject(new GateDisabledError());
141
+ if (this.shuttingDown)
142
+ return Promise.reject(new GateNotReadyError(this.state));
143
+ if (this.state !== 'ready') {
144
+ const retryAfterMs = this.retryAt ? Math.max(0, Date.parse(this.retryAt) - Date.now()) : undefined;
145
+ return Promise.reject(new GateNotReadyError(this.state, retryAfterMs));
146
+ }
147
+ return new Promise((resolve, reject) => {
148
+ this.queue.push({
149
+ text, dims: o.dims ?? 1024, isQuery: o.isQuery ?? false,
150
+ prio: PRIO[o.priority], seq: this.seq++, gen: this.generation, resolve, reject,
151
+ });
152
+ this.queue.sort((a, b) => a.prio - b.prio || a.seq - b.seq);
153
+ this.pump();
154
+ });
155
+ }
156
+ pump() {
157
+ if (this.running || !this.embedFn)
158
+ return;
159
+ const job = this.queue.shift();
160
+ if (!job)
161
+ return;
162
+ this.running = true;
163
+ void this.embedFn(job.text, job.dims, job.isQuery)
164
+ .then(v => {
165
+ this.failedInputs.clear();
166
+ this.demotions = 0; // a real success resets escalation
167
+ if (job.gen === this.generation)
168
+ job.resolve(v);
169
+ else
170
+ job.reject(new Error('embedding discarded: gate shut down'));
171
+ })
172
+ .catch(e => {
173
+ // Systemic-failure detection (beta 2R B4): 3 DISTINCT failed inputs in
174
+ // this load epoch -> demote to failed + reload with ESCALATING backoff
175
+ // (demotions counter survives reloads; only an inference success
176
+ // resets it — so a persistently broken runtime backs off instead of
177
+ // hot-looping). Same-input repeats (A→A) and A→B→A count 2 distinct.
178
+ this.failedInputs.add(`${job.text}|${job.dims}|${job.isQuery}`);
179
+ if (this.failedInputs.size >= 3 && this.state === 'ready' && !this.shuttingDown) {
180
+ this.lastError = e instanceof Error ? e.message : String(e);
181
+ this.embedFn = null;
182
+ this.startPromise = null;
183
+ this.failedInputs.clear();
184
+ this.setState('failed');
185
+ this.scheduleRetry(this.demotions++);
186
+ for (const j of this.queue.splice(0))
187
+ j.reject(new GateNotReadyError('failed'));
188
+ }
189
+ job.reject(e);
190
+ })
191
+ .finally(() => {
192
+ this.running = false;
193
+ this.pump();
194
+ });
195
+ }
196
+ // Was the load still in flight when shutdown settled? (index.ts uses this to
197
+ // decide whether a bounded exit is needed — see shutdownAll.)
198
+ get loadInFlight() {
199
+ return this.startPromise !== null && this.state !== 'ready' && this.state !== 'failed' && this.state !== 'disabled';
200
+ }
201
+ // Graceful shutdown (spec §3, beta B1): block new work, abort what we can
202
+ // (lock waits via this.abort), discard in-flight inference results via the
203
+ // generation token, clear retry timers, then wait (bounded) for BOTH the
204
+ // current inference and an in-flight model load to settle.
205
+ async shutdown(deadlineMs = 5000) {
206
+ this.shuttingDown = true;
207
+ this.generation++;
208
+ this.abort.abort();
209
+ if (this.retryTimer) {
210
+ clearTimeout(this.retryTimer);
211
+ this.retryTimer = null;
212
+ }
213
+ for (const j of this.queue.splice(0))
214
+ j.reject(new Error('gate shutdown'));
215
+ const deadline = Date.now() + deadlineMs;
216
+ if (this.startPromise) {
217
+ const remaining = () => Math.max(0, deadline - Date.now());
218
+ await Promise.race([
219
+ this.startPromise.catch(() => { }),
220
+ new Promise(r => setTimeout(r, remaining())),
221
+ ]);
222
+ }
223
+ while (this.running && Date.now() < deadline) {
224
+ await new Promise(r => setTimeout(r, 20));
225
+ }
226
+ }
227
+ }
@@ -1,2 +1,4 @@
1
1
  import { Migration } from './migration-manager.js';
2
+ export type MigrationFaultPoint = 'preflight' | 'roots' | 'revisions' | 'sources' | 'gate';
3
+ export declare function setMigrationFaultPoint(point: MigrationFaultPoint | null): void;
2
4
  export declare const migrations: Migration[];
@@ -1,3 +1,9 @@
1
+ import { OBSERVATION_SCHEMA_SQL } from '../observations/schema.js';
2
+ import { randomUUID } from 'node:crypto';
3
+ let faultPoint = null;
4
+ export function setMigrationFaultPoint(point) {
5
+ faultPoint = point;
6
+ }
1
7
  export const migrations = [
2
8
  {
3
9
  version: 1,
@@ -570,5 +576,180 @@ export const migrations = [
570
576
  // No clean reversal: would need the original document content to recompute
571
577
  // UTF-16 indices, and we never recorded which chunks were touched. No-op.
572
578
  }
579
+ },
580
+ // v3.6 (spec 2026-07-18 lite-install v5 §6c): embedding provenance + narrowed FTS trigger.
581
+ // ADD COLUMN is guarded by PRAGMA table_info so re-runs are idempotent.
582
+ {
583
+ version: 12,
584
+ description: 'Embedding provenance (profiles, input_hash, provenance_state), backfill failures, narrowed chunks_fts_update trigger',
585
+ up: (db) => {
586
+ const hasCol = (table, col) => db.prepare(`PRAGMA table_info(${table})`).all().some(c => c.name === col);
587
+ for (const table of ['chunk_metadata', 'entity_embedding_metadata']) {
588
+ if (!hasCol(table, 'input_hash'))
589
+ db.exec(`ALTER TABLE ${table} ADD COLUMN input_hash TEXT`);
590
+ if (!hasCol(table, 'profile_id'))
591
+ db.exec(`ALTER TABLE ${table} ADD COLUMN profile_id INTEGER`);
592
+ if (!hasCol(table, 'provenance_state'))
593
+ db.exec(`ALTER TABLE ${table} ADD COLUMN provenance_state TEXT`);
594
+ }
595
+ db.exec(`
596
+ CREATE TABLE IF NOT EXISTS embedding_profiles (
597
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
598
+ model_id TEXT NOT NULL,
599
+ revision TEXT NOT NULL,
600
+ dtype TEXT NOT NULL,
601
+ dims INTEGER NOT NULL,
602
+ pooling TEXT NOT NULL,
603
+ normalize INTEGER NOT NULL,
604
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
605
+ UNIQUE(model_id, revision, dtype, dims, pooling, normalize)
606
+ )
607
+ `);
608
+ db.exec(`
609
+ CREATE TABLE IF NOT EXISTS embedding_backfill_failures (
610
+ kind TEXT NOT NULL,
611
+ target_id TEXT NOT NULL,
612
+ input_hash TEXT,
613
+ profile_id INTEGER,
614
+ attempts INTEGER NOT NULL DEFAULT 0,
615
+ last_error TEXT,
616
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
617
+ UNIQUE(kind, target_id)
618
+ )
619
+ `);
620
+ db.exec(`CREATE TABLE IF NOT EXISTS server_meta (key TEXT PRIMARY KEY, value TEXT)`);
621
+ // Narrow the broad AFTER UPDATE trigger (installed by v8) so provenance-only
622
+ // updates do not rewrite the FTS index (advisor 4R must-fix 1).
623
+ db.exec(`DROP TRIGGER IF EXISTS chunks_fts_update`);
624
+ db.exec(`
625
+ CREATE TRIGGER chunks_fts_update AFTER UPDATE OF text, chunk_id ON chunk_metadata BEGIN
626
+ INSERT INTO chunks_fts(chunks_fts, rowid, text, chunk_id)
627
+ VALUES ('delete', old.rowid, old.text, old.chunk_id);
628
+ INSERT INTO chunks_fts(rowid, text, chunk_id)
629
+ VALUES (new.rowid, new.text, new.chunk_id);
630
+ END
631
+ `);
632
+ },
633
+ down: (db) => {
634
+ // Restore the broad v8 trigger; drop v12 tables. Added columns are left in
635
+ // place (SQLite DROP COLUMN restrictions with dependent objects) — pre-v12
636
+ // code ignores unknown columns, so rollback stays safe (spec §8-4).
637
+ db.exec(`DROP TRIGGER IF EXISTS chunks_fts_update`);
638
+ db.exec(`
639
+ CREATE TRIGGER chunks_fts_update AFTER UPDATE ON chunk_metadata BEGIN
640
+ INSERT INTO chunks_fts(chunks_fts, rowid, text, chunk_id)
641
+ VALUES ('delete', old.rowid, old.text, old.chunk_id);
642
+ INSERT INTO chunks_fts(rowid, text, chunk_id)
643
+ VALUES (new.rowid, new.text, new.chunk_id);
644
+ END
645
+ `);
646
+ db.exec(`DROP TABLE IF EXISTS embedding_backfill_failures`);
647
+ db.exec(`DROP TABLE IF EXISTS embedding_profiles`);
648
+ db.exec(`DROP TABLE IF EXISTS server_meta`);
649
+ }
650
+ },
651
+ // v13 (spec 2026-07-30 observation-lifecycle §4): 관찰 생애주기 정규화.
652
+ // 이 항목은 DDL 만 만든다. 데이터 변환은 후속 단계에서 같은 항목에 덧붙인다.
653
+ {
654
+ version: 13,
655
+ description: 'Observation lifecycle: roots/revisions/sources/events + immutability triggers',
656
+ up: (db) => {
657
+ db.exec(OBSERVATION_SCHEMA_SQL);
658
+ // ---- spec §5.3 변환 ----
659
+ // MIGRATION_TS = 이 DB 의 v12->v13 트랜잭션 시작시각 하나 (전 행 공유).
660
+ // entities.created_at 을 복사하지 않는다 — 그것은 entity 생성시각을
661
+ // 관찰 기록시각으로 단정하는 것이고, import event 는 실제로 지금 발생했다.
662
+ // 원래 관찰 시각은 unknown 으로 둔다 (v13 은 기록시간 축만 다룬다).
663
+ const MIGRATION_TS = new Date().toISOString();
664
+ const BATCH_ID = randomUUID();
665
+ // 단계 경계 fault injection. 주입은 setMigrationFaultPoint() 로만 하며
666
+ // 환경변수를 보지 않는다 — 환경변수로 두면 프로덕션에 "마이그레이션을 깨는
667
+ // 스위치"가 상시 존재하고, 오설정 한 줄이 .bak 을 남겨 재시작을 막는다
668
+ // (advisor beta 자기의심 2 = "더 나쁘다").
669
+ const fault = (point) => {
670
+ if (faultPoint === point)
671
+ throw new Error(`injected fault at '${point}' (test-only)`);
672
+ };
673
+ // 1) 읽기 전용 preflight: array<string> 검증.
674
+ // JSON.parse 만으로는 객체·숫자·null 요소가 통과한다.
675
+ const rows = db.prepare(`SELECT id, observations FROM entities`).all();
676
+ const parsed = new Map();
677
+ for (const r of rows) {
678
+ let val;
679
+ try {
680
+ val = JSON.parse(r.observations ?? '[]');
681
+ }
682
+ catch {
683
+ throw new Error(`v13 preflight: entity ${r.id} observations is not JSON`);
684
+ }
685
+ if (!Array.isArray(val))
686
+ throw new Error(`v13 preflight: entity ${r.id} observations is not an array<string>`);
687
+ for (const el of val) {
688
+ if (typeof el !== 'string')
689
+ throw new Error(`v13 preflight: entity ${r.id} observations contains a non-string ` +
690
+ `element (${el === null ? 'null' : typeof el}) — array<string> required`);
691
+ }
692
+ parsed.set(r.id, val);
693
+ }
694
+ fault('preflight');
695
+ // 2~4) roots -> revisions -> sources/events.
696
+ // 이 순서가 계약이다: trg_obs_matches_root 가 root 선행을 요구한다.
697
+ // 3패스로 나눈 이유 = 지점별 fault injection 을 검증 가능하게 하려면
698
+ // 단계 경계가 실제로 존재해야 한다(T11).
699
+ const insRoot = db.prepare(`INSERT INTO observation_roots
700
+ (root_id, entity_id, projection_order, created_at) VALUES (?, ?, ?, ?)`);
701
+ const insRev = db.prepare(`INSERT INTO entity_observations
702
+ (observation_id, root_id, entity_id, revision_no, projection_order,
703
+ content, status, supersedes_id, recorded_at, superseded_at)
704
+ VALUES (?, ?, ?, 1, ?, ?, 'active', NULL, ?, NULL)`);
705
+ const insSrc = db.prepare(`INSERT INTO observation_sources
706
+ (observation_id, source_kind, source_ref, source_hash, recorded_at)
707
+ VALUES (?, 'import', 'v12-migration', NULL, ?)`);
708
+ const insEv = db.prepare(`INSERT INTO observation_events
709
+ (event_id, root_id, from_id, to_id, event, change_kind, reason, actor, batch_id, recorded_at)
710
+ VALUES (?, ?, NULL, ?, 'import', NULL, NULL, 'v12-migration', ?, ?)`);
711
+ const plan = [];
712
+ for (const [entityId, arr] of parsed) {
713
+ arr.forEach((content, order) => {
714
+ const rootId = randomUUID();
715
+ insRoot.run(rootId, entityId, order, MIGRATION_TS);
716
+ plan.push({ entityId, rootId, obsId: randomUUID(), order, content });
717
+ });
718
+ }
719
+ fault('roots');
720
+ // pass 2: revisions
721
+ for (const p of plan)
722
+ insRev.run(p.obsId, p.rootId, p.entityId, p.order, p.content, MIGRATION_TS);
723
+ fault('revisions');
724
+ // pass 3: sources + events
725
+ for (const p of plan) {
726
+ insSrc.run(p.obsId, MIGRATION_TS);
727
+ insEv.run(randomUUID(), p.rootId, p.obsId, BATCH_ID, MIGRATION_TS);
728
+ }
729
+ fault('sources');
730
+ // 6) 검증 게이트 (a): FK 무결성.
731
+ // FK 가 켜져 있어도 방어층으로 확인한다.
732
+ const fkBad = db.prepare(`PRAGMA foreign_key_check`).all();
733
+ if (fkBad.length > 0)
734
+ throw new Error(`v13 gate: foreign_key_check reported ${fkBad.length} violation(s)`);
735
+ fault('gate');
736
+ // 7) 검증 게이트 (b): 합성 배열 == 원본 배열 (중복·순서 포함 byte 동일)
737
+ const synthStmt = db.prepare(`SELECT content FROM entity_observations
738
+ WHERE entity_id = ? AND status = 'active' ORDER BY projection_order`);
739
+ for (const [entityId, arr] of parsed) {
740
+ const rebuilt = JSON.stringify(synthStmt.all(entityId).map(x => x.content));
741
+ const original = JSON.stringify(arr);
742
+ if (rebuilt !== original)
743
+ throw new Error(`v13 gate: projection mismatch for entity ${entityId}\n` +
744
+ ` original: ${original}\n rebuilt: ${rebuilt}`);
745
+ }
746
+ },
747
+ down: (db) => {
748
+ // 역순 삭제 (FK 의존 순서). 트리거는 테이블과 함께 사라진다.
749
+ db.exec(`DROP TABLE IF EXISTS observation_events`);
750
+ db.exec(`DROP TABLE IF EXISTS observation_sources`);
751
+ db.exec(`DROP TABLE IF EXISTS entity_observations`);
752
+ db.exec(`DROP TABLE IF EXISTS observation_roots`);
753
+ }
573
754
  }
574
755
  ];