rag-memory-epf-mcp 3.6.0 → 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,5 @@
1
+ import Database from 'better-sqlite3';
2
+ export declare function backupBeforeMigration(db: Database.Database, dbPath: string, pendingVersions: number[], currentVersion: number): Promise<{
3
+ path: string;
4
+ sha256: string;
5
+ } | null>;
@@ -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
+ }
@@ -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,
@@ -641,5 +647,109 @@ export const migrations = [
641
647
  db.exec(`DROP TABLE IF EXISTS embedding_profiles`);
642
648
  db.exec(`DROP TABLE IF EXISTS server_meta`);
643
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
+ }
644
754
  }
645
755
  ];
@@ -0,0 +1,8 @@
1
+ import type Database from 'better-sqlite3';
2
+ export declare function getObservationHistory(db: Database.Database, sel: {
3
+ entity_name?: string;
4
+ observation_id?: string;
5
+ root_id?: string;
6
+ }): {
7
+ roots: any[];
8
+ };
@@ -0,0 +1,64 @@
1
+ // spec §6.2: history 의 유일한 창구.
2
+ // 응답은 항상 roots 배열이다 (entity_name 이면 N개, root_id/observation_id 면 1개).
3
+ // 단일 객체로 두면 entity_name 선택자와 cardinality 가 모순된다.
4
+ export function getObservationHistory(db, sel) {
5
+ // 정확히 하나만 받는다. 우선순위를 조용히 적용하면 두 개를 넘긴 호출자가
6
+ // *다른* 선택자의 답을 받고 그 사실을 모른다 — 실측으로 entity_name 이 유효한데
7
+ // 존재하지 않는 root_id 가 이겨서 `{roots: []}` 가 나갔다(advisor beta 발견 4-2).
8
+ const given = ['entity_name', 'observation_id', 'root_id']
9
+ .filter(k => sel[k] !== undefined && sel[k] !== null && sel[k] !== '');
10
+ if (given.length === 0) {
11
+ throw new Error('getObservationHistory requires one of: entity_name, observation_id, root_id');
12
+ }
13
+ if (given.length > 1) {
14
+ throw new Error(`getObservationHistory takes exactly one selector, got ${given.length} (${given.join(', ')}). ` +
15
+ `Applying a precedence order would silently answer a different question than the one asked.`);
16
+ }
17
+ let rootIds;
18
+ if (sel.root_id) {
19
+ rootIds = [sel.root_id];
20
+ }
21
+ else if (sel.observation_id) {
22
+ const r = db.prepare(`SELECT root_id FROM entity_observations WHERE observation_id = ?`)
23
+ .get(sel.observation_id);
24
+ rootIds = r ? [r.root_id] : [];
25
+ }
26
+ else if (sel.entity_name) {
27
+ const entityId = `entity_${sel.entity_name.toLowerCase().replace(/[^\p{L}\p{N}]/gu, '_')}`;
28
+ rootIds = db.prepare(`SELECT root_id FROM observation_roots
29
+ WHERE entity_id = ? ORDER BY projection_order`)
30
+ .all(entityId).map(r => r.root_id);
31
+ }
32
+ else {
33
+ throw new Error('getObservationHistory requires one of: entity_name, observation_id, root_id');
34
+ }
35
+ const roots = rootIds.map(rid => {
36
+ const root = db.prepare(`SELECT * FROM observation_roots WHERE root_id = ?`).get(rid);
37
+ if (!root)
38
+ return null;
39
+ const revs = db.prepare(`SELECT * FROM entity_observations WHERE root_id = ? ORDER BY revision_no`).all(rid);
40
+ // 정렬 = recorded_at, 동시각이면 event_id 사전순 (결정론)
41
+ const events = db.prepare(`SELECT * FROM observation_events WHERE root_id = ? ORDER BY recorded_at, event_id`).all(rid);
42
+ return {
43
+ root_id: root.root_id,
44
+ entity_id: root.entity_id,
45
+ projection_order: root.projection_order,
46
+ revisions: revs.map(v => ({
47
+ observation_id: v.observation_id,
48
+ revision_no: v.revision_no,
49
+ content: v.content,
50
+ status: v.status,
51
+ supersedes_id: v.supersedes_id,
52
+ recorded_at: v.recorded_at,
53
+ superseded_at: v.superseded_at,
54
+ sources: db.prepare(`SELECT source_kind, source_ref, source_hash, recorded_at
55
+ FROM observation_sources WHERE observation_id = ?
56
+ ORDER BY recorded_at, source_kind, source_ref`).all(v.observation_id),
57
+ // 이 revision 을 from/to 로 지목한 event 만
58
+ events: events.filter(e => e.from_id === v.observation_id || e.to_id === v.observation_id),
59
+ })),
60
+ };
61
+ }).filter(Boolean);
62
+ roots.sort((a, b) => a.projection_order - b.projection_order);
63
+ return { roots };
64
+ }
@@ -0,0 +1,45 @@
1
+ import type Database from 'better-sqlite3';
2
+ export type ObsStatus = 'active' | 'superseded' | 'retracted' | 'provisional';
3
+ export type ObsEvent = 'add' | 'correct' | 'retract' | 'restore' | 'approve' | 'decline' | 'import';
4
+ export type SourceInput = {
5
+ source_kind: 'document' | 'conversation' | 'decision' | 'import';
6
+ source_ref: string;
7
+ source_hash?: string | null;
8
+ };
9
+ export declare function nextProjectionOrder(db: Database.Database, entityId: string): number;
10
+ export declare function recordEvent(db: Database.Database, a: {
11
+ rootId: string;
12
+ event: ObsEvent;
13
+ fromId?: string | null;
14
+ toId?: string | null;
15
+ changeKind?: 'correction' | 'world_change' | 'retraction' | null;
16
+ reason?: string | null;
17
+ actor?: string | null;
18
+ batchId?: string | null;
19
+ ts: string;
20
+ }): void;
21
+ export declare function linkSources(db: Database.Database, observationId: string, sources: SourceInput[], ts: string): number;
22
+ export declare function addRevision(db: Database.Database, a: {
23
+ entityId: string;
24
+ content: string;
25
+ status: Extract<ObsStatus, 'active' | 'provisional'>;
26
+ sources?: SourceInput[];
27
+ actor?: string | null;
28
+ ts: string;
29
+ event?: Extract<ObsEvent, 'add' | 'import'>;
30
+ }): string;
31
+ export declare function correctRevision(db: Database.Database, a: {
32
+ observationId: string;
33
+ content: string;
34
+ changeKind: 'correction' | 'world_change';
35
+ reason?: string | null;
36
+ actor?: string | null;
37
+ ts: string;
38
+ }): string;
39
+ export declare function transitionStatus(db: Database.Database, a: {
40
+ observationId: string;
41
+ event: 'retract' | 'restore' | 'approve' | 'decline';
42
+ reason?: string | null;
43
+ actor?: string | null;
44
+ ts: string;
45
+ }): void;
@@ -0,0 +1,101 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ // 순번 정본은 observation_roots 다. entity_observations 로 세면 revision 이
3
+ // purge 된 root 의 순번을 재사용해 이후 restore/approve 가 UNIQUE 로 실패한다.
4
+ export function nextProjectionOrder(db, entityId) {
5
+ const r = db.prepare(`SELECT COALESCE(MAX(projection_order), -1) + 1 AS n FROM observation_roots WHERE entity_id = ?`).get(entityId);
6
+ return r.n;
7
+ }
8
+ export function recordEvent(db, a) {
9
+ db.prepare(`INSERT INTO observation_events
10
+ (event_id, root_id, from_id, to_id, event, change_kind, reason, actor, batch_id, recorded_at)
11
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
12
+ .run(randomUUID(), a.rootId, a.fromId ?? null, a.toId ?? null, a.event, a.changeKind ?? null, a.reason ?? null, a.actor ?? null, a.batchId ?? null, a.ts);
13
+ }
14
+ // 한 관찰에 evidence 를 더한다. 같은 사실이 다른 출처에서 다시 오면
15
+ // 새 revision 이 아니라 source link 가 늘어난다(spec §8.3 T13).
16
+ export function linkSources(db, observationId, sources, ts) {
17
+ let n = 0;
18
+ for (const s of sources) {
19
+ n += db.prepare(`INSERT OR IGNORE INTO observation_sources
20
+ (observation_id, source_kind, source_ref, source_hash, recorded_at)
21
+ VALUES (?, ?, ?, ?, ?)`)
22
+ .run(observationId, s.source_kind, s.source_ref, s.source_hash ?? null, ts).changes;
23
+ }
24
+ return n;
25
+ }
26
+ // 새 논리 관찰 = root 1 + rev1 1 + sources N + event 1.
27
+ // event 는 기본 'add' 지만 legacy import 경로는 'import' 를 남겨야 한다(spec §6.4):
28
+ // 그 관찰은 사람이 방금 추가한 것이 아니라 옛 형식 dump 에서 승격된 것이고,
29
+ // 'add' 로 적으면 history 가 유입 경로를 잘못 말한다(advisor beta 발견 1).
30
+ export function addRevision(db, a) {
31
+ const rootId = randomUUID();
32
+ const obsId = randomUUID();
33
+ const order = nextProjectionOrder(db, a.entityId);
34
+ db.prepare(`INSERT INTO observation_roots (root_id, entity_id, projection_order, created_at)
35
+ VALUES (?, ?, ?, ?)`).run(rootId, a.entityId, order, a.ts);
36
+ db.prepare(`INSERT INTO entity_observations
37
+ (observation_id, root_id, entity_id, revision_no, projection_order,
38
+ content, status, supersedes_id, recorded_at, superseded_at)
39
+ VALUES (?, ?, ?, 1, ?, ?, ?, NULL, ?, NULL)`)
40
+ .run(obsId, rootId, a.entityId, order, a.content, a.status, a.ts);
41
+ // 출처를 모르면 source 행을 만들지 않는다.
42
+ // source_ref 가 NOT NULL 인 것은 "행을 반드시 만들라"는 뜻이 아니다.
43
+ // sources 가 생략됐는데 conversation/unspecified 를 넣으면 "대화에서 왔다"는
44
+ // 사실을 발명하는 것이고, provenance 의 목적과 정반대다
45
+ // (advisor 구현리뷰 r1 발견 5). unknown 은 0행으로 표현한다.
46
+ if (a.sources?.length)
47
+ linkSources(db, obsId, a.sources, a.ts);
48
+ recordEvent(db, { rootId, event: a.event ?? 'add', toId: obsId,
49
+ actor: a.actor ?? null, ts: a.ts });
50
+ return obsId;
51
+ }
52
+ // §4.4 복합 전이: ①구 active -> superseded ②신규 revision -> active ③correct event.
53
+ // 셋이 한 트랜잭션 안에서 일어나야 한다 — 호출자가 mutateEntityAndInvalidate 로 감싼다.
54
+ export function correctRevision(db, a) {
55
+ const cur = db.prepare(`SELECT observation_id, root_id, entity_id, revision_no, projection_order, status
56
+ FROM entity_observations WHERE observation_id = ?`).get(a.observationId);
57
+ if (!cur)
58
+ throw new Error(`observation ${a.observationId} not found`);
59
+ if (cur.status !== 'active')
60
+ throw new Error(`cannot correct an observation in status '${cur.status}' — only 'active' (spec §4.4)`);
61
+ const newId = randomUUID();
62
+ // ① 구 행을 먼저 superseded 로. active-per-root 부분 UNIQUE 때문에 순서가 계약이다.
63
+ db.prepare(`UPDATE entity_observations SET status='superseded', superseded_at=? WHERE observation_id=?`)
64
+ .run(a.ts, cur.observation_id);
65
+ // ② 신규 revision. projection_order 는 전임자 상속 = 배열 위치가 움직이지 않는다.
66
+ db.prepare(`INSERT INTO entity_observations
67
+ (observation_id, root_id, entity_id, revision_no, projection_order,
68
+ content, status, supersedes_id, recorded_at, superseded_at)
69
+ VALUES (?, ?, ?, ?, ?, ?, 'active', ?, ?, NULL)`)
70
+ .run(newId, cur.root_id, cur.entity_id, cur.revision_no + 1, cur.projection_order, a.content, cur.observation_id, a.ts);
71
+ // ③ event
72
+ recordEvent(db, { rootId: cur.root_id, event: 'correct', fromId: cur.observation_id,
73
+ toId: newId, changeKind: a.changeKind, reason: a.reason ?? null,
74
+ actor: a.actor ?? null, ts: a.ts });
75
+ return newId;
76
+ }
77
+ // §4.4 전이 표를 코드로. 표에 없는 조합은 거부한다 — 'superseded' 는 종착이다.
78
+ const ALLOWED = {
79
+ active: [{ to: 'retracted', event: 'retract' }],
80
+ retracted: [{ to: 'active', event: 'restore' }],
81
+ provisional: [{ to: 'active', event: 'approve' },
82
+ { to: 'retracted', event: 'decline' }],
83
+ superseded: [],
84
+ };
85
+ export function transitionStatus(db, a) {
86
+ const cur = db.prepare(`SELECT observation_id, root_id, entity_id, status FROM entity_observations WHERE observation_id = ?`).get(a.observationId);
87
+ if (!cur)
88
+ throw new Error(`observation ${a.observationId} not found`);
89
+ const allowed = (ALLOWED[cur.status] ?? []).find(x => x.event === a.event);
90
+ if (!allowed) {
91
+ throw new Error(`illegal transition: '${a.event}' from status '${cur.status}' is not in the §4.4 table` +
92
+ (cur.status === 'superseded'
93
+ ? " — 'superseded' is terminal; create a new revision instead"
94
+ : ''));
95
+ }
96
+ db.prepare(`UPDATE entity_observations SET status = ? WHERE observation_id = ?`)
97
+ .run(allowed.to, cur.observation_id);
98
+ recordEvent(db, { rootId: cur.root_id, event: a.event, fromId: cur.observation_id,
99
+ toId: cur.observation_id, reason: a.reason ?? null,
100
+ actor: a.actor ?? null, ts: a.ts });
101
+ }
@@ -0,0 +1,3 @@
1
+ import type Database from 'better-sqlite3';
2
+ export declare function rebuildProjection(db: Database.Database, entityId: string): void;
3
+ export declare function deleteStaleKgChunks(db: Database.Database, entityId: string): number;
@@ -0,0 +1,31 @@
1
+ // active 행을 projection_order 순으로 모아 entities.observations 를 재작성한다.
2
+ // 단계 1 에서는 이 배열이 여전히 FTS/벡터의 입력이다 — 그래서 projection 갱신만으로
3
+ // 기존 entity FTS 트리거와 벡터 무효화가 따라온다(spec §4.5).
4
+ export function rebuildProjection(db, entityId) {
5
+ const rows = db.prepare(`SELECT content FROM entity_observations
6
+ WHERE entity_id = ? AND status = 'active'
7
+ ORDER BY projection_order`).all(entityId);
8
+ db.prepare(`UPDATE entities SET observations = ? WHERE id = ?`)
9
+ .run(JSON.stringify(rows.map(r => r.content)), entityId);
10
+ }
11
+ // D4: observation 이 바뀌면 그 entity 를 가리키는 KG chunk 는 stale 이다.
12
+ // 단계 1 에서는 재생성하지 않고 fail-closed 로 제거한다 — 낡은 텍스트가
13
+ // hybridSearch 에 남아 있는 것이 없는 것보다 나쁘다.
14
+ //
15
+ // KG chunk 의 식별자는 document_id 가 아니라 (chunk_type, entity_id) 다:
16
+ // generateKnowledgeGraphChunks() 는 document_id 를 넣지 않는다.
17
+ //
18
+ // 이 경로는 현재 dormant 다 — generateKnowledgeGraphChunks/embedKnowledgeGraphChunks
19
+ // 는 MCP 도구로 노출되지 않고 내부 호출 지점도 없으며, 실사용 DB 의 chunk 는 전부
20
+ // chunk_type='document' 였다. 그래서 이것은 미래·타 배포 대비 방어층이고,
21
+ // 테스트는 KG chunk 를 직접 심어서 검증한다(자연 발생하지 않는다).
22
+ export function deleteStaleKgChunks(db, entityId) {
23
+ const chunks = db.prepare(`SELECT rowid FROM chunk_metadata WHERE chunk_type = 'entity' AND entity_id = ?`).all(entityId);
24
+ let n = 0;
25
+ for (const c of chunks) {
26
+ db.exec(`DELETE FROM chunks WHERE rowid = ${Number(c.rowid)}`);
27
+ db.prepare(`DELETE FROM chunk_metadata WHERE rowid = ?`).run(c.rowid);
28
+ n++;
29
+ }
30
+ return n;
31
+ }
@@ -0,0 +1 @@
1
+ export declare const OBSERVATION_SCHEMA_SQL = "\nCREATE TABLE IF NOT EXISTS observation_roots (\n root_id TEXT PRIMARY KEY NOT NULL,\n entity_id TEXT NOT NULL REFERENCES entities(id) ON DELETE CASCADE,\n projection_order INTEGER NOT NULL\n CHECK (typeof(projection_order) = 'integer' AND projection_order >= 0),\n created_at DATETIME NOT NULL,\n UNIQUE (entity_id, projection_order)\n);\n\nCREATE TABLE IF NOT EXISTS entity_observations (\n observation_id TEXT PRIMARY KEY NOT NULL,\n root_id TEXT NOT NULL REFERENCES observation_roots(root_id) ON DELETE CASCADE,\n entity_id TEXT NOT NULL REFERENCES entities(id) ON DELETE CASCADE,\n revision_no INTEGER NOT NULL\n CHECK (typeof(revision_no) = 'integer' AND revision_no >= 1),\n projection_order INTEGER NOT NULL\n CHECK (typeof(projection_order) = 'integer' AND projection_order >= 0),\n content TEXT NOT NULL,\n status TEXT NOT NULL CHECK (status IN\n ('active','superseded','retracted','provisional')),\n supersedes_id TEXT REFERENCES entity_observations(observation_id),\n recorded_at DATETIME NOT NULL,\n superseded_at DATETIME,\n UNIQUE (root_id, revision_no)\n);\n\nCREATE TABLE IF NOT EXISTS observation_sources (\n observation_id TEXT NOT NULL REFERENCES entity_observations(observation_id) ON DELETE CASCADE,\n source_kind TEXT NOT NULL CHECK (source_kind IN\n ('document','conversation','decision','import')),\n source_ref TEXT NOT NULL,\n source_hash TEXT,\n recorded_at DATETIME NOT NULL,\n PRIMARY KEY (observation_id, source_kind, source_ref)\n);\n\nCREATE TABLE IF NOT EXISTS observation_events (\n event_id TEXT PRIMARY KEY NOT NULL,\n root_id TEXT NOT NULL REFERENCES observation_roots(root_id) ON DELETE CASCADE,\n from_id TEXT,\n to_id TEXT,\n event TEXT NOT NULL CHECK (event IN\n ('add','correct','retract','restore','approve','decline','import')),\n change_kind TEXT CHECK (change_kind IN ('correction','world_change','retraction')),\n reason TEXT,\n actor TEXT,\n batch_id TEXT,\n recorded_at DATETIME NOT NULL\n);\n\nCREATE UNIQUE INDEX IF NOT EXISTS idx_obs_active_per_root\n ON entity_observations(root_id) WHERE status = 'active';\n\nCREATE UNIQUE INDEX IF NOT EXISTS idx_obs_active_order\n ON entity_observations(entity_id, projection_order) WHERE status = 'active';\n\nCREATE INDEX IF NOT EXISTS idx_obs_entity ON entity_observations(entity_id);\nCREATE INDEX IF NOT EXISTS idx_obs_root ON entity_observations(root_id);\nCREATE INDEX IF NOT EXISTS idx_obs_events_root ON observation_events(root_id);\n\nCREATE TRIGGER IF NOT EXISTS trg_roots_immutable\nBEFORE UPDATE ON observation_roots\nBEGIN\n SELECT RAISE(ABORT, 'observation_roots is immutable');\nEND;\n\nCREATE TRIGGER IF NOT EXISTS trg_obs_content_immutable\nBEFORE UPDATE OF content ON entity_observations\nBEGIN\n SELECT RAISE(ABORT, 'observation content is immutable; create a new revision');\nEND;\n\nCREATE TRIGGER IF NOT EXISTS trg_obs_identity_immutable\nBEFORE UPDATE OF observation_id, root_id, entity_id, revision_no, supersedes_id,\n projection_order, recorded_at\nON entity_observations\nBEGIN\n SELECT RAISE(ABORT, 'identity/order fields are immutable after insert');\nEND;\n\nCREATE TRIGGER IF NOT EXISTS trg_obs_matches_root\nBEFORE INSERT ON entity_observations\nBEGIN\n SELECT RAISE(ABORT, 'root_id must exist and (entity_id, projection_order) must match it')\n WHERE NOT EXISTS (\n SELECT 1 FROM observation_roots r\n WHERE r.root_id = NEW.root_id\n AND r.entity_id = NEW.entity_id\n AND r.projection_order = NEW.projection_order);\nEND;\n\nCREATE TRIGGER IF NOT EXISTS trg_obs_chain_wellformed\nBEFORE INSERT ON entity_observations\nBEGIN\n SELECT RAISE(ABORT, 'revision_no must be >= 1') WHERE NEW.revision_no < 1;\n SELECT RAISE(ABORT, 'first revision must have NULL supersedes_id')\n WHERE NEW.revision_no = 1 AND NEW.supersedes_id IS NOT NULL;\n SELECT RAISE(ABORT, 'non-first revision must have a predecessor')\n WHERE NEW.revision_no > 1 AND NEW.supersedes_id IS NULL;\n SELECT RAISE(ABORT, 'supersedes must be the immediately preceding revision of the same root')\n WHERE NEW.supersedes_id IS NOT NULL AND NOT EXISTS (\n SELECT 1 FROM entity_observations p\n WHERE p.observation_id = NEW.supersedes_id\n AND p.root_id = NEW.root_id\n AND p.revision_no = NEW.revision_no - 1);\nEND;\n";