engrm 0.1.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.
Files changed (82) hide show
  1. package/.mcp.json +9 -0
  2. package/AUTH-DESIGN.md +436 -0
  3. package/BRIEF.md +197 -0
  4. package/CLAUDE.md +44 -0
  5. package/COMPETITIVE.md +174 -0
  6. package/CONTEXT-OPTIMIZATION.md +305 -0
  7. package/INFRASTRUCTURE.md +252 -0
  8. package/LICENSE +105 -0
  9. package/MARKET.md +230 -0
  10. package/PLAN.md +278 -0
  11. package/README.md +121 -0
  12. package/SENTINEL.md +293 -0
  13. package/SERVER-API-PLAN.md +553 -0
  14. package/SPEC.md +843 -0
  15. package/SWOT.md +148 -0
  16. package/SYNC-ARCHITECTURE.md +294 -0
  17. package/VIBE-CODER-STRATEGY.md +250 -0
  18. package/bun.lock +375 -0
  19. package/hooks/post-tool-use.ts +144 -0
  20. package/hooks/session-start.ts +64 -0
  21. package/hooks/stop.ts +131 -0
  22. package/mem-page.html +1305 -0
  23. package/package.json +30 -0
  24. package/src/capture/dedup.test.ts +103 -0
  25. package/src/capture/dedup.ts +76 -0
  26. package/src/capture/extractor.test.ts +245 -0
  27. package/src/capture/extractor.ts +330 -0
  28. package/src/capture/quality.test.ts +168 -0
  29. package/src/capture/quality.ts +104 -0
  30. package/src/capture/retrospective.test.ts +115 -0
  31. package/src/capture/retrospective.ts +121 -0
  32. package/src/capture/scanner.test.ts +131 -0
  33. package/src/capture/scanner.ts +100 -0
  34. package/src/capture/scrubber.test.ts +144 -0
  35. package/src/capture/scrubber.ts +181 -0
  36. package/src/cli.ts +517 -0
  37. package/src/config.ts +238 -0
  38. package/src/context/inject.test.ts +940 -0
  39. package/src/context/inject.ts +382 -0
  40. package/src/embeddings/backfill.ts +50 -0
  41. package/src/embeddings/embedder.test.ts +76 -0
  42. package/src/embeddings/embedder.ts +139 -0
  43. package/src/lifecycle/aging.test.ts +103 -0
  44. package/src/lifecycle/aging.ts +36 -0
  45. package/src/lifecycle/compaction.test.ts +264 -0
  46. package/src/lifecycle/compaction.ts +190 -0
  47. package/src/lifecycle/purge.test.ts +100 -0
  48. package/src/lifecycle/purge.ts +37 -0
  49. package/src/lifecycle/scheduler.test.ts +120 -0
  50. package/src/lifecycle/scheduler.ts +101 -0
  51. package/src/provisioning/browser-auth.ts +172 -0
  52. package/src/provisioning/provision.test.ts +198 -0
  53. package/src/provisioning/provision.ts +94 -0
  54. package/src/register.test.ts +167 -0
  55. package/src/register.ts +178 -0
  56. package/src/server.ts +436 -0
  57. package/src/storage/migrations.test.ts +244 -0
  58. package/src/storage/migrations.ts +261 -0
  59. package/src/storage/outbox.test.ts +229 -0
  60. package/src/storage/outbox.ts +131 -0
  61. package/src/storage/projects.test.ts +137 -0
  62. package/src/storage/projects.ts +184 -0
  63. package/src/storage/sqlite.test.ts +798 -0
  64. package/src/storage/sqlite.ts +934 -0
  65. package/src/storage/vec.test.ts +198 -0
  66. package/src/sync/auth.test.ts +76 -0
  67. package/src/sync/auth.ts +68 -0
  68. package/src/sync/client.ts +183 -0
  69. package/src/sync/engine.test.ts +94 -0
  70. package/src/sync/engine.ts +127 -0
  71. package/src/sync/pull.test.ts +279 -0
  72. package/src/sync/pull.ts +170 -0
  73. package/src/sync/push.test.ts +117 -0
  74. package/src/sync/push.ts +230 -0
  75. package/src/tools/get.ts +34 -0
  76. package/src/tools/pin.ts +47 -0
  77. package/src/tools/save.test.ts +301 -0
  78. package/src/tools/save.ts +231 -0
  79. package/src/tools/search.test.ts +69 -0
  80. package/src/tools/search.ts +181 -0
  81. package/src/tools/timeline.ts +64 -0
  82. package/tsconfig.json +22 -0
package/src/server.ts ADDED
@@ -0,0 +1,436 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * Engrm — MCP Server entry point.
4
+ *
5
+ * Registers MCP tools and runs over stdio transport.
6
+ * Creates a single MemDatabase instance shared across all tools.
7
+ */
8
+
9
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
10
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
11
+ import { z } from "zod";
12
+
13
+ import { loadConfig, getDbPath, configExists } from "./config.js";
14
+ import { MemDatabase } from "./storage/sqlite.js";
15
+ import { saveObservation } from "./tools/save.js";
16
+ import { searchObservations } from "./tools/search.js";
17
+ import { getObservations } from "./tools/get.js";
18
+ import { getTimeline } from "./tools/timeline.js";
19
+ import { pinObservation } from "./tools/pin.js";
20
+ import {
21
+ buildSessionContext,
22
+ formatContextForInjection,
23
+ } from "./context/inject.js";
24
+ import { runDueJobs } from "./lifecycle/scheduler.js";
25
+ import { SyncEngine } from "./sync/engine.js";
26
+ import { backfillEmbeddings } from "./embeddings/backfill.js";
27
+
28
+ // --- Bootstrap ---
29
+
30
+ if (!configExists()) {
31
+ console.error(
32
+ "Engrm is not configured. Run: engrm init --manual"
33
+ );
34
+ process.exit(1);
35
+ }
36
+
37
+ const config = loadConfig();
38
+ const db = new MemDatabase(getDbPath());
39
+
40
+ // Double-injection guard: track whether context has been served this session.
41
+ // Stdio transport = 1 process per session, so module-level flag is safe.
42
+ let contextServed = false;
43
+
44
+ // Agent auto-detection: resolved lazily from MCP clientInfo on first tool call.
45
+ let _detectedAgent: string | null = null;
46
+
47
+ /**
48
+ * Get the detected agent name. Reads from MCP clientInfo (set during initialize).
49
+ * Resolved lazily because initialize happens after connect().
50
+ */
51
+ function getDetectedAgent(): string {
52
+ if (_detectedAgent) return _detectedAgent;
53
+ try {
54
+ const clientInfo = server.server.getClientVersion();
55
+ if (clientInfo?.name) {
56
+ _detectedAgent = resolveAgentName(clientInfo.name);
57
+ return _detectedAgent;
58
+ }
59
+ } catch {
60
+ // Not yet initialized — use default
61
+ }
62
+ return "claude-code";
63
+ }
64
+
65
+ /**
66
+ * Map MCP clientInfo.name to our agent identifiers.
67
+ */
68
+ function resolveAgentName(clientName: string): string {
69
+ const name = clientName.toLowerCase();
70
+ if (name.includes("codex")) return "codex-cli";
71
+ if (name.includes("cursor")) return "cursor";
72
+ if (name.includes("windsurf")) return "windsurf";
73
+ if (name.includes("cline")) return "cline";
74
+ if (name.includes("copilot")) return "vscode-copilot";
75
+ if (name.includes("zed")) return "zed";
76
+ if (name.includes("claude")) return "claude-code";
77
+ return clientName;
78
+ }
79
+
80
+ // Sync engine (started in main, needs module-level ref for shutdown)
81
+ let syncEngine: SyncEngine | null = null;
82
+
83
+ // Graceful shutdown
84
+ process.on("SIGINT", () => {
85
+ syncEngine?.stop();
86
+ db.close();
87
+ process.exit(0);
88
+ });
89
+ process.on("SIGTERM", () => {
90
+ syncEngine?.stop();
91
+ db.close();
92
+ process.exit(0);
93
+ });
94
+
95
+ // --- MCP Server ---
96
+
97
+ const server = new McpServer({
98
+ name: "engrm",
99
+ version: "0.1.0",
100
+ });
101
+
102
+ // Tool: save_observation
103
+ server.tool(
104
+ "save_observation",
105
+ "Save an observation to memory",
106
+ {
107
+ type: z.enum([
108
+ "bugfix",
109
+ "discovery",
110
+ "decision",
111
+ "pattern",
112
+ "change",
113
+ "feature",
114
+ "refactor",
115
+ "digest",
116
+ ]),
117
+ title: z.string().describe("Brief title"),
118
+ narrative: z.string().optional().describe("What happened and why"),
119
+ facts: z.array(z.string()).optional().describe("Key facts"),
120
+ concepts: z.array(z.string()).optional().describe("Tags"),
121
+ files_read: z
122
+ .array(z.string())
123
+ .optional()
124
+ .describe("Files read (project-relative)"),
125
+ files_modified: z
126
+ .array(z.string())
127
+ .optional()
128
+ .describe("Files modified (project-relative)"),
129
+ sensitivity: z.enum(["shared", "personal", "secret"]).optional(),
130
+ session_id: z.string().optional(),
131
+ supersedes: z.number().optional().describe("ID of observation this replaces"),
132
+ },
133
+ async (params) => {
134
+ const result = await saveObservation(db, config, { ...params, agent: getDetectedAgent() });
135
+
136
+ if (!result.success) {
137
+ return {
138
+ content: [
139
+ {
140
+ type: "text" as const,
141
+ text: `Not saved: ${result.reason}`,
142
+ },
143
+ ],
144
+ };
145
+ }
146
+
147
+ if (result.merged_into) {
148
+ return {
149
+ content: [
150
+ {
151
+ type: "text" as const,
152
+ text: `Merged into observation #${result.merged_into} (quality: ${result.quality_score?.toFixed(2)})`,
153
+ },
154
+ ],
155
+ };
156
+ }
157
+
158
+ // Handle supersession: archive the old observation
159
+ let supersessionNote = "";
160
+ if (params.supersedes && result.observation_id) {
161
+ const superseded = db.supersedeObservation(
162
+ params.supersedes,
163
+ result.observation_id
164
+ );
165
+ if (superseded) {
166
+ supersessionNote = `, supersedes #${params.supersedes}`;
167
+ }
168
+ }
169
+
170
+ return {
171
+ content: [
172
+ {
173
+ type: "text" as const,
174
+ text: `Saved observation #${result.observation_id} (quality: ${result.quality_score?.toFixed(2)}${supersessionNote})`,
175
+ },
176
+ ],
177
+ };
178
+ }
179
+ );
180
+
181
+ // Tool: search
182
+ server.tool(
183
+ "search",
184
+ "Search memory for observations",
185
+ {
186
+ query: z.string().describe("Search query"),
187
+ project_scoped: z.boolean().optional().describe("Scope to project (default: true)"),
188
+ limit: z.number().optional().describe("Max results (default: 10)"),
189
+ },
190
+ async (params) => {
191
+ const result = await searchObservations(db, params);
192
+
193
+ if (result.total === 0) {
194
+ return {
195
+ content: [
196
+ {
197
+ type: "text" as const,
198
+ text: result.project
199
+ ? `No observations found for "${params.query}" in project ${result.project}`
200
+ : `No observations found for "${params.query}"`,
201
+ },
202
+ ],
203
+ };
204
+ }
205
+
206
+ // Format as compact table
207
+ const header = "| ID | Type | Q | Title | Created |";
208
+ const separator = "|---|---|---|---|---|";
209
+ const rows = result.observations.map((obs) => {
210
+ const qualityDots = qualityIndicator(obs.quality);
211
+ const date = obs.created_at.split("T")[0];
212
+ return `| ${obs.id} | ${obs.type} | ${qualityDots} | ${obs.title} | ${date} |`;
213
+ });
214
+
215
+ const projectLine = result.project
216
+ ? `Project: ${result.project}\n`
217
+ : "";
218
+
219
+ return {
220
+ content: [
221
+ {
222
+ type: "text" as const,
223
+ text: `${projectLine}Found ${result.total} result(s):\n\n${header}\n${separator}\n${rows.join("\n")}`,
224
+ },
225
+ ],
226
+ };
227
+ }
228
+ );
229
+
230
+ // Tool: get_observations
231
+ server.tool(
232
+ "get_observations",
233
+ "Get observations by ID",
234
+ {
235
+ ids: z.array(z.number()).describe("Observation IDs"),
236
+ },
237
+ async (params) => {
238
+ const result = getObservations(db, params);
239
+
240
+ if (result.observations.length === 0) {
241
+ return {
242
+ content: [
243
+ {
244
+ type: "text" as const,
245
+ text: `No observations found for IDs: ${params.ids.join(", ")}`,
246
+ },
247
+ ],
248
+ };
249
+ }
250
+
251
+ const formatted = result.observations.map((obs) => {
252
+ const parts = [
253
+ `## Observation #${obs.id}`,
254
+ `**Type**: ${obs.type} | **Quality**: ${obs.quality.toFixed(2)} | **Lifecycle**: ${obs.lifecycle}`,
255
+ `**Title**: ${obs.title}`,
256
+ ];
257
+ if (obs.narrative) parts.push(`**Narrative**: ${obs.narrative}`);
258
+ if (obs.facts) parts.push(`**Facts**: ${obs.facts}`);
259
+ if (obs.concepts) parts.push(`**Concepts**: ${obs.concepts}`);
260
+ if (obs.files_modified)
261
+ parts.push(`**Files modified**: ${obs.files_modified}`);
262
+ if (obs.files_read) parts.push(`**Files read**: ${obs.files_read}`);
263
+ parts.push(`**Created**: ${obs.created_at}`);
264
+ return parts.join("\n");
265
+ });
266
+
267
+ let text = formatted.join("\n\n---\n\n");
268
+ if (result.not_found.length > 0) {
269
+ text += `\n\nNot found: ${result.not_found.join(", ")}`;
270
+ }
271
+
272
+ return {
273
+ content: [{ type: "text" as const, text }],
274
+ };
275
+ }
276
+ );
277
+
278
+ // Tool: timeline
279
+ server.tool(
280
+ "timeline",
281
+ "Timeline around an observation",
282
+ {
283
+ anchor: z.number().describe("Observation ID to centre on"),
284
+ depth_before: z.number().optional().describe("Before anchor (default: 3)"),
285
+ depth_after: z.number().optional().describe("After anchor (default: 3)"),
286
+ project_scoped: z.boolean().optional().describe("Scope to project (default: true)"),
287
+ },
288
+ async (params) => {
289
+ const result = getTimeline(db, {
290
+ anchor_id: params.anchor,
291
+ depth_before: params.depth_before,
292
+ depth_after: params.depth_after,
293
+ project_scoped: params.project_scoped,
294
+ });
295
+
296
+ if (result.observations.length === 0) {
297
+ return {
298
+ content: [
299
+ {
300
+ type: "text" as const,
301
+ text: `Observation #${params.anchor} not found`,
302
+ },
303
+ ],
304
+ };
305
+ }
306
+
307
+ const lines = result.observations.map((obs, i) => {
308
+ const marker = i === result.anchor_index ? "→" : " ";
309
+ const date = obs.created_at.split("T")[0];
310
+ return `${marker} #${obs.id} [${date}] ${obs.type}: ${obs.title}`;
311
+ });
312
+
313
+ const projectLine = result.project
314
+ ? `Project: ${result.project}\n`
315
+ : "";
316
+
317
+ return {
318
+ content: [
319
+ {
320
+ type: "text" as const,
321
+ text: `${projectLine}Timeline around #${params.anchor}:\n\n${lines.join("\n")}`,
322
+ },
323
+ ],
324
+ };
325
+ }
326
+ );
327
+
328
+ // Tool: pin_observation
329
+ server.tool(
330
+ "pin_observation",
331
+ "Pin/unpin observation",
332
+ {
333
+ id: z.number().describe("Observation ID"),
334
+ pinned: z.boolean().describe("true=pin, false=unpin"),
335
+ },
336
+ async (params) => {
337
+ const result = pinObservation(db, params);
338
+
339
+ return {
340
+ content: [
341
+ {
342
+ type: "text" as const,
343
+ text: result.success
344
+ ? `Observation #${params.id} ${params.pinned ? "pinned" : "unpinned"}`
345
+ : `Failed: ${result.reason}`,
346
+ },
347
+ ],
348
+ };
349
+ }
350
+ );
351
+
352
+ // Tool: session_context
353
+ server.tool(
354
+ "session_context",
355
+ "Load project memory for this session",
356
+ {
357
+ max_observations: z.number().optional().describe("Max observations (default: token-budgeted)"),
358
+ },
359
+ async (params) => {
360
+ // Double-injection guard
361
+ if (contextServed) {
362
+ return {
363
+ content: [
364
+ {
365
+ type: "text" as const,
366
+ text: "Context already loaded for this session. Use search for specific queries.",
367
+ },
368
+ ],
369
+ };
370
+ }
371
+
372
+ const context = buildSessionContext(
373
+ db,
374
+ process.cwd(),
375
+ params.max_observations
376
+ ? { maxCount: params.max_observations }
377
+ : { tokenBudget: 800 }
378
+ );
379
+
380
+ if (!context || context.observations.length === 0) {
381
+ return {
382
+ content: [
383
+ {
384
+ type: "text" as const,
385
+ text: context
386
+ ? `Project: ${context.project_name} — no prior observations found.`
387
+ : "Could not detect project.",
388
+ },
389
+ ],
390
+ };
391
+ }
392
+
393
+ contextServed = true;
394
+
395
+ return {
396
+ content: [
397
+ {
398
+ type: "text" as const,
399
+ text: formatContextForInjection(context),
400
+ },
401
+ ],
402
+ };
403
+ }
404
+ );
405
+
406
+ // --- Helpers ---
407
+
408
+ function qualityIndicator(quality: number): string {
409
+ const filled = Math.round(quality * 5);
410
+ return "●".repeat(filled) + "○".repeat(5 - filled);
411
+ }
412
+
413
+ // --- Start ---
414
+
415
+ async function main(): Promise<void> {
416
+ // Run lifecycle jobs if due (aging, compaction, purge)
417
+ runDueJobs(db);
418
+
419
+ // Backfill embeddings for observations without vectors (non-blocking)
420
+ if (db.vecAvailable) {
421
+ backfillEmbeddings(db, 100).catch(() => {});
422
+ }
423
+
424
+ // Start sync engine (no-op if not configured)
425
+ syncEngine = new SyncEngine(db, config);
426
+ syncEngine.start();
427
+
428
+ const transport = new StdioServerTransport();
429
+ await server.connect(transport);
430
+ }
431
+
432
+ main().catch((error) => {
433
+ console.error("Fatal:", error);
434
+ db.close();
435
+ process.exit(1);
436
+ });
@@ -0,0 +1,244 @@
1
+ import { describe, expect, test, afterEach } from "bun:test";
2
+ import { Database } from "bun:sqlite";
3
+ import { mkdtempSync, rmSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import { tmpdir } from "node:os";
6
+ import {
7
+ runMigrations,
8
+ getSchemaVersion,
9
+ LATEST_SCHEMA_VERSION,
10
+ } from "./migrations.js";
11
+
12
+ let tmpDir: string;
13
+
14
+ afterEach(() => {
15
+ if (tmpDir) rmSync(tmpDir, { recursive: true, force: true });
16
+ });
17
+
18
+ function createDb(): Database {
19
+ tmpDir = mkdtempSync(join(tmpdir(), "candengo-mem-migration-test-"));
20
+ const db = new Database(join(tmpDir, "test.db"));
21
+ db.exec("PRAGMA journal_mode = WAL");
22
+ db.exec("PRAGMA foreign_keys = ON");
23
+ return db;
24
+ }
25
+
26
+ describe("migrations", () => {
27
+ test("runMigrations creates all tables", () => {
28
+ const db = createDb();
29
+ runMigrations(db);
30
+
31
+ const tables = db
32
+ .query<{ name: string }, []>(
33
+ "SELECT name FROM sqlite_master WHERE type='table' ORDER BY name"
34
+ )
35
+ .all()
36
+ .map((r) => r.name);
37
+
38
+ expect(tables).toContain("projects");
39
+ expect(tables).toContain("observations");
40
+ expect(tables).toContain("sessions");
41
+ expect(tables).toContain("session_summaries");
42
+ expect(tables).toContain("sync_outbox");
43
+ expect(tables).toContain("sync_state");
44
+
45
+ db.close();
46
+ });
47
+
48
+ test("runMigrations creates FTS5 virtual table", () => {
49
+ const db = createDb();
50
+ runMigrations(db);
51
+
52
+ const tables = db
53
+ .query<{ name: string }, []>(
54
+ "SELECT name FROM sqlite_master WHERE type='table' AND name='observations_fts'"
55
+ )
56
+ .all();
57
+ expect(tables.length).toBe(1);
58
+
59
+ db.close();
60
+ });
61
+
62
+ test("runMigrations creates indexes", () => {
63
+ const db = createDb();
64
+ runMigrations(db);
65
+
66
+ const indexes = db
67
+ .query<{ name: string }, []>(
68
+ "SELECT name FROM sqlite_master WHERE type='index' AND name LIKE 'idx_%'"
69
+ )
70
+ .all()
71
+ .map((r) => r.name);
72
+
73
+ expect(indexes).toContain("idx_observations_project");
74
+ expect(indexes).toContain("idx_observations_project_lifecycle");
75
+ expect(indexes).toContain("idx_observations_created");
76
+ expect(indexes).toContain("idx_outbox_status");
77
+
78
+ db.close();
79
+ });
80
+
81
+ test("runMigrations is idempotent", () => {
82
+ const db = createDb();
83
+ runMigrations(db);
84
+ runMigrations(db); // second run should be a no-op
85
+ expect(getSchemaVersion(db)).toBe(LATEST_SCHEMA_VERSION);
86
+ db.close();
87
+ });
88
+
89
+ test("getSchemaVersion returns 0 for fresh database", () => {
90
+ const db = createDb();
91
+ expect(getSchemaVersion(db)).toBe(0);
92
+ db.close();
93
+ });
94
+
95
+ test("getSchemaVersion returns latest after migrations", () => {
96
+ const db = createDb();
97
+ runMigrations(db);
98
+ expect(getSchemaVersion(db)).toBe(LATEST_SCHEMA_VERSION);
99
+ expect(LATEST_SCHEMA_VERSION).toBeGreaterThan(0);
100
+ db.close();
101
+ });
102
+
103
+ test("CHECK constraints enforce valid observation types", () => {
104
+ const db = createDb();
105
+ runMigrations(db);
106
+
107
+ // Insert a valid project first
108
+ db.query(
109
+ "INSERT INTO projects (canonical_id, name, first_seen_epoch, last_active_epoch) VALUES (?, ?, ?, ?)"
110
+ ).run("test/project", "test", 0, 0);
111
+
112
+ // Valid type should work
113
+ expect(() => {
114
+ db.query(
115
+ `INSERT INTO observations (project_id, type, title, quality, user_id, device_id, created_at, created_at_epoch)
116
+ VALUES (1, 'bugfix', 'test', 0.5, 'user', 'device', '2024-01-01', 0)`
117
+ ).run();
118
+ }).not.toThrow();
119
+
120
+ // Invalid type should fail
121
+ expect(() => {
122
+ db.query(
123
+ `INSERT INTO observations (project_id, type, title, quality, user_id, device_id, created_at, created_at_epoch)
124
+ VALUES (1, 'invalid_type', 'test', 0.5, 'user', 'device', '2024-01-01', 0)`
125
+ ).run();
126
+ }).toThrow();
127
+
128
+ db.close();
129
+ });
130
+
131
+ test("CHECK constraints enforce quality range", () => {
132
+ const db = createDb();
133
+ runMigrations(db);
134
+
135
+ db.query(
136
+ "INSERT INTO projects (canonical_id, name, first_seen_epoch, last_active_epoch) VALUES (?, ?, ?, ?)"
137
+ ).run("test/project", "test", 0, 0);
138
+
139
+ // Quality > 1.0 should fail
140
+ expect(() => {
141
+ db.query(
142
+ `INSERT INTO observations (project_id, type, title, quality, user_id, device_id, created_at, created_at_epoch)
143
+ VALUES (1, 'bugfix', 'test', 1.5, 'user', 'device', '2024-01-01', 0)`
144
+ ).run();
145
+ }).toThrow();
146
+
147
+ // Quality < 0.0 should fail
148
+ expect(() => {
149
+ db.query(
150
+ `INSERT INTO observations (project_id, type, title, quality, user_id, device_id, created_at, created_at_epoch)
151
+ VALUES (1, 'bugfix', 'test', -0.1, 'user', 'device', '2024-01-01', 0)`
152
+ ).run();
153
+ }).toThrow();
154
+
155
+ db.close();
156
+ });
157
+
158
+ test("CHECK constraints enforce valid lifecycle", () => {
159
+ const db = createDb();
160
+ runMigrations(db);
161
+
162
+ db.query(
163
+ "INSERT INTO projects (canonical_id, name, first_seen_epoch, last_active_epoch) VALUES (?, ?, ?, ?)"
164
+ ).run("test/project", "test", 0, 0);
165
+
166
+ expect(() => {
167
+ db.query(
168
+ `INSERT INTO observations (project_id, type, title, quality, lifecycle, user_id, device_id, created_at, created_at_epoch)
169
+ VALUES (1, 'bugfix', 'test', 0.5, 'deleted', 'user', 'device', '2024-01-01', 0)`
170
+ ).run();
171
+ }).toThrow();
172
+
173
+ db.close();
174
+ });
175
+
176
+ test("migration v2 adds superseded_by column", () => {
177
+ const db = createDb();
178
+ runMigrations(db);
179
+
180
+ const columns = db
181
+ .query<{ name: string }, []>("PRAGMA table_info(observations)")
182
+ .all()
183
+ .map((r) => r.name);
184
+
185
+ expect(columns).toContain("superseded_by");
186
+ expect(getSchemaVersion(db)).toBe(LATEST_SCHEMA_VERSION);
187
+
188
+ db.close();
189
+ });
190
+
191
+ test("migration v2 creates superseded index", () => {
192
+ const db = createDb();
193
+ runMigrations(db);
194
+
195
+ const indexes = db
196
+ .query<{ name: string }, []>(
197
+ "SELECT name FROM sqlite_master WHERE type='index' AND name = 'idx_observations_superseded'"
198
+ )
199
+ .all();
200
+
201
+ expect(indexes.length).toBe(1);
202
+
203
+ db.close();
204
+ });
205
+
206
+ test("v1 database upgrades to v2 correctly", () => {
207
+ const db = createDb();
208
+
209
+ // Manually run v1 only by setting version ceiling
210
+ db.exec("PRAGMA journal_mode = WAL");
211
+ db.exec("PRAGMA foreign_keys = ON");
212
+
213
+ // Run migrations (will apply both v1 and v2)
214
+ runMigrations(db);
215
+ expect(getSchemaVersion(db)).toBe(LATEST_SCHEMA_VERSION);
216
+
217
+ // Verify superseded_by works with data
218
+ db.query(
219
+ "INSERT INTO projects (canonical_id, name, first_seen_epoch, last_active_epoch) VALUES (?, ?, ?, ?)"
220
+ ).run("test/proj", "test", 0, 0);
221
+
222
+ db.query(
223
+ `INSERT INTO observations (project_id, type, title, quality, user_id, device_id, created_at, created_at_epoch, superseded_by)
224
+ VALUES (1, 'decision', 'Old decision', 0.5, 'user', 'dev', '2024-01-01', 0, NULL)`
225
+ ).run();
226
+
227
+ db.query(
228
+ `INSERT INTO observations (project_id, type, title, quality, user_id, device_id, created_at, created_at_epoch, superseded_by)
229
+ VALUES (1, 'decision', 'New decision', 0.8, 'user', 'dev', '2024-01-01', 0, NULL)`
230
+ ).run();
231
+
232
+ // Set superseded_by
233
+ db.query("UPDATE observations SET superseded_by = 2 WHERE id = 1").run();
234
+
235
+ const superseded = db
236
+ .query<{ superseded_by: number | null }, [number]>(
237
+ "SELECT superseded_by FROM observations WHERE id = ?"
238
+ )
239
+ .get(1);
240
+ expect(superseded!.superseded_by).toBe(2);
241
+
242
+ db.close();
243
+ });
244
+ });