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.
- package/.mcp.json +9 -0
- package/AUTH-DESIGN.md +436 -0
- package/BRIEF.md +197 -0
- package/CLAUDE.md +44 -0
- package/COMPETITIVE.md +174 -0
- package/CONTEXT-OPTIMIZATION.md +305 -0
- package/INFRASTRUCTURE.md +252 -0
- package/LICENSE +105 -0
- package/MARKET.md +230 -0
- package/PLAN.md +278 -0
- package/README.md +121 -0
- package/SENTINEL.md +293 -0
- package/SERVER-API-PLAN.md +553 -0
- package/SPEC.md +843 -0
- package/SWOT.md +148 -0
- package/SYNC-ARCHITECTURE.md +294 -0
- package/VIBE-CODER-STRATEGY.md +250 -0
- package/bun.lock +375 -0
- package/hooks/post-tool-use.ts +144 -0
- package/hooks/session-start.ts +64 -0
- package/hooks/stop.ts +131 -0
- package/mem-page.html +1305 -0
- package/package.json +30 -0
- package/src/capture/dedup.test.ts +103 -0
- package/src/capture/dedup.ts +76 -0
- package/src/capture/extractor.test.ts +245 -0
- package/src/capture/extractor.ts +330 -0
- package/src/capture/quality.test.ts +168 -0
- package/src/capture/quality.ts +104 -0
- package/src/capture/retrospective.test.ts +115 -0
- package/src/capture/retrospective.ts +121 -0
- package/src/capture/scanner.test.ts +131 -0
- package/src/capture/scanner.ts +100 -0
- package/src/capture/scrubber.test.ts +144 -0
- package/src/capture/scrubber.ts +181 -0
- package/src/cli.ts +517 -0
- package/src/config.ts +238 -0
- package/src/context/inject.test.ts +940 -0
- package/src/context/inject.ts +382 -0
- package/src/embeddings/backfill.ts +50 -0
- package/src/embeddings/embedder.test.ts +76 -0
- package/src/embeddings/embedder.ts +139 -0
- package/src/lifecycle/aging.test.ts +103 -0
- package/src/lifecycle/aging.ts +36 -0
- package/src/lifecycle/compaction.test.ts +264 -0
- package/src/lifecycle/compaction.ts +190 -0
- package/src/lifecycle/purge.test.ts +100 -0
- package/src/lifecycle/purge.ts +37 -0
- package/src/lifecycle/scheduler.test.ts +120 -0
- package/src/lifecycle/scheduler.ts +101 -0
- package/src/provisioning/browser-auth.ts +172 -0
- package/src/provisioning/provision.test.ts +198 -0
- package/src/provisioning/provision.ts +94 -0
- package/src/register.test.ts +167 -0
- package/src/register.ts +178 -0
- package/src/server.ts +436 -0
- package/src/storage/migrations.test.ts +244 -0
- package/src/storage/migrations.ts +261 -0
- package/src/storage/outbox.test.ts +229 -0
- package/src/storage/outbox.ts +131 -0
- package/src/storage/projects.test.ts +137 -0
- package/src/storage/projects.ts +184 -0
- package/src/storage/sqlite.test.ts +798 -0
- package/src/storage/sqlite.ts +934 -0
- package/src/storage/vec.test.ts +198 -0
- package/src/sync/auth.test.ts +76 -0
- package/src/sync/auth.ts +68 -0
- package/src/sync/client.ts +183 -0
- package/src/sync/engine.test.ts +94 -0
- package/src/sync/engine.ts +127 -0
- package/src/sync/pull.test.ts +279 -0
- package/src/sync/pull.ts +170 -0
- package/src/sync/push.test.ts +117 -0
- package/src/sync/push.ts +230 -0
- package/src/tools/get.ts +34 -0
- package/src/tools/pin.ts +47 -0
- package/src/tools/save.test.ts +301 -0
- package/src/tools/save.ts +231 -0
- package/src/tools/search.test.ts +69 -0
- package/src/tools/search.ts +181 -0
- package/src/tools/timeline.ts +64 -0
- 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
|
+
});
|