ndomo 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 (247) hide show
  1. package/.bun-version +1 -0
  2. package/.dockerignore +79 -0
  3. package/.editorconfig +18 -0
  4. package/.env.example +19 -0
  5. package/.github/CODEOWNERS +8 -0
  6. package/.github/ISSUE_TEMPLATE/bug_report.yml +62 -0
  7. package/.github/ISSUE_TEMPLATE/config.yml +2 -0
  8. package/.github/ISSUE_TEMPLATE/feature_request.yml +34 -0
  9. package/.github/dependabot.yml +36 -0
  10. package/.github/pull_request_template.md +24 -0
  11. package/.github/release.yml +30 -0
  12. package/.github/workflows/gitleaks.yml +28 -0
  13. package/.github/workflows/release-please.yml +27 -0
  14. package/.github/workflows/smoke.yml +29 -0
  15. package/.husky/commit-msg +1 -0
  16. package/CHANGELOG.md +114 -0
  17. package/Dockerfile +32 -0
  18. package/README.es.md +174 -0
  19. package/README.md +187 -0
  20. package/agents/chronicler.md +98 -0
  21. package/agents/ci-smith.md +136 -0
  22. package/agents/craftsman.md +341 -0
  23. package/agents/deploy-smith.md +138 -0
  24. package/agents/foreman.md +377 -0
  25. package/agents/go-smith.md +164 -0
  26. package/agents/guild.md +188 -0
  27. package/agents/inspector.md +83 -0
  28. package/agents/js-smith.md +127 -0
  29. package/agents/ops-scout.md +173 -0
  30. package/agents/painter.md +200 -0
  31. package/agents/python-smith.md +120 -0
  32. package/agents/ranger.md +307 -0
  33. package/agents/release-smith.md +165 -0
  34. package/agents/rust-smith.md +159 -0
  35. package/agents/sage.md +178 -0
  36. package/agents/scout.md +144 -0
  37. package/agents/scribe.md +156 -0
  38. package/agents/smith.md +201 -0
  39. package/agents/vue-smith.md +155 -0
  40. package/agents/warden.md +216 -0
  41. package/agents/zig-smith.md +156 -0
  42. package/bin/ndomo-analyses.ts +4 -0
  43. package/bin/ndomo-status.ts +4 -0
  44. package/biome.json +57 -0
  45. package/bun.lock +514 -0
  46. package/commitlint.config.js +3 -0
  47. package/config/ndomo.config.json +258 -0
  48. package/config/ndomo.schema.json +166 -0
  49. package/docs/agents.md +375 -0
  50. package/docs/bugs/plan-create-orphan-fk.md +131 -0
  51. package/docs/bugs/task_create_batch-order-index-collision.md +158 -0
  52. package/docs/configuration.md +276 -0
  53. package/docs/database.md +364 -0
  54. package/docs/features/feature-flexible-builder-v1.md +724 -0
  55. package/docs/features/feature-flexible-builder-v2.md +882 -0
  56. package/docs/features/feature-flexible-builder.md +974 -0
  57. package/docs/http-server.md +244 -0
  58. package/docs/installation.md +259 -0
  59. package/docs/integrations.md +129 -0
  60. package/docs/operations/anti-pattern-sub-agent-verify-2026-06-21.md +32 -0
  61. package/docs/operations/audit-v1.md +417 -0
  62. package/docs/operations/audit-v2.md +197 -0
  63. package/docs/operations/audit-v3.md +306 -0
  64. package/docs/operations/db-optimize-foundations.md +123 -0
  65. package/docs/operations/verify-gate-architecture.md +82 -0
  66. package/docs/workflows.md +448 -0
  67. package/opencode.json +5 -0
  68. package/package.json +65 -0
  69. package/release-please-config.json +11 -0
  70. package/scripts/dev-bust-cache.sh +164 -0
  71. package/scripts/install.sh +688 -0
  72. package/scripts/smoke-e2e.ts +704 -0
  73. package/scripts/smoke-hot.ts +417 -0
  74. package/scripts/smoke-http.sh +228 -0
  75. package/scripts/smoke-v4.ts +256 -0
  76. package/scripts/smoke-v5.ts +397 -0
  77. package/scripts/smoke.sh +9 -0
  78. package/scripts/uninstall.sh +224 -0
  79. package/skills/api-security-best-practices/SKILL.md +915 -0
  80. package/skills/bash-scripting/SKILL.md +201 -0
  81. package/skills/bun/SKILL.md +313 -0
  82. package/skills/cavecrew/SKILL.md +82 -0
  83. package/skills/caveman/SKILL.md +74 -0
  84. package/skills/caveman-review/README.md +33 -0
  85. package/skills/caveman-review/SKILL.md +55 -0
  86. package/skills/find-skills/SKILL.md +142 -0
  87. package/skills/frontend-design/LICENSE.txt +177 -0
  88. package/skills/frontend-design/SKILL.md +55 -0
  89. package/skills/golang-patterns/SKILL.md +674 -0
  90. package/skills/golang-security/SKILL.md +185 -0
  91. package/skills/golang-security/evals/evals.json +595 -0
  92. package/skills/golang-security/references/architecture.md +268 -0
  93. package/skills/golang-security/references/checklist.md +80 -0
  94. package/skills/golang-security/references/cookies.md +200 -0
  95. package/skills/golang-security/references/cryptography.md +424 -0
  96. package/skills/golang-security/references/filesystem.md +285 -0
  97. package/skills/golang-security/references/injection.md +315 -0
  98. package/skills/golang-security/references/logging.md +163 -0
  99. package/skills/golang-security/references/memory-safety.md +241 -0
  100. package/skills/golang-security/references/network.md +253 -0
  101. package/skills/golang-security/references/secrets.md +189 -0
  102. package/skills/golang-security/references/third-party.md +159 -0
  103. package/skills/golang-security/references/threat-modeling.md +189 -0
  104. package/skills/golang-testing/SKILL.md +720 -0
  105. package/skills/grill-me/SKILL.md +7 -0
  106. package/skills/javascript-testing-patterns/SKILL.md +537 -0
  107. package/skills/javascript-testing-patterns/references/advanced-testing-patterns.md +513 -0
  108. package/skills/modern-javascript-patterns/SKILL.md +43 -0
  109. package/skills/modern-javascript-patterns/references/advanced-patterns.md +487 -0
  110. package/skills/modern-javascript-patterns/references/details.md +457 -0
  111. package/skills/python-anti-patterns/SKILL.md +349 -0
  112. package/skills/python-design-patterns/SKILL.md +85 -0
  113. package/skills/python-design-patterns/references/details.md +353 -0
  114. package/skills/python-error-handling/SKILL.md +193 -0
  115. package/skills/python-error-handling/references/details.md +171 -0
  116. package/skills/python-testing-patterns/SKILL.md +278 -0
  117. package/skills/python-testing-patterns/references/advanced-patterns.md +411 -0
  118. package/skills/python-testing-patterns/references/details.md +349 -0
  119. package/skills/rust-patterns/SKILL.md +500 -0
  120. package/skills/rust-testing/SKILL.md +501 -0
  121. package/skills/security-review/SKILL.md +504 -0
  122. package/skills/security-review/cloud-infrastructure-security.md +361 -0
  123. package/skills/vue-best-practices/SKILL.md +154 -0
  124. package/skills/vue-best-practices/references/animation-class-based-technique.md +254 -0
  125. package/skills/vue-best-practices/references/animation-state-driven-technique.md +291 -0
  126. package/skills/vue-best-practices/references/component-async.md +97 -0
  127. package/skills/vue-best-practices/references/component-data-flow.md +307 -0
  128. package/skills/vue-best-practices/references/component-fallthrough-attrs.md +174 -0
  129. package/skills/vue-best-practices/references/component-keep-alive.md +137 -0
  130. package/skills/vue-best-practices/references/component-slots.md +216 -0
  131. package/skills/vue-best-practices/references/component-suspense.md +228 -0
  132. package/skills/vue-best-practices/references/component-teleport.md +108 -0
  133. package/skills/vue-best-practices/references/component-transition-group.md +128 -0
  134. package/skills/vue-best-practices/references/component-transition.md +125 -0
  135. package/skills/vue-best-practices/references/composables.md +290 -0
  136. package/skills/vue-best-practices/references/directives.md +162 -0
  137. package/skills/vue-best-practices/references/perf-avoid-component-abstraction-in-lists.md +159 -0
  138. package/skills/vue-best-practices/references/perf-v-once-v-memo-directives.md +182 -0
  139. package/skills/vue-best-practices/references/perf-virtualize-large-lists.md +187 -0
  140. package/skills/vue-best-practices/references/plugins.md +166 -0
  141. package/skills/vue-best-practices/references/reactivity.md +344 -0
  142. package/skills/vue-best-practices/references/render-functions.md +201 -0
  143. package/skills/vue-best-practices/references/sfc.md +310 -0
  144. package/skills/vue-best-practices/references/state-management.md +135 -0
  145. package/skills/vue-best-practices/references/updated-hook-performance.md +187 -0
  146. package/skills/vue-pinia-best-practices/SKILL.md +21 -0
  147. package/skills/vue-pinia-best-practices/reference/pinia-no-active-pinia-error.md +248 -0
  148. package/skills/vue-pinia-best-practices/reference/pinia-setup-store-return-all-state.md +227 -0
  149. package/skills/vue-pinia-best-practices/reference/pinia-store-destructuring-breaks-reactivity.md +193 -0
  150. package/skills/vue-pinia-best-practices/reference/state-url-for-ephemeral-filters.md +238 -0
  151. package/skills/vue-pinia-best-practices/reference/state-use-pinia-for-large-apps.md +262 -0
  152. package/skills/vue-pinia-best-practices/reference/store-method-binding-parentheses.md +191 -0
  153. package/skills/zig-0.16/SKILL.md +840 -0
  154. package/skills/zig-0.16/scripts/check-zig-version.sh +21 -0
  155. package/src/cli/analyses.ts +280 -0
  156. package/src/cli/index.ts +108 -0
  157. package/src/cli/serve.ts +192 -0
  158. package/src/cli/smoke.ts +131 -0
  159. package/src/cli/status.test.ts +204 -0
  160. package/src/cli/status.ts +263 -0
  161. package/src/cli/vacuum.test.ts +82 -0
  162. package/src/cli/vacuum.ts +96 -0
  163. package/src/config/schema.test.ts +88 -0
  164. package/src/config/schema.ts +64 -0
  165. package/src/db/analyses-migration.test.ts +210 -0
  166. package/src/db/analyses.test.ts +466 -0
  167. package/src/db/analyses.ts +375 -0
  168. package/src/db/auto-checkpoint.ts +131 -0
  169. package/src/db/client.test.ts +129 -0
  170. package/src/db/client.ts +55 -0
  171. package/src/db/fts-escape.ts +20 -0
  172. package/src/db/incidents.test.ts +201 -0
  173. package/src/db/incidents.ts +93 -0
  174. package/src/db/index.ts +86 -0
  175. package/src/db/migrations-v13.test.ts +141 -0
  176. package/src/db/migrations-v8.test.ts +301 -0
  177. package/src/db/migrations.ts +147 -0
  178. package/src/db/plan-archive.test.ts +180 -0
  179. package/src/db/plan-archive.ts +274 -0
  180. package/src/db/plan-create.test.ts +276 -0
  181. package/src/db/plan-create.ts +78 -0
  182. package/src/db/plan-files.test.ts +289 -0
  183. package/src/db/plan-update-status.ts +287 -0
  184. package/src/db/plans.test.ts +490 -0
  185. package/src/db/plans.ts +534 -0
  186. package/src/db/resolve-project-dir.test.ts +143 -0
  187. package/src/db/resolve-project-dir.ts +75 -0
  188. package/src/db/rollbacks.test.ts +150 -0
  189. package/src/db/rollbacks.ts +67 -0
  190. package/src/db/schema.ts +907 -0
  191. package/src/db/sessions.test.ts +80 -0
  192. package/src/db/sessions.ts +135 -0
  193. package/src/db/shutdown.test.ts +147 -0
  194. package/src/db/shutdown.ts +45 -0
  195. package/src/db/tasks.test.ts +921 -0
  196. package/src/db/tasks.ts +747 -0
  197. package/src/db/types.ts +619 -0
  198. package/src/http/__tests__/auth.test.ts +196 -0
  199. package/src/http/__tests__/routes.test.ts +465 -0
  200. package/src/http/__tests__/sse.test.ts +317 -0
  201. package/src/http/auth.ts +72 -0
  202. package/src/http/middleware/cors.ts +53 -0
  203. package/src/http/middleware/security-headers.ts +21 -0
  204. package/src/http/routes/events.ts +112 -0
  205. package/src/http/routes/health.ts +51 -0
  206. package/src/http/routes/plans.ts +66 -0
  207. package/src/http/routes/sessions.ts +50 -0
  208. package/src/http/routes/tasks.ts +60 -0
  209. package/src/http/server.ts +95 -0
  210. package/src/http/sse.ts +116 -0
  211. package/src/index.ts +37 -0
  212. package/src/lib.ts +65 -0
  213. package/src/mem/scoped.ts +65 -0
  214. package/src/orchestrator/background.test.ts +268 -0
  215. package/src/orchestrator/background.ts +293 -0
  216. package/src/orchestrator/memory-hook.ts +182 -0
  217. package/src/orchestrator/reconciler.ts +123 -0
  218. package/src/orchestrator/scheduler.test.ts +300 -0
  219. package/src/orchestrator/scheduler.ts +243 -0
  220. package/src/plugin.test.ts +2574 -0
  221. package/src/plugin.ts +1690 -0
  222. package/src/sdk/client.ts +66 -0
  223. package/src/worktrees/manager.ts +236 -0
  224. package/src/worktrees/state.ts +87 -0
  225. package/tests/integration/ranger-flow.test.ts +257 -0
  226. package/tools/analysis_archive.ts +28 -0
  227. package/tools/analysis_create.ts +55 -0
  228. package/tools/analysis_get.ts +33 -0
  229. package/tools/analysis_link_plan.ts +44 -0
  230. package/tools/analysis_list.ts +48 -0
  231. package/tools/analysis_search.ts +36 -0
  232. package/tools/analysis_update.ts +44 -0
  233. package/tools/plan_approve.ts +31 -0
  234. package/tools/plan_create.ts +58 -0
  235. package/tools/plan_get.ts +40 -0
  236. package/tools/plan_list.ts +37 -0
  237. package/tools/plan_search.ts +34 -0
  238. package/tools/plan_update_status.ts +71 -0
  239. package/tools/session_checkpoint.ts +31 -0
  240. package/tools/session_end.ts +26 -0
  241. package/tools/session_start.ts +43 -0
  242. package/tools/task_create_batch.ts +70 -0
  243. package/tools/task_list.ts +35 -0
  244. package/tools/task_next_for_agent.ts +30 -0
  245. package/tools/task_search.ts +34 -0
  246. package/tools/task_update_status.ts +37 -0
  247. package/tsconfig.json +31 -0
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Tests for ensureSession — the idempotent FK-integrity helper.
3
+ *
4
+ * Uses in-memory SQLite via bun:sqlite. Each test gets a fresh DB
5
+ * with the full schema applied by runMigrations.
6
+ */
7
+
8
+ import { Database } from "bun:sqlite";
9
+ import { beforeEach, describe, expect, test } from "bun:test";
10
+ import { runMigrations } from "./migrations.ts";
11
+ import { ensureSession, getSession } from "./sessions.ts";
12
+
13
+ let db: Database;
14
+
15
+ beforeEach(() => {
16
+ db = new Database(":memory:");
17
+ db.exec("PRAGMA foreign_keys = ON");
18
+ runMigrations(db);
19
+ });
20
+
21
+ describe("ensureSession", () => {
22
+ test("inserts row when missing", () => {
23
+ const id = crypto.randomUUID();
24
+ ensureSession(db, id, "test goal");
25
+
26
+ const row = getSession(db, id);
27
+ expect(row).not.toBeNull();
28
+ expect(row?.id).toBe(id);
29
+ expect(row?.goal).toBe("test goal");
30
+ expect(row?.createdBy).toBe("auto");
31
+ // NOT NULL cols must be populated
32
+ expect(row?.startedAt).toBeGreaterThan(0);
33
+ expect(row?.lastCheckpoint).toBeGreaterThan(0);
34
+ expect(row?.state).toEqual({});
35
+ expect(row?.agentHistory).toEqual([]);
36
+ });
37
+
38
+ test("is idempotent on existing row with different goal", () => {
39
+ const id = crypto.randomUUID();
40
+ ensureSession(db, id, "goal A");
41
+ ensureSession(db, id, "goal B");
42
+
43
+ const row = getSession(db, id);
44
+ expect(row).not.toBeNull();
45
+ // INSERT OR IGNORE — original goal preserved
46
+ expect(row?.goal).toBe("goal A");
47
+ });
48
+
49
+ test("respects custom createdBy", () => {
50
+ const id = crypto.randomUUID();
51
+ ensureSession(db, id, "test goal", "test-agent");
52
+
53
+ const row = getSession(db, id);
54
+ expect(row).not.toBeNull();
55
+ expect(row?.createdBy).toBe("test-agent");
56
+ });
57
+
58
+ test("defaults createdBy to 'auto' when not specified", () => {
59
+ const id = crypto.randomUUID();
60
+ ensureSession(db, id, "test goal");
61
+
62
+ const row = getSession(db, id);
63
+ expect(row).not.toBeNull();
64
+ expect(row?.createdBy).toBe("auto");
65
+ });
66
+
67
+ test("populates started_at and last_checkpoint with current timestamp", () => {
68
+ const before = Date.now();
69
+ const id = crypto.randomUUID();
70
+ ensureSession(db, id, "timestamp check");
71
+ const after = Date.now();
72
+
73
+ const row = getSession(db, id);
74
+ expect(row).not.toBeNull();
75
+ expect(row?.startedAt).toBeGreaterThanOrEqual(before);
76
+ expect(row?.startedAt).toBeLessThanOrEqual(after);
77
+ expect(row?.lastCheckpoint).toBeGreaterThanOrEqual(before);
78
+ expect(row?.lastCheckpoint).toBeLessThanOrEqual(after);
79
+ });
80
+ });
@@ -0,0 +1,135 @@
1
+ /**
2
+ * ndomo DB — Session lifecycle management.
3
+ *
4
+ * Sessions track continuity across multiple agents working
5
+ * toward a shared goal. They record checkpoints, agent history,
6
+ * and key decisions.
7
+ */
8
+
9
+ import type { Database } from "bun:sqlite";
10
+ import type { Session, SessionMetadata } from "./types.ts";
11
+ import { sessionFromRow } from "./types.ts";
12
+
13
+ /**
14
+ * Idempotent upsert: ensure a session row exists for FK integrity.
15
+ *
16
+ * Used by plan_create and plan transition functions so that the harness
17
+ * session ID (ctx.sessionID) always has a matching row in `sessions`.
18
+ * INSERT OR IGNORE is safe — if the row already exists, this is a no-op.
19
+ * Does NOT call `startSession` (which requires full Session shape).
20
+ */
21
+ export function ensureSession(
22
+ db: Database,
23
+ sessionId: string,
24
+ fallbackGoal: string,
25
+ createdBy = "auto",
26
+ ): void {
27
+ const now = Date.now();
28
+ db.query(
29
+ `INSERT OR IGNORE INTO sessions (id, started_at, last_checkpoint, goal, state, agent_history, created_by)
30
+ VALUES (?, ?, ?, ?, '{}', '[]', ?)`,
31
+ ).run(sessionId, now, now, fallbackGoal, createdBy);
32
+ }
33
+
34
+ export function startSession(
35
+ db: Database,
36
+ session: {
37
+ id: string;
38
+ goal: string;
39
+ planId?: string;
40
+ metadata?: SessionMetadata;
41
+ createdBy?: string;
42
+ sourceMessageId?: string;
43
+ },
44
+ ): Session {
45
+ const now = Date.now();
46
+ const meta = session.metadata ?? {};
47
+ db.query(
48
+ `INSERT INTO sessions (id, started_at, last_checkpoint, plan_id, goal, state, agent_history, key_decisions, metadata, created_by, source_message_id)
49
+ VALUES (?, ?, ?, ?, ?, '{}', '[]', NULL, ?, ?, ?)`,
50
+ ).run(
51
+ session.id,
52
+ now,
53
+ now,
54
+ session.planId ?? null,
55
+ session.goal,
56
+ JSON.stringify(meta),
57
+ session.createdBy ?? "unknown",
58
+ session.sourceMessageId ?? null,
59
+ );
60
+ const created = getSession(db, session.id);
61
+ if (!created) throw new Error("ndomo: failed to create session");
62
+ return created;
63
+ }
64
+
65
+ export function getSession(db: Database, id: string): Session | null {
66
+ const row = db.query("SELECT * FROM sessions WHERE id = ?").get(id);
67
+ return row ? sessionFromRow(row) : null;
68
+ }
69
+
70
+ export function listSessions(
71
+ db: Database,
72
+ opts: { planId?: string; limit?: number; includeArchived?: boolean } = {},
73
+ ): Session[] {
74
+ const limit = opts.limit ?? 20;
75
+ const conditions: string[] = [];
76
+ const params: (string | number)[] = [];
77
+
78
+ if (opts.planId) {
79
+ conditions.push("plan_id = ?");
80
+ params.push(opts.planId);
81
+ }
82
+ if (!opts.includeArchived) {
83
+ conditions.push("archived_at IS NULL");
84
+ }
85
+
86
+ const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
87
+ params.push(limit);
88
+
89
+ const rows = db
90
+ .query(`SELECT * FROM sessions ${where} ORDER BY started_at DESC LIMIT ?`)
91
+ .all(...params);
92
+ return (rows as unknown[]).map((r) => sessionFromRow(r));
93
+ }
94
+
95
+ export function checkpointSession(
96
+ db: Database,
97
+ id: string,
98
+ state: Record<string, unknown>,
99
+ keyDecisions?: string,
100
+ ): Session | null {
101
+ const now = Date.now();
102
+ const result = db
103
+ .query(
104
+ "UPDATE sessions SET last_checkpoint = ?, state = ?, key_decisions = COALESCE(?, key_decisions) WHERE id = ?",
105
+ )
106
+ .run(now, JSON.stringify(state), keyDecisions ?? null, id);
107
+ if (result.changes === 0) return null;
108
+ return getSession(db, id);
109
+ }
110
+
111
+ export function appendAgentHistory(
112
+ db: Database,
113
+ id: string,
114
+ entry: {
115
+ agent: string;
116
+ taskId?: string;
117
+ startedAt?: number;
118
+ endedAt?: number | null;
119
+ },
120
+ ): Session | null {
121
+ const sess = getSession(db, id);
122
+ if (!sess) return null;
123
+ const history = [...sess.agentHistory, { ...entry, startedAt: entry.startedAt ?? Date.now() }];
124
+ db.query("UPDATE sessions SET agent_history = ? WHERE id = ?").run(JSON.stringify(history), id);
125
+ return getSession(db, id);
126
+ }
127
+
128
+ export function endSession(db: Database, id: string): Session | null {
129
+ const now = Date.now();
130
+ const result = db
131
+ .query("UPDATE sessions SET ended_at = ? WHERE id = ? AND ended_at IS NULL")
132
+ .run(now, id);
133
+ if (result.changes === 0) return null;
134
+ return getSession(db, id);
135
+ }
@@ -0,0 +1,147 @@
1
+ /**
2
+ * Tests for shutdown.ts — plan fcb12dc5 (memory hygiene, finding #2).
3
+ *
4
+ * Validates that:
5
+ * - Multiple openDb() calls each get SIGTERM/SIGINT/beforeExit cleanup
6
+ * (regression: module-level `registered` boolean skipped all but the first).
7
+ * - unregister(db) explicitly removes a db from cleanup tracking (needed
8
+ * by tests, hot-reload, and explicit teardown paths).
9
+ * - process.once is used for SIGTERM/SIGINT so listeners self-remove after
10
+ * firing (regression: process.on would leak listeners across signals).
11
+ * - SIGTERM cleanly closes all tracked dbs (verify by attempting use-after-close).
12
+ */
13
+
14
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
15
+ import { mkdtempSync, rmSync } from "node:fs";
16
+ import { tmpdir } from "node:os";
17
+ import { join } from "node:path";
18
+ import { closeDb, openDb } from "./client.ts";
19
+ import { getRegisteredDbCount, registerShutdownHandlers, unregister } from "./shutdown.ts";
20
+
21
+ describe("shutdown — tracking", () => {
22
+ let tmpDir: string;
23
+
24
+ beforeEach(() => {
25
+ tmpDir = mkdtempSync(join(tmpdir(), "ndomo-shutdown-test-"));
26
+ });
27
+
28
+ afterEach(() => {
29
+ rmSync(tmpDir, { recursive: true, force: true });
30
+ });
31
+
32
+ test("registerShutdownHandlers tracks each db in a Set", () => {
33
+ const baseline = getRegisteredDbCount();
34
+ const db1 = openDb(tmpDir);
35
+ const db2 = openDb(join(tmpDir, "sub"));
36
+
37
+ registerShutdownHandlers(db1);
38
+ expect(getRegisteredDbCount()).toBe(baseline + 1);
39
+
40
+ registerShutdownHandlers(db2);
41
+ expect(getRegisteredDbCount()).toBe(baseline + 2);
42
+
43
+ // Calling again with the same db should not double-count (Set semantics).
44
+ registerShutdownHandlers(db1);
45
+ expect(getRegisteredDbCount()).toBe(baseline + 2);
46
+
47
+ unregister(db1);
48
+ unregister(db2);
49
+ closeDb(db1);
50
+ closeDb(db2);
51
+ });
52
+
53
+ test("unregister(db) removes db from tracking", () => {
54
+ const db = openDb(tmpDir);
55
+ const baseline = getRegisteredDbCount();
56
+
57
+ registerShutdownHandlers(db);
58
+ expect(getRegisteredDbCount()).toBe(baseline + 1);
59
+
60
+ unregister(db);
61
+ expect(getRegisteredDbCount()).toBe(baseline);
62
+
63
+ closeDb(db);
64
+ });
65
+
66
+ test("unregister of an untracked db is a no-op (no throw)", () => {
67
+ const db = openDb(tmpDir);
68
+ expect(() => unregister(db)).not.toThrow();
69
+ closeDb(db);
70
+ });
71
+ });
72
+
73
+ describe("shutdown — SIGTERM behavior", () => {
74
+ let tmpDir: string;
75
+
76
+ beforeEach(() => {
77
+ tmpDir = mkdtempSync(join(tmpdir(), "ndomo-shutdown-sigterm-"));
78
+ });
79
+
80
+ afterEach(() => {
81
+ rmSync(tmpDir, { recursive: true, force: true });
82
+ });
83
+
84
+ test("SIGTERM closes ALL registered dbs (regression: 2nd db used to leak)", () => {
85
+ const db1 = openDb(tmpDir);
86
+ const db2 = openDb(join(tmpDir, "sub"));
87
+ registerShutdownHandlers(db1);
88
+ registerShutdownHandlers(db2);
89
+
90
+ process.emit("SIGTERM" as NodeJS.Signals);
91
+
92
+ // Both dbs should now be closed — using them throws.
93
+ expect(() => db1.query("SELECT 1").get()).toThrow();
94
+ expect(() => db2.query("SELECT 1").get()).toThrow();
95
+ });
96
+
97
+ test("SIGINT closes ALL registered dbs", () => {
98
+ const db1 = openDb(tmpDir);
99
+ const db2 = openDb(join(tmpDir, "sub"));
100
+ registerShutdownHandlers(db1);
101
+ registerShutdownHandlers(db2);
102
+
103
+ process.emit("SIGINT" as NodeJS.Signals);
104
+
105
+ expect(() => db1.query("SELECT 1").get()).toThrow();
106
+ expect(() => db2.query("SELECT 1").get()).toThrow();
107
+ });
108
+
109
+ test("unregistered db is NOT closed on SIGTERM", () => {
110
+ const db1 = openDb(tmpDir);
111
+ const db2 = openDb(join(tmpDir, "sub"));
112
+ registerShutdownHandlers(db1);
113
+ registerShutdownHandlers(db2);
114
+ unregister(db2);
115
+
116
+ process.emit("SIGTERM" as NodeJS.Signals);
117
+
118
+ // db1 closed, db2 still usable
119
+ expect(() => db1.query("SELECT 1").get()).toThrow();
120
+ expect(db2.query("SELECT 1").get()).not.toBeNull();
121
+
122
+ // Cleanup
123
+ closeDb(db2);
124
+ });
125
+
126
+ test("process.once — SIGTERM listeners self-remove after firing (no leak)", () => {
127
+ const db1 = openDb(tmpDir);
128
+ const db2 = openDb(join(tmpDir, "sub"));
129
+
130
+ const beforeRegister = process.listenerCount("SIGTERM");
131
+ registerShutdownHandlers(db1);
132
+ registerShutdownHandlers(db2);
133
+ const afterRegister = process.listenerCount("SIGTERM");
134
+
135
+ // We added 2 SIGTERM listeners (one per db registration)
136
+ expect(afterRegister - beforeRegister).toBe(2);
137
+
138
+ // Emit SIGTERM — both listeners fire cleanup, then process.once removes them
139
+ process.emit("SIGTERM" as NodeJS.Signals);
140
+
141
+ const afterEmit = process.listenerCount("SIGTERM");
142
+
143
+ // process.once guarantees listeners deregister after firing — count must
144
+ // drop back to pre-registration baseline (no accumulation across signals).
145
+ expect(afterEmit).toBe(beforeRegister);
146
+ });
147
+ });
@@ -0,0 +1,45 @@
1
+ /**
2
+ * ndomo DB — Graceful shutdown handler.
3
+ *
4
+ * Tracks every Database returned by openDb() in a module-level Set so each
5
+ * connection gets SIGTERM/SIGINT/beforeExit cleanup. Replaces the previous
6
+ * module-level `registered` boolean which silently skipped every call after
7
+ * the first (resulting in leaked file handles on hot-reload, smoke tests,
8
+ * and CLI tools running alongside the plugin).
9
+ *
10
+ * Signal handlers use `process.once` so listeners self-remove after firing —
11
+ * prevents listener accumulation when multiple dbs register in sequence.
12
+ */
13
+
14
+ import type { Database } from "bun:sqlite";
15
+ import { closeDb } from "./client.ts";
16
+
17
+ const registeredDbs = new Set<Database>();
18
+
19
+ export function registerShutdownHandlers(db: Database): void {
20
+ if (registeredDbs.has(db)) return;
21
+ registeredDbs.add(db);
22
+
23
+ const cleanup = (): void => {
24
+ for (const tracked of registeredDbs) {
25
+ try {
26
+ closeDb(tracked);
27
+ } catch {
28
+ /* ignore — already closed or never opened */
29
+ }
30
+ }
31
+ registeredDbs.clear();
32
+ };
33
+
34
+ process.once("SIGTERM", cleanup);
35
+ process.once("SIGINT", cleanup);
36
+ process.once("beforeExit", cleanup);
37
+ }
38
+
39
+ export function unregister(db: Database): void {
40
+ registeredDbs.delete(db);
41
+ }
42
+
43
+ export function getRegisteredDbCount(): number {
44
+ return registeredDbs.size;
45
+ }