@drewpayment/mink 0.11.0 → 0.12.0-beta.2

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 (73) hide show
  1. package/dashboard/out/404.html +1 -1
  2. package/dashboard/out/action-log.html +1 -1
  3. package/dashboard/out/action-log.txt +1 -1
  4. package/dashboard/out/activity.html +1 -1
  5. package/dashboard/out/activity.txt +1 -1
  6. package/dashboard/out/bugs.html +1 -1
  7. package/dashboard/out/bugs.txt +1 -1
  8. package/dashboard/out/capture.html +1 -1
  9. package/dashboard/out/capture.txt +1 -1
  10. package/dashboard/out/config.html +1 -1
  11. package/dashboard/out/config.txt +1 -1
  12. package/dashboard/out/daemon.html +1 -1
  13. package/dashboard/out/daemon.txt +1 -1
  14. package/dashboard/out/design.html +1 -1
  15. package/dashboard/out/design.txt +1 -1
  16. package/dashboard/out/discord.html +1 -1
  17. package/dashboard/out/discord.txt +1 -1
  18. package/dashboard/out/file-index.html +1 -1
  19. package/dashboard/out/file-index.txt +1 -1
  20. package/dashboard/out/index.html +1 -1
  21. package/dashboard/out/index.txt +1 -1
  22. package/dashboard/out/insights.html +1 -1
  23. package/dashboard/out/insights.txt +1 -1
  24. package/dashboard/out/learning.html +1 -1
  25. package/dashboard/out/learning.txt +1 -1
  26. package/dashboard/out/overview.html +1 -1
  27. package/dashboard/out/overview.txt +1 -1
  28. package/dashboard/out/scheduler.html +1 -1
  29. package/dashboard/out/scheduler.txt +1 -1
  30. package/dashboard/out/sync.html +1 -1
  31. package/dashboard/out/sync.txt +1 -1
  32. package/dashboard/out/tokens.html +1 -1
  33. package/dashboard/out/tokens.txt +1 -1
  34. package/dashboard/out/waste.html +1 -1
  35. package/dashboard/out/waste.txt +1 -1
  36. package/dashboard/out/wiki.html +1 -1
  37. package/dashboard/out/wiki.txt +1 -1
  38. package/dist/cli.bun.js +90615 -0
  39. package/dist/cli.js +27 -92464
  40. package/dist/cli.node.js +93945 -0
  41. package/package.json +13 -2
  42. package/scripts/build.mjs +50 -0
  43. package/scripts/cli-shim.mjs +39 -0
  44. package/src/commands/bug-search.ts +2 -4
  45. package/src/commands/detect-waste.ts +24 -32
  46. package/src/commands/post-read.ts +10 -11
  47. package/src/commands/post-write.ts +13 -19
  48. package/src/commands/pre-read.ts +19 -24
  49. package/src/commands/scan.ts +103 -40
  50. package/src/commands/status.ts +45 -26
  51. package/src/core/bug-memory.ts +32 -34
  52. package/src/core/dashboard-api.ts +44 -22
  53. package/src/core/index-store.ts +23 -0
  54. package/src/core/paths.ts +7 -0
  55. package/src/core/scanner.ts +8 -4
  56. package/src/core/state-aggregator.ts +64 -7
  57. package/src/core/state-counters.ts +11 -31
  58. package/src/core/sync-merge-drivers.ts +164 -1
  59. package/src/core/sync.ts +9 -0
  60. package/src/core/token-ledger.ts +50 -4
  61. package/src/repositories/bug-memory-repo.ts +268 -0
  62. package/src/repositories/counters-repo.ts +88 -0
  63. package/src/repositories/file-index-repo.ts +238 -0
  64. package/src/repositories/token-ledger-repo.ts +412 -0
  65. package/src/storage/db.ts +121 -0
  66. package/src/storage/driver.bun.ts +99 -0
  67. package/src/storage/driver.node.ts +107 -0
  68. package/src/storage/driver.ts +76 -0
  69. package/src/storage/migrate-json.ts +415 -0
  70. package/src/storage/schema.ts +207 -0
  71. package/src/types/file-index.ts +9 -0
  72. /package/dashboard/out/_next/static/{I7QxkFr_LXY4BWGjogOs1 → 7bx94K8a7-O53mwi7UoEu}/_buildManifest.js +0 -0
  73. /package/dashboard/out/_next/static/{I7QxkFr_LXY4BWGjogOs1 → 7bx94K8a7-O53mwi7UoEu}/_ssgManifest.js +0 -0
@@ -0,0 +1,412 @@
1
+ // Token-ledger repository. Sessions + reads + writes + per-device lifetime
2
+ // counters all live in mink.db. The legacy archive file is subsumed by the
3
+ // `archived` column on ledger_sessions — "archive" becomes
4
+ // `UPDATE ledger_sessions SET archived = 1 WHERE ...`.
5
+ //
6
+ // Sessions are insert-only from the perspective of the merge driver
7
+ // (first-writer-wins keyed on session_id). The only post-insert mutations
8
+ // allowed are:
9
+ // - updateSession(): replaces totals/lists for the latest session before
10
+ // it's been seen by any other device — the in-process device knows
11
+ // its own session_id is exclusive until session_stop persists it.
12
+ // - archive(): flips `archived = 1` once the active-session count
13
+ // exceeds the retention threshold.
14
+ //
15
+ // Lifetime counters are also per-device; the merge driver MAX-merges them
16
+ // across shards so concurrent activity on different devices keeps the
17
+ // project-wide total monotonic.
18
+
19
+ import type { DbDriver } from "../storage/driver";
20
+ import type {
21
+ TokenLedger,
22
+ LedgerSession,
23
+ LifetimeCounters,
24
+ } from "../types/token-ledger";
25
+ import type { SessionSummary } from "../types/session";
26
+ import type { WasteFlag, WastePattern } from "../types/waste-detection";
27
+ import { openProjectDb } from "../storage/db";
28
+ import { getOrCreateDeviceId } from "../core/device";
29
+
30
+ function emptyLifetime(): LifetimeCounters {
31
+ return {
32
+ totalTokens: 0,
33
+ totalReads: 0,
34
+ totalWrites: 0,
35
+ totalSessions: 0,
36
+ totalFileIndexHits: 0,
37
+ totalFileIndexMisses: 0,
38
+ totalRepeatedReads: 0,
39
+ totalEstimatedSavings: 0,
40
+ };
41
+ }
42
+
43
+ export class TokenLedgerRepo {
44
+ constructor(private readonly db: DbDriver) {}
45
+
46
+ static for(cwd: string): TokenLedgerRepo {
47
+ return new TokenLedgerRepo(openProjectDb(cwd));
48
+ }
49
+
50
+ // ── Append / update sessions ──────────────────────────────────────────
51
+
52
+ // Insert a session as the active (archived = 0) ledger entry, append its
53
+ // reads/writes, and bump this device's lifetime counters. Wrapped in a
54
+ // transaction so a partial write never leaves the lifetime out of sync
55
+ // with the per-session rows.
56
+ appendSession(summary: SessionSummary, deviceId: string = getOrCreateDeviceId()): void {
57
+ this.db.transaction(() => {
58
+ this.insertSessionRow(summary, deviceId, 0);
59
+ this.appendChildRows(summary);
60
+ this.addToLifetime(deviceId, summary);
61
+ });
62
+ }
63
+
64
+ // Replace a previously-inserted active session. Used when a session is
65
+ // stopped multiple times — only the last stop's totals are authoritative
66
+ // for this device, so we subtract the old contribution and add the new.
67
+ // We don't allow updating an archived session (the merge driver assumes
68
+ // archived rows are immutable).
69
+ updateSession(summary: SessionSummary, deviceId: string = getOrCreateDeviceId()): void {
70
+ this.db.transaction(() => {
71
+ const existing = this.fetchSession(summary.sessionId);
72
+ if (!existing) {
73
+ this.insertSessionRow(summary, deviceId, 0);
74
+ this.appendChildRows(summary);
75
+ this.addToLifetime(deviceId, summary);
76
+ return;
77
+ }
78
+ this.subtractFromLifetime(existing.device_id, existing);
79
+ this.db.prepare(
80
+ "DELETE FROM ledger_reads WHERE session_id = ?"
81
+ ).run(summary.sessionId);
82
+ this.db.prepare(
83
+ "DELETE FROM ledger_writes WHERE session_id = ?"
84
+ ).run(summary.sessionId);
85
+ this.db.prepare(`
86
+ UPDATE ledger_sessions SET
87
+ start_timestamp = ?,
88
+ end_timestamp = ?,
89
+ read_count = ?,
90
+ write_count = ?,
91
+ estimated_tokens = ?,
92
+ repeated_reads = ?,
93
+ file_index_hits = ?,
94
+ file_index_misses = ?,
95
+ estimated_savings = ?
96
+ WHERE session_id = ?
97
+ `).run(
98
+ summary.startTimestamp, summary.endTimestamp,
99
+ summary.totals.readCount, summary.totals.writeCount,
100
+ summary.totals.estimatedTokens, summary.totals.repeatedReads,
101
+ summary.totals.fileIndexHits, summary.totals.fileIndexMisses,
102
+ summary.estimatedSavings, summary.sessionId
103
+ );
104
+ this.appendChildRows(summary);
105
+ this.addToLifetime(existing.device_id, summary);
106
+ });
107
+ }
108
+
109
+ // Archive everything past the retention threshold. Returns the number of
110
+ // sessions newly archived. We sort by start_timestamp ASC and flip the
111
+ // oldest ones so the most recent N stay active — same intent as the v1
112
+ // JSON archive flow.
113
+ archive(threshold: number = 1000): number {
114
+ if (threshold <= 0) return 0;
115
+ const active = Number(
116
+ (this.db.prepare(
117
+ "SELECT COUNT(*) AS n FROM ledger_sessions WHERE archived = 0"
118
+ ).get() as { n: number }).n
119
+ );
120
+ if (active <= threshold) return 0;
121
+ const excess = active - threshold;
122
+ const r = this.db.prepare(`
123
+ UPDATE ledger_sessions SET archived = 1
124
+ WHERE session_id IN (
125
+ SELECT session_id FROM ledger_sessions
126
+ WHERE archived = 0
127
+ ORDER BY start_timestamp ASC
128
+ LIMIT ?
129
+ )
130
+ `).run(excess);
131
+ return Number(r.changes);
132
+ }
133
+
134
+ // ── Read ──────────────────────────────────────────────────────────────
135
+
136
+ lifetime(): LifetimeCounters {
137
+ // Sum across every device's row — gives the project-wide total.
138
+ const row = this.db.prepare(`
139
+ SELECT
140
+ COALESCE(SUM(total_tokens), 0) AS totalTokens,
141
+ COALESCE(SUM(total_reads), 0) AS totalReads,
142
+ COALESCE(SUM(total_writes), 0) AS totalWrites,
143
+ COALESCE(SUM(total_sessions), 0) AS totalSessions,
144
+ COALESCE(SUM(total_file_index_hits), 0) AS totalFileIndexHits,
145
+ COALESCE(SUM(total_file_index_misses), 0) AS totalFileIndexMisses,
146
+ COALESCE(SUM(total_repeated_reads), 0) AS totalRepeatedReads,
147
+ COALESCE(SUM(total_estimated_savings), 0) AS totalEstimatedSavings
148
+ FROM ledger_lifetime
149
+ `).get();
150
+ if (!row) return emptyLifetime();
151
+ const r = row as Record<string, number>;
152
+ return {
153
+ totalTokens: Number(r.totalTokens),
154
+ totalReads: Number(r.totalReads),
155
+ totalWrites: Number(r.totalWrites),
156
+ totalSessions: Number(r.totalSessions),
157
+ totalFileIndexHits: Number(r.totalFileIndexHits),
158
+ totalFileIndexMisses: Number(r.totalFileIndexMisses),
159
+ totalRepeatedReads: Number(r.totalRepeatedReads),
160
+ totalEstimatedSavings: Number(r.totalEstimatedSavings),
161
+ };
162
+ }
163
+
164
+ // All active (archived = 0) sessions, hydrated with their reads + writes.
165
+ // Sorted by start_timestamp to match the JSON aggregator's ordering.
166
+ activeSessions(): LedgerSession[] {
167
+ return this.hydrateSessions(
168
+ "SELECT * FROM ledger_sessions WHERE archived = 0 ORDER BY start_timestamp"
169
+ );
170
+ }
171
+
172
+ archivedSessions(): LedgerSession[] {
173
+ return this.hydrateSessions(
174
+ "SELECT * FROM ledger_sessions WHERE archived = 1 ORDER BY start_timestamp"
175
+ );
176
+ }
177
+
178
+ // Project-wide snapshot in the legacy TokenLedger shape — used by the
179
+ // dashboard, status, and detect-waste. wasteFlags are pulled from
180
+ // waste_flags and deduped by (pattern, detected_at).
181
+ snapshot(): TokenLedger {
182
+ const ledger: TokenLedger = {
183
+ lifetime: this.lifetime(),
184
+ sessions: this.activeSessions(),
185
+ };
186
+ const flagRows = this.db
187
+ .prepare(
188
+ "SELECT pattern, detected_at, details FROM waste_flags ORDER BY detected_at"
189
+ )
190
+ .all();
191
+ if (flagRows.length > 0) {
192
+ ledger.wasteFlags = flagRows.map((r) => {
193
+ const row = r as { pattern: string; detected_at: string; details: string | null };
194
+ const flag: WasteFlag = {
195
+ pattern: row.pattern as WastePattern,
196
+ detectedAt: row.detected_at,
197
+ description: "",
198
+ estimatedTokensWasted: 0,
199
+ suggestion: "",
200
+ };
201
+ if (row.details) {
202
+ try {
203
+ const parsed = JSON.parse(row.details) as Partial<WasteFlag>;
204
+ Object.assign(flag, parsed);
205
+ } catch {
206
+ // ignore bad JSON — keep base flag
207
+ }
208
+ }
209
+ return flag;
210
+ });
211
+ }
212
+ return ledger;
213
+ }
214
+
215
+ // Replace all waste_flags rows for THIS device with the provided set.
216
+ // detect-waste re-runs and overwrites every cycle, so we don't try to
217
+ // merge with previous flags.
218
+ replaceWasteFlagsForDevice(
219
+ deviceId: string,
220
+ flags: NonNullable<TokenLedger["wasteFlags"]>
221
+ ): void {
222
+ this.db.transaction(() => {
223
+ this.db.prepare(
224
+ "DELETE FROM waste_flags WHERE device_id = ?"
225
+ ).run(deviceId);
226
+ const stmt = this.db.prepare(
227
+ "INSERT OR REPLACE INTO waste_flags (pattern, detected_at, details, device_id) VALUES (?, ?, ?, ?)"
228
+ );
229
+ for (const flag of flags) {
230
+ const { pattern, detectedAt, ...rest } = flag;
231
+ stmt.run(pattern, detectedAt, JSON.stringify(rest), deviceId);
232
+ }
233
+ });
234
+ }
235
+
236
+ // ── Helpers ───────────────────────────────────────────────────────────
237
+
238
+ private insertSessionRow(
239
+ summary: SessionSummary,
240
+ deviceId: string,
241
+ archived: 0 | 1
242
+ ): void {
243
+ this.db.prepare(`
244
+ INSERT OR REPLACE INTO ledger_sessions
245
+ (session_id, device_id, start_timestamp, end_timestamp,
246
+ read_count, write_count, estimated_tokens, repeated_reads,
247
+ file_index_hits, file_index_misses, estimated_savings, archived)
248
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
249
+ `).run(
250
+ summary.sessionId, deviceId,
251
+ summary.startTimestamp, summary.endTimestamp,
252
+ summary.totals.readCount, summary.totals.writeCount,
253
+ summary.totals.estimatedTokens, summary.totals.repeatedReads,
254
+ summary.totals.fileIndexHits, summary.totals.fileIndexMisses,
255
+ summary.estimatedSavings, archived
256
+ );
257
+ }
258
+
259
+ private appendChildRows(summary: SessionSummary): void {
260
+ const insertRead = this.db.prepare(
261
+ "INSERT INTO ledger_reads (session_id, file_path, estimated_tokens, read_count) VALUES (?, ?, ?, ?)"
262
+ );
263
+ for (const r of summary.reads ?? []) {
264
+ insertRead.run(summary.sessionId, r.filePath, r.estimatedTokens, r.readCount);
265
+ }
266
+ const insertWrite = this.db.prepare(
267
+ "INSERT INTO ledger_writes (session_id, file_path, estimated_tokens, action) VALUES (?, ?, ?, ?)"
268
+ );
269
+ for (const w of summary.writes ?? []) {
270
+ insertWrite.run(summary.sessionId, w.filePath, w.estimatedTokens, w.action);
271
+ }
272
+ }
273
+
274
+ private addToLifetime(deviceId: string, summary: SessionSummary): void {
275
+ this.adjustLifetime(deviceId, summary, +1);
276
+ }
277
+
278
+ private subtractFromLifetime(
279
+ deviceId: string,
280
+ existing: { estimated_tokens: number; read_count: number; write_count: number; file_index_hits: number; file_index_misses: number; repeated_reads: number; estimated_savings: number }
281
+ ): void {
282
+ // Reconstruct a SessionSummary-shaped delta from the stored row.
283
+ const synthetic: SessionSummary = {
284
+ sessionId: "",
285
+ startTimestamp: "",
286
+ endTimestamp: "",
287
+ reads: [],
288
+ writes: [],
289
+ totals: {
290
+ readCount: existing.read_count,
291
+ writeCount: existing.write_count,
292
+ estimatedTokens: existing.estimated_tokens,
293
+ repeatedReads: existing.repeated_reads,
294
+ fileIndexHits: existing.file_index_hits,
295
+ fileIndexMisses: existing.file_index_misses,
296
+ },
297
+ estimatedSavings: existing.estimated_savings,
298
+ };
299
+ this.adjustLifetime(deviceId, synthetic, -1);
300
+ }
301
+
302
+ private adjustLifetime(
303
+ deviceId: string,
304
+ summary: SessionSummary,
305
+ sign: 1 | -1
306
+ ): void {
307
+ const s = sign;
308
+ this.db.prepare(`
309
+ INSERT INTO ledger_lifetime
310
+ (device_id, total_tokens, total_reads, total_writes, total_sessions,
311
+ total_file_index_hits, total_file_index_misses, total_repeated_reads,
312
+ total_estimated_savings)
313
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
314
+ ON CONFLICT(device_id) DO UPDATE SET
315
+ total_tokens = ledger_lifetime.total_tokens + excluded.total_tokens,
316
+ total_reads = ledger_lifetime.total_reads + excluded.total_reads,
317
+ total_writes = ledger_lifetime.total_writes + excluded.total_writes,
318
+ total_sessions = ledger_lifetime.total_sessions + excluded.total_sessions,
319
+ total_file_index_hits = ledger_lifetime.total_file_index_hits + excluded.total_file_index_hits,
320
+ total_file_index_misses = ledger_lifetime.total_file_index_misses + excluded.total_file_index_misses,
321
+ total_repeated_reads = ledger_lifetime.total_repeated_reads + excluded.total_repeated_reads,
322
+ total_estimated_savings = ledger_lifetime.total_estimated_savings + excluded.total_estimated_savings
323
+ `).run(
324
+ deviceId,
325
+ s * summary.totals.estimatedTokens,
326
+ s * summary.totals.readCount,
327
+ s * summary.totals.writeCount,
328
+ s * 1,
329
+ s * summary.totals.fileIndexHits,
330
+ s * summary.totals.fileIndexMisses,
331
+ s * summary.totals.repeatedReads,
332
+ s * summary.estimatedSavings
333
+ );
334
+ }
335
+
336
+ private fetchSession(sessionId: string): {
337
+ session_id: string;
338
+ device_id: string;
339
+ estimated_tokens: number;
340
+ read_count: number;
341
+ write_count: number;
342
+ file_index_hits: number;
343
+ file_index_misses: number;
344
+ repeated_reads: number;
345
+ estimated_savings: number;
346
+ } | null {
347
+ const row = this.db
348
+ .prepare("SELECT * FROM ledger_sessions WHERE session_id = ?")
349
+ .get(sessionId);
350
+ if (!row) return null;
351
+ return row as never;
352
+ }
353
+
354
+ private hydrateSessions(sql: string): LedgerSession[] {
355
+ const rows = this.db.prepare(sql).all() as Array<Record<string, unknown>>;
356
+ if (rows.length === 0) return [];
357
+ const ids = rows.map((r) => String(r.session_id));
358
+ const readsBySession = this.groupChildren(
359
+ "SELECT session_id, file_path, estimated_tokens, read_count FROM ledger_reads WHERE session_id IN (" +
360
+ ids.map(() => "?").join(",") + ")",
361
+ ids
362
+ );
363
+ const writesBySession = this.groupChildren(
364
+ "SELECT session_id, file_path, estimated_tokens, action FROM ledger_writes WHERE session_id IN (" +
365
+ ids.map(() => "?").join(",") + ")",
366
+ ids
367
+ );
368
+ return rows.map((r) => {
369
+ const sid = String(r.session_id);
370
+ return {
371
+ sessionId: sid,
372
+ startTimestamp: String(r.start_timestamp),
373
+ endTimestamp: String(r.end_timestamp),
374
+ reads: (readsBySession.get(sid) ?? []).map((x) => ({
375
+ filePath: String(x.file_path),
376
+ estimatedTokens: Number(x.estimated_tokens),
377
+ readCount: Number(x.read_count),
378
+ })),
379
+ writes: (writesBySession.get(sid) ?? []).map((x) => ({
380
+ filePath: String(x.file_path),
381
+ estimatedTokens: Number(x.estimated_tokens),
382
+ action: x.action as "create" | "edit",
383
+ })),
384
+ totals: {
385
+ readCount: Number(r.read_count),
386
+ writeCount: Number(r.write_count),
387
+ estimatedTokens: Number(r.estimated_tokens),
388
+ repeatedReads: Number(r.repeated_reads),
389
+ fileIndexHits: Number(r.file_index_hits),
390
+ fileIndexMisses: Number(r.file_index_misses),
391
+ },
392
+ estimatedSavings: Number(r.estimated_savings),
393
+ };
394
+ });
395
+ }
396
+
397
+ private groupChildren(sql: string, ids: string[]): Map<string, Array<Record<string, unknown>>> {
398
+ const out = new Map<string, Array<Record<string, unknown>>>();
399
+ if (ids.length === 0) return out;
400
+ const rows = this.db.prepare(sql).all(...ids) as Array<Record<string, unknown>>;
401
+ for (const r of rows) {
402
+ const sid = String(r.session_id);
403
+ let list = out.get(sid);
404
+ if (!list) {
405
+ list = [];
406
+ out.set(sid, list);
407
+ }
408
+ list.push(r);
409
+ }
410
+ return out;
411
+ }
412
+ }
@@ -0,0 +1,121 @@
1
+ // Project database lifecycle. The handle is opened lazily and cached per
2
+ // process (hook commands are short-lived; their first call to a repository
3
+ // triggers the open, and the handle is closed via the registered exit hook).
4
+ //
5
+ // On first open for a project that has on-disk JSON state, the lazy JSON
6
+ // importer runs (see `migrate-json.ts`). The importer is idempotent — once
7
+ // `meta.migrated_from_json_at` is set, it returns immediately.
8
+
9
+ import { mkdirSync } from "fs";
10
+ import { dirname } from "path";
11
+ import { projectDbPath } from "../core/paths";
12
+ import { openDriver, type DbDriver } from "./driver";
13
+ import { applySchema } from "./schema";
14
+ import { migrateJsonIfNeeded } from "./migrate-json";
15
+
16
+ export { projectDbPath } from "../core/paths";
17
+
18
+ interface ConnectionEntry {
19
+ driver: DbDriver;
20
+ closed: boolean;
21
+ }
22
+
23
+ const handles = new Map<string, ConnectionEntry>();
24
+ let exitHookInstalled = false;
25
+
26
+ function installExitHook(): void {
27
+ if (exitHookInstalled) return;
28
+ exitHookInstalled = true;
29
+ const closeAll = (): void => {
30
+ for (const entry of handles.values()) {
31
+ if (entry.closed) continue;
32
+ try {
33
+ entry.driver.close();
34
+ } catch {
35
+ // best effort — process is shutting down
36
+ }
37
+ entry.closed = true;
38
+ }
39
+ };
40
+ process.on("exit", closeAll);
41
+ }
42
+
43
+ // Test-only — drop cached handles between tests that wipe MINK_ROOT_OVERRIDE.
44
+ // Production code never calls this; the exit hook handles real shutdown.
45
+ export function _resetDbCacheForTests(): void {
46
+ for (const entry of handles.values()) {
47
+ if (entry.closed) continue;
48
+ try {
49
+ entry.driver.close();
50
+ } catch {
51
+ // ignore
52
+ }
53
+ entry.closed = true;
54
+ }
55
+ handles.clear();
56
+ }
57
+
58
+ function applyPragmas(db: DbDriver): void {
59
+ // WAL: enables concurrent readers during a writer; survives crashes.
60
+ // synchronous=NORMAL: safe with WAL, ~2-5x faster than FULL.
61
+ // foreign_keys=ON: required for bug_tags / bug_related cascades.
62
+ // busy_timeout: matches the existing 5s hook safety timeout in
63
+ // src/core/runtime.ts — under contention SQLite will retry rather than
64
+ // throw SQLITE_BUSY immediately.
65
+ db.exec("PRAGMA journal_mode = WAL");
66
+ db.exec("PRAGMA synchronous = NORMAL");
67
+ db.exec("PRAGMA foreign_keys = ON");
68
+ db.exec("PRAGMA busy_timeout = 5000");
69
+ }
70
+
71
+ export function openProjectDb(cwd: string): DbDriver {
72
+ const path = projectDbPath(cwd);
73
+ const cached = handles.get(path);
74
+ if (cached && !cached.closed) return cached.driver;
75
+
76
+ mkdirSync(dirname(path), { recursive: true });
77
+ const driver = openDriver(path);
78
+ applyPragmas(driver);
79
+ applySchema(driver);
80
+
81
+ // Run migration AFTER applySchema so the importer can write into existing
82
+ // tables. The importer no-ops once `meta.migrated_from_json_at` is set.
83
+ try {
84
+ migrateJsonIfNeeded(driver, cwd);
85
+ } catch (err) {
86
+ // Migration failures should not block the process — log and continue
87
+ // with an empty DB. Phase 2 callers will fall back to legacy JSON reads.
88
+ // (We rethrow for tests via MINK_DB_STRICT_MIGRATE=1.)
89
+ if (process.env.MINK_DB_STRICT_MIGRATE === "1") throw err;
90
+ console.warn(
91
+ `[mink] JSON → SQLite migration failed for ${cwd}: ${
92
+ (err as Error).message
93
+ }`
94
+ );
95
+ }
96
+
97
+ installExitHook();
98
+ handles.set(path, { driver, closed: false });
99
+ return driver;
100
+ }
101
+
102
+ // Force a WAL checkpoint and close the handle for the given cwd. Used by
103
+ // `mink sync` before pushing so the .db is self-contained (the -wal/-shm
104
+ // sidecars are not synced).
105
+ export function checkpointAndClose(cwd: string): void {
106
+ const path = projectDbPath(cwd);
107
+ const entry = handles.get(path);
108
+ if (!entry || entry.closed) return;
109
+ try {
110
+ entry.driver.exec("PRAGMA wal_checkpoint(TRUNCATE)");
111
+ } catch {
112
+ // best effort
113
+ }
114
+ try {
115
+ entry.driver.close();
116
+ } catch {
117
+ // best effort
118
+ }
119
+ entry.closed = true;
120
+ handles.delete(path);
121
+ }
@@ -0,0 +1,99 @@
1
+ // bun:sqlite implementation of the DbDriver interface.
2
+ // Selected at build time when MINK_RUNTIME === "bun" and via `typeof Bun`
3
+ // detection when running unbundled.
4
+
5
+ import type { DbDriver, DriverModule, SqlParam, Statement } from "./driver";
6
+
7
+ // Use require() so the type-only import path doesn't trip Node when this
8
+ // file is loaded under the wrong runtime by mistake (the runtime dispatcher
9
+ // in driver.ts is supposed to prevent that).
10
+ const { Database } = require("bun:sqlite") as typeof import("bun:sqlite");
11
+
12
+ class BunStatement implements Statement {
13
+ constructor(private readonly stmt: import("bun:sqlite").Statement) {}
14
+
15
+ run(...params: SqlParam[]): { changes: number | bigint; lastInsertRowid: number | bigint } {
16
+ const r = this.stmt.run(...(params as never[]));
17
+ return { changes: r.changes, lastInsertRowid: r.lastInsertRowid };
18
+ }
19
+
20
+ get(...params: SqlParam[]) {
21
+ const row = this.stmt.get(...(params as never[]));
22
+ return (row ?? undefined) as Record<string, unknown> | undefined;
23
+ }
24
+
25
+ all(...params: SqlParam[]) {
26
+ return this.stmt.all(...(params as never[])) as Record<string, unknown>[];
27
+ }
28
+ }
29
+
30
+ class BunDriver implements DbDriver {
31
+ readonly filename: string;
32
+ private readonly db: import("bun:sqlite").Database;
33
+ private readonly txnDepth = { value: 0 };
34
+
35
+ constructor(filename: string) {
36
+ this.filename = filename;
37
+ this.db = new Database(filename, { create: true });
38
+ }
39
+
40
+ prepare(sql: string): Statement {
41
+ return new BunStatement(this.db.prepare(sql));
42
+ }
43
+
44
+ exec(sql: string): void {
45
+ this.db.exec(sql);
46
+ }
47
+
48
+ // We implement transactions manually (rather than using bun:sqlite's
49
+ // `db.transaction(fn)` wrapper) so semantics match node:sqlite exactly:
50
+ // synchronous, nestable via savepoints, IMMEDIATE locking to fail fast
51
+ // when another writer is mid-transaction.
52
+ transaction<T>(fn: () => T): T {
53
+ if (this.txnDepth.value > 0) {
54
+ const sp = `sp_${this.txnDepth.value}`;
55
+ this.db.exec(`SAVEPOINT ${sp}`);
56
+ this.txnDepth.value++;
57
+ try {
58
+ const result = fn();
59
+ this.db.exec(`RELEASE SAVEPOINT ${sp}`);
60
+ this.txnDepth.value--;
61
+ return result;
62
+ } catch (err) {
63
+ this.db.exec(`ROLLBACK TO SAVEPOINT ${sp}`);
64
+ this.db.exec(`RELEASE SAVEPOINT ${sp}`);
65
+ this.txnDepth.value--;
66
+ throw err;
67
+ }
68
+ }
69
+ this.db.exec("BEGIN IMMEDIATE");
70
+ this.txnDepth.value++;
71
+ try {
72
+ const result = fn();
73
+ this.db.exec("COMMIT");
74
+ this.txnDepth.value--;
75
+ return result;
76
+ } catch (err) {
77
+ this.db.exec("ROLLBACK");
78
+ this.txnDepth.value--;
79
+ throw err;
80
+ }
81
+ }
82
+
83
+ close(): void {
84
+ this.db.close();
85
+ }
86
+
87
+ pragma(stmt: string): unknown {
88
+ // bun:sqlite has no dedicated pragma() helper; route through exec/query.
89
+ // Pragmas that return a value (e.g. `journal_mode`) are SELECT-shaped.
90
+ if (/^[a-z_]+\s*=/i.test(stmt) || /^[a-z_]+\s*\([^)]*\)/i.test(stmt)) {
91
+ // Assignment or call form — no result expected, but the sqlite engine
92
+ // still returns the new value. Query so callers can read it.
93
+ return this.db.prepare(`PRAGMA ${stmt}`).all();
94
+ }
95
+ return this.db.prepare(`PRAGMA ${stmt}`).all();
96
+ }
97
+ }
98
+
99
+ export const open: DriverModule["open"] = (filename) => new BunDriver(filename);