engrm 0.1.0 → 0.2.1

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 (98) hide show
  1. package/README.md +214 -73
  2. package/bin/build.mjs +97 -0
  3. package/bin/engrm.mjs +13 -0
  4. package/dist/cli.js +2712 -0
  5. package/dist/hooks/elicitation-result.js +1786 -0
  6. package/dist/hooks/post-tool-use.js +2357 -0
  7. package/dist/hooks/pre-compact.js +1321 -0
  8. package/dist/hooks/sentinel.js +1168 -0
  9. package/dist/hooks/session-start.js +1473 -0
  10. package/dist/hooks/stop.js +1834 -0
  11. package/dist/server.js +16628 -0
  12. package/package.json +34 -5
  13. package/packs/api-best-practices.json +182 -0
  14. package/packs/nextjs-patterns.json +68 -0
  15. package/packs/node-security.json +68 -0
  16. package/packs/python-django.json +68 -0
  17. package/packs/react-gotchas.json +182 -0
  18. package/packs/typescript-patterns.json +67 -0
  19. package/packs/web-security.json +182 -0
  20. package/.mcp.json +0 -9
  21. package/AUTH-DESIGN.md +0 -436
  22. package/BRIEF.md +0 -197
  23. package/CLAUDE.md +0 -44
  24. package/COMPETITIVE.md +0 -174
  25. package/CONTEXT-OPTIMIZATION.md +0 -305
  26. package/INFRASTRUCTURE.md +0 -252
  27. package/MARKET.md +0 -230
  28. package/PLAN.md +0 -278
  29. package/SENTINEL.md +0 -293
  30. package/SERVER-API-PLAN.md +0 -553
  31. package/SPEC.md +0 -843
  32. package/SWOT.md +0 -148
  33. package/SYNC-ARCHITECTURE.md +0 -294
  34. package/VIBE-CODER-STRATEGY.md +0 -250
  35. package/bun.lock +0 -375
  36. package/hooks/post-tool-use.ts +0 -144
  37. package/hooks/session-start.ts +0 -64
  38. package/hooks/stop.ts +0 -131
  39. package/mem-page.html +0 -1305
  40. package/src/capture/dedup.test.ts +0 -103
  41. package/src/capture/dedup.ts +0 -76
  42. package/src/capture/extractor.test.ts +0 -245
  43. package/src/capture/extractor.ts +0 -330
  44. package/src/capture/quality.test.ts +0 -168
  45. package/src/capture/quality.ts +0 -104
  46. package/src/capture/retrospective.test.ts +0 -115
  47. package/src/capture/retrospective.ts +0 -121
  48. package/src/capture/scanner.test.ts +0 -131
  49. package/src/capture/scanner.ts +0 -100
  50. package/src/capture/scrubber.test.ts +0 -144
  51. package/src/capture/scrubber.ts +0 -181
  52. package/src/cli.ts +0 -517
  53. package/src/config.ts +0 -238
  54. package/src/context/inject.test.ts +0 -940
  55. package/src/context/inject.ts +0 -382
  56. package/src/embeddings/backfill.ts +0 -50
  57. package/src/embeddings/embedder.test.ts +0 -76
  58. package/src/embeddings/embedder.ts +0 -139
  59. package/src/lifecycle/aging.test.ts +0 -103
  60. package/src/lifecycle/aging.ts +0 -36
  61. package/src/lifecycle/compaction.test.ts +0 -264
  62. package/src/lifecycle/compaction.ts +0 -190
  63. package/src/lifecycle/purge.test.ts +0 -100
  64. package/src/lifecycle/purge.ts +0 -37
  65. package/src/lifecycle/scheduler.test.ts +0 -120
  66. package/src/lifecycle/scheduler.ts +0 -101
  67. package/src/provisioning/browser-auth.ts +0 -172
  68. package/src/provisioning/provision.test.ts +0 -198
  69. package/src/provisioning/provision.ts +0 -94
  70. package/src/register.test.ts +0 -167
  71. package/src/register.ts +0 -178
  72. package/src/server.ts +0 -436
  73. package/src/storage/migrations.test.ts +0 -244
  74. package/src/storage/migrations.ts +0 -261
  75. package/src/storage/outbox.test.ts +0 -229
  76. package/src/storage/outbox.ts +0 -131
  77. package/src/storage/projects.test.ts +0 -137
  78. package/src/storage/projects.ts +0 -184
  79. package/src/storage/sqlite.test.ts +0 -798
  80. package/src/storage/sqlite.ts +0 -934
  81. package/src/storage/vec.test.ts +0 -198
  82. package/src/sync/auth.test.ts +0 -76
  83. package/src/sync/auth.ts +0 -68
  84. package/src/sync/client.ts +0 -183
  85. package/src/sync/engine.test.ts +0 -94
  86. package/src/sync/engine.ts +0 -127
  87. package/src/sync/pull.test.ts +0 -279
  88. package/src/sync/pull.ts +0 -170
  89. package/src/sync/push.test.ts +0 -117
  90. package/src/sync/push.ts +0 -230
  91. package/src/tools/get.ts +0 -34
  92. package/src/tools/pin.ts +0 -47
  93. package/src/tools/save.test.ts +0 -301
  94. package/src/tools/save.ts +0 -231
  95. package/src/tools/search.test.ts +0 -69
  96. package/src/tools/search.ts +0 -181
  97. package/src/tools/timeline.ts +0 -64
  98. package/tsconfig.json +0 -22
package/src/server.ts DELETED
@@ -1,436 +0,0 @@
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
- });
@@ -1,244 +0,0 @@
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
- });