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.
- package/.bun-version +1 -0
- package/.dockerignore +79 -0
- package/.editorconfig +18 -0
- package/.env.example +19 -0
- package/.github/CODEOWNERS +8 -0
- package/.github/ISSUE_TEMPLATE/bug_report.yml +62 -0
- package/.github/ISSUE_TEMPLATE/config.yml +2 -0
- package/.github/ISSUE_TEMPLATE/feature_request.yml +34 -0
- package/.github/dependabot.yml +36 -0
- package/.github/pull_request_template.md +24 -0
- package/.github/release.yml +30 -0
- package/.github/workflows/gitleaks.yml +28 -0
- package/.github/workflows/release-please.yml +27 -0
- package/.github/workflows/smoke.yml +29 -0
- package/.husky/commit-msg +1 -0
- package/CHANGELOG.md +114 -0
- package/Dockerfile +32 -0
- package/README.es.md +174 -0
- package/README.md +187 -0
- package/agents/chronicler.md +98 -0
- package/agents/ci-smith.md +136 -0
- package/agents/craftsman.md +341 -0
- package/agents/deploy-smith.md +138 -0
- package/agents/foreman.md +377 -0
- package/agents/go-smith.md +164 -0
- package/agents/guild.md +188 -0
- package/agents/inspector.md +83 -0
- package/agents/js-smith.md +127 -0
- package/agents/ops-scout.md +173 -0
- package/agents/painter.md +200 -0
- package/agents/python-smith.md +120 -0
- package/agents/ranger.md +307 -0
- package/agents/release-smith.md +165 -0
- package/agents/rust-smith.md +159 -0
- package/agents/sage.md +178 -0
- package/agents/scout.md +144 -0
- package/agents/scribe.md +156 -0
- package/agents/smith.md +201 -0
- package/agents/vue-smith.md +155 -0
- package/agents/warden.md +216 -0
- package/agents/zig-smith.md +156 -0
- package/bin/ndomo-analyses.ts +4 -0
- package/bin/ndomo-status.ts +4 -0
- package/biome.json +57 -0
- package/bun.lock +514 -0
- package/commitlint.config.js +3 -0
- package/config/ndomo.config.json +258 -0
- package/config/ndomo.schema.json +166 -0
- package/docs/agents.md +375 -0
- package/docs/bugs/plan-create-orphan-fk.md +131 -0
- package/docs/bugs/task_create_batch-order-index-collision.md +158 -0
- package/docs/configuration.md +276 -0
- package/docs/database.md +364 -0
- package/docs/features/feature-flexible-builder-v1.md +724 -0
- package/docs/features/feature-flexible-builder-v2.md +882 -0
- package/docs/features/feature-flexible-builder.md +974 -0
- package/docs/http-server.md +244 -0
- package/docs/installation.md +259 -0
- package/docs/integrations.md +129 -0
- package/docs/operations/anti-pattern-sub-agent-verify-2026-06-21.md +32 -0
- package/docs/operations/audit-v1.md +417 -0
- package/docs/operations/audit-v2.md +197 -0
- package/docs/operations/audit-v3.md +306 -0
- package/docs/operations/db-optimize-foundations.md +123 -0
- package/docs/operations/verify-gate-architecture.md +82 -0
- package/docs/workflows.md +448 -0
- package/opencode.json +5 -0
- package/package.json +65 -0
- package/release-please-config.json +11 -0
- package/scripts/dev-bust-cache.sh +164 -0
- package/scripts/install.sh +688 -0
- package/scripts/smoke-e2e.ts +704 -0
- package/scripts/smoke-hot.ts +417 -0
- package/scripts/smoke-http.sh +228 -0
- package/scripts/smoke-v4.ts +256 -0
- package/scripts/smoke-v5.ts +397 -0
- package/scripts/smoke.sh +9 -0
- package/scripts/uninstall.sh +224 -0
- package/skills/api-security-best-practices/SKILL.md +915 -0
- package/skills/bash-scripting/SKILL.md +201 -0
- package/skills/bun/SKILL.md +313 -0
- package/skills/cavecrew/SKILL.md +82 -0
- package/skills/caveman/SKILL.md +74 -0
- package/skills/caveman-review/README.md +33 -0
- package/skills/caveman-review/SKILL.md +55 -0
- package/skills/find-skills/SKILL.md +142 -0
- package/skills/frontend-design/LICENSE.txt +177 -0
- package/skills/frontend-design/SKILL.md +55 -0
- package/skills/golang-patterns/SKILL.md +674 -0
- package/skills/golang-security/SKILL.md +185 -0
- package/skills/golang-security/evals/evals.json +595 -0
- package/skills/golang-security/references/architecture.md +268 -0
- package/skills/golang-security/references/checklist.md +80 -0
- package/skills/golang-security/references/cookies.md +200 -0
- package/skills/golang-security/references/cryptography.md +424 -0
- package/skills/golang-security/references/filesystem.md +285 -0
- package/skills/golang-security/references/injection.md +315 -0
- package/skills/golang-security/references/logging.md +163 -0
- package/skills/golang-security/references/memory-safety.md +241 -0
- package/skills/golang-security/references/network.md +253 -0
- package/skills/golang-security/references/secrets.md +189 -0
- package/skills/golang-security/references/third-party.md +159 -0
- package/skills/golang-security/references/threat-modeling.md +189 -0
- package/skills/golang-testing/SKILL.md +720 -0
- package/skills/grill-me/SKILL.md +7 -0
- package/skills/javascript-testing-patterns/SKILL.md +537 -0
- package/skills/javascript-testing-patterns/references/advanced-testing-patterns.md +513 -0
- package/skills/modern-javascript-patterns/SKILL.md +43 -0
- package/skills/modern-javascript-patterns/references/advanced-patterns.md +487 -0
- package/skills/modern-javascript-patterns/references/details.md +457 -0
- package/skills/python-anti-patterns/SKILL.md +349 -0
- package/skills/python-design-patterns/SKILL.md +85 -0
- package/skills/python-design-patterns/references/details.md +353 -0
- package/skills/python-error-handling/SKILL.md +193 -0
- package/skills/python-error-handling/references/details.md +171 -0
- package/skills/python-testing-patterns/SKILL.md +278 -0
- package/skills/python-testing-patterns/references/advanced-patterns.md +411 -0
- package/skills/python-testing-patterns/references/details.md +349 -0
- package/skills/rust-patterns/SKILL.md +500 -0
- package/skills/rust-testing/SKILL.md +501 -0
- package/skills/security-review/SKILL.md +504 -0
- package/skills/security-review/cloud-infrastructure-security.md +361 -0
- package/skills/vue-best-practices/SKILL.md +154 -0
- package/skills/vue-best-practices/references/animation-class-based-technique.md +254 -0
- package/skills/vue-best-practices/references/animation-state-driven-technique.md +291 -0
- package/skills/vue-best-practices/references/component-async.md +97 -0
- package/skills/vue-best-practices/references/component-data-flow.md +307 -0
- package/skills/vue-best-practices/references/component-fallthrough-attrs.md +174 -0
- package/skills/vue-best-practices/references/component-keep-alive.md +137 -0
- package/skills/vue-best-practices/references/component-slots.md +216 -0
- package/skills/vue-best-practices/references/component-suspense.md +228 -0
- package/skills/vue-best-practices/references/component-teleport.md +108 -0
- package/skills/vue-best-practices/references/component-transition-group.md +128 -0
- package/skills/vue-best-practices/references/component-transition.md +125 -0
- package/skills/vue-best-practices/references/composables.md +290 -0
- package/skills/vue-best-practices/references/directives.md +162 -0
- package/skills/vue-best-practices/references/perf-avoid-component-abstraction-in-lists.md +159 -0
- package/skills/vue-best-practices/references/perf-v-once-v-memo-directives.md +182 -0
- package/skills/vue-best-practices/references/perf-virtualize-large-lists.md +187 -0
- package/skills/vue-best-practices/references/plugins.md +166 -0
- package/skills/vue-best-practices/references/reactivity.md +344 -0
- package/skills/vue-best-practices/references/render-functions.md +201 -0
- package/skills/vue-best-practices/references/sfc.md +310 -0
- package/skills/vue-best-practices/references/state-management.md +135 -0
- package/skills/vue-best-practices/references/updated-hook-performance.md +187 -0
- package/skills/vue-pinia-best-practices/SKILL.md +21 -0
- package/skills/vue-pinia-best-practices/reference/pinia-no-active-pinia-error.md +248 -0
- package/skills/vue-pinia-best-practices/reference/pinia-setup-store-return-all-state.md +227 -0
- package/skills/vue-pinia-best-practices/reference/pinia-store-destructuring-breaks-reactivity.md +193 -0
- package/skills/vue-pinia-best-practices/reference/state-url-for-ephemeral-filters.md +238 -0
- package/skills/vue-pinia-best-practices/reference/state-use-pinia-for-large-apps.md +262 -0
- package/skills/vue-pinia-best-practices/reference/store-method-binding-parentheses.md +191 -0
- package/skills/zig-0.16/SKILL.md +840 -0
- package/skills/zig-0.16/scripts/check-zig-version.sh +21 -0
- package/src/cli/analyses.ts +280 -0
- package/src/cli/index.ts +108 -0
- package/src/cli/serve.ts +192 -0
- package/src/cli/smoke.ts +131 -0
- package/src/cli/status.test.ts +204 -0
- package/src/cli/status.ts +263 -0
- package/src/cli/vacuum.test.ts +82 -0
- package/src/cli/vacuum.ts +96 -0
- package/src/config/schema.test.ts +88 -0
- package/src/config/schema.ts +64 -0
- package/src/db/analyses-migration.test.ts +210 -0
- package/src/db/analyses.test.ts +466 -0
- package/src/db/analyses.ts +375 -0
- package/src/db/auto-checkpoint.ts +131 -0
- package/src/db/client.test.ts +129 -0
- package/src/db/client.ts +55 -0
- package/src/db/fts-escape.ts +20 -0
- package/src/db/incidents.test.ts +201 -0
- package/src/db/incidents.ts +93 -0
- package/src/db/index.ts +86 -0
- package/src/db/migrations-v13.test.ts +141 -0
- package/src/db/migrations-v8.test.ts +301 -0
- package/src/db/migrations.ts +147 -0
- package/src/db/plan-archive.test.ts +180 -0
- package/src/db/plan-archive.ts +274 -0
- package/src/db/plan-create.test.ts +276 -0
- package/src/db/plan-create.ts +78 -0
- package/src/db/plan-files.test.ts +289 -0
- package/src/db/plan-update-status.ts +287 -0
- package/src/db/plans.test.ts +490 -0
- package/src/db/plans.ts +534 -0
- package/src/db/resolve-project-dir.test.ts +143 -0
- package/src/db/resolve-project-dir.ts +75 -0
- package/src/db/rollbacks.test.ts +150 -0
- package/src/db/rollbacks.ts +67 -0
- package/src/db/schema.ts +907 -0
- package/src/db/sessions.test.ts +80 -0
- package/src/db/sessions.ts +135 -0
- package/src/db/shutdown.test.ts +147 -0
- package/src/db/shutdown.ts +45 -0
- package/src/db/tasks.test.ts +921 -0
- package/src/db/tasks.ts +747 -0
- package/src/db/types.ts +619 -0
- package/src/http/__tests__/auth.test.ts +196 -0
- package/src/http/__tests__/routes.test.ts +465 -0
- package/src/http/__tests__/sse.test.ts +317 -0
- package/src/http/auth.ts +72 -0
- package/src/http/middleware/cors.ts +53 -0
- package/src/http/middleware/security-headers.ts +21 -0
- package/src/http/routes/events.ts +112 -0
- package/src/http/routes/health.ts +51 -0
- package/src/http/routes/plans.ts +66 -0
- package/src/http/routes/sessions.ts +50 -0
- package/src/http/routes/tasks.ts +60 -0
- package/src/http/server.ts +95 -0
- package/src/http/sse.ts +116 -0
- package/src/index.ts +37 -0
- package/src/lib.ts +65 -0
- package/src/mem/scoped.ts +65 -0
- package/src/orchestrator/background.test.ts +268 -0
- package/src/orchestrator/background.ts +293 -0
- package/src/orchestrator/memory-hook.ts +182 -0
- package/src/orchestrator/reconciler.ts +123 -0
- package/src/orchestrator/scheduler.test.ts +300 -0
- package/src/orchestrator/scheduler.ts +243 -0
- package/src/plugin.test.ts +2574 -0
- package/src/plugin.ts +1690 -0
- package/src/sdk/client.ts +66 -0
- package/src/worktrees/manager.ts +236 -0
- package/src/worktrees/state.ts +87 -0
- package/tests/integration/ranger-flow.test.ts +257 -0
- package/tools/analysis_archive.ts +28 -0
- package/tools/analysis_create.ts +55 -0
- package/tools/analysis_get.ts +33 -0
- package/tools/analysis_link_plan.ts +44 -0
- package/tools/analysis_list.ts +48 -0
- package/tools/analysis_search.ts +36 -0
- package/tools/analysis_update.ts +44 -0
- package/tools/plan_approve.ts +31 -0
- package/tools/plan_create.ts +58 -0
- package/tools/plan_get.ts +40 -0
- package/tools/plan_list.ts +37 -0
- package/tools/plan_search.ts +34 -0
- package/tools/plan_update_status.ts +71 -0
- package/tools/session_checkpoint.ts +31 -0
- package/tools/session_end.ts +26 -0
- package/tools/session_start.ts +43 -0
- package/tools/task_create_batch.ts +70 -0
- package/tools/task_list.ts +35 -0
- package/tools/task_next_for_agent.ts +30 -0
- package/tools/task_search.ts +34 -0
- package/tools/task_update_status.ts +37 -0
- package/tsconfig.json +31 -0
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ndomo DB — Analysis CRUD with FTS5 search.
|
|
3
|
+
*
|
|
4
|
+
* All functions take a Database instance and return camelCase TS types.
|
|
5
|
+
* Uses TEXT timestamps (ISO datetime) unlike INTEGER epoch in other tables.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { Database } from "bun:sqlite";
|
|
9
|
+
import { randomUUID } from "node:crypto";
|
|
10
|
+
import { escapeFtsQuery } from "./fts-escape.ts";
|
|
11
|
+
import type { Analysis, InsertAnalysis } from "./types.ts";
|
|
12
|
+
import { analysisFromRow } from "./types.ts";
|
|
13
|
+
|
|
14
|
+
// ─── Validation helpers ─────────────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
function validateSlug(slug: string): void {
|
|
17
|
+
if (!slug || slug.trim().length === 0) {
|
|
18
|
+
throw new Error("ndomo: analysis slug cannot be empty");
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function validateProjectPath(projectPath: string): void {
|
|
23
|
+
if (!projectPath || projectPath.trim().length === 0) {
|
|
24
|
+
throw new Error("ndomo: analysis projectPath cannot be empty");
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Agent-aware validation of analysis findings JSON.
|
|
30
|
+
*
|
|
31
|
+
* Boundary policy (ndomo agent contract):
|
|
32
|
+
* - `ranger` emits observation-only findings (factual, descriptive).
|
|
33
|
+
* Rangers MUST NOT include `proposedAction` because they lack decision authority.
|
|
34
|
+
* - `foreman` and other decision-capable agents MAY include `proposedAction`.
|
|
35
|
+
*
|
|
36
|
+
* Behavior:
|
|
37
|
+
* - If findingsJson is undefined → no-op (field not being set).
|
|
38
|
+
* - If findingsJson is not valid JSON → no-op (let the existing JSON.parse
|
|
39
|
+
* in the tool layer surface the parse error). This helper focuses on the
|
|
40
|
+
* semantic boundary check, not syntax.
|
|
41
|
+
* - If findingsJson parses to a non-array (or empty array) → no-op.
|
|
42
|
+
* - If agent === 'ranger' AND any parsed finding has a `proposedAction` key
|
|
43
|
+
* → throw a clear validation error.
|
|
44
|
+
*
|
|
45
|
+
* Pure: no DB access. Safe to call from plugin.ts tool handlers before write.
|
|
46
|
+
*/
|
|
47
|
+
export function validateAnalysisFindings(
|
|
48
|
+
findingsJson: string | undefined,
|
|
49
|
+
agent: string | undefined,
|
|
50
|
+
): void {
|
|
51
|
+
if (findingsJson === undefined) return;
|
|
52
|
+
if (agent !== "ranger") return;
|
|
53
|
+
|
|
54
|
+
let parsed: unknown;
|
|
55
|
+
try {
|
|
56
|
+
parsed = JSON.parse(findingsJson);
|
|
57
|
+
} catch {
|
|
58
|
+
// Defer parse errors to the JSON.parse call in the tool handler.
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
if (!Array.isArray(parsed) || parsed.length === 0) return;
|
|
62
|
+
|
|
63
|
+
for (const item of parsed) {
|
|
64
|
+
if (item !== null && typeof item === "object" && "proposedAction" in item) {
|
|
65
|
+
throw new Error(
|
|
66
|
+
"ndomo: ranger cannot emit proposedAction on analysis findings (observation-only contract); foreman/decision-capable agents only",
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ─── CRUD ───────────────────────────────────────────────────────────────────
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Create a new analysis. Validates slug uniqueness per project_path.
|
|
76
|
+
* Throws if slug already exists for the same project_path.
|
|
77
|
+
*/
|
|
78
|
+
export function createAnalysis(db: Database, input: InsertAnalysis): Analysis {
|
|
79
|
+
validateSlug(input.slug);
|
|
80
|
+
validateProjectPath(input.projectPath);
|
|
81
|
+
|
|
82
|
+
const id = randomUUID();
|
|
83
|
+
const slug = input.slug.trim();
|
|
84
|
+
const title = input.title.trim();
|
|
85
|
+
const projectPath = input.projectPath.trim();
|
|
86
|
+
const summary = input.summary?.trim() ?? "";
|
|
87
|
+
const findingsJson = input.findingsJson ?? "[]";
|
|
88
|
+
const agent = input.agent ?? "ranger";
|
|
89
|
+
|
|
90
|
+
// Check uniqueness (slug, project_path)
|
|
91
|
+
const existing = db
|
|
92
|
+
.query("SELECT id FROM analyses WHERE slug = ? AND project_path = ?")
|
|
93
|
+
.get(slug, projectPath) as { id: string } | null;
|
|
94
|
+
if (existing) {
|
|
95
|
+
throw new Error(
|
|
96
|
+
`ndomo: analysis with slug '${slug}' already exists for project '${projectPath}'`,
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
db.query(
|
|
101
|
+
`INSERT INTO analyses (id, slug, title, project_path, summary, findings_json, source_plan_id, agent, session_id, created_by)
|
|
102
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
103
|
+
).run(
|
|
104
|
+
id,
|
|
105
|
+
slug,
|
|
106
|
+
title,
|
|
107
|
+
projectPath,
|
|
108
|
+
summary,
|
|
109
|
+
findingsJson,
|
|
110
|
+
input.sourcePlanId ?? null,
|
|
111
|
+
agent,
|
|
112
|
+
input.sessionId ?? null,
|
|
113
|
+
input.createdBy ?? null,
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
return getAnalysis(db, id)!;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Get analysis by id. Returns null if not found or archived.
|
|
121
|
+
* Set includeArchived=true to include soft-deleted.
|
|
122
|
+
*/
|
|
123
|
+
export function getAnalysis(
|
|
124
|
+
db: Database,
|
|
125
|
+
id: string,
|
|
126
|
+
opts?: { includeArchived?: boolean },
|
|
127
|
+
): Analysis | null {
|
|
128
|
+
const includeArchived = opts?.includeArchived ?? false;
|
|
129
|
+
const sql = includeArchived
|
|
130
|
+
? "SELECT * FROM analyses WHERE id = ?"
|
|
131
|
+
: "SELECT * FROM analyses WHERE id = ? AND archived_at IS NULL";
|
|
132
|
+
const row = db.query(sql).get(id);
|
|
133
|
+
return row ? analysisFromRow(row) : null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Get analysis by slug + project_path. Returns null if not found.
|
|
138
|
+
*/
|
|
139
|
+
export function getAnalysisBySlug(
|
|
140
|
+
db: Database,
|
|
141
|
+
slug: string,
|
|
142
|
+
projectPath: string,
|
|
143
|
+
opts?: { includeArchived?: boolean },
|
|
144
|
+
): Analysis | null {
|
|
145
|
+
const includeArchived = opts?.includeArchived ?? false;
|
|
146
|
+
const sql = includeArchived
|
|
147
|
+
? "SELECT * FROM analyses WHERE slug = ? AND project_path = ?"
|
|
148
|
+
: "SELECT * FROM analyses WHERE slug = ? AND project_path = ? AND archived_at IS NULL";
|
|
149
|
+
const row = db.query(sql).get(slug, projectPath);
|
|
150
|
+
return row ? analysisFromRow(row) : null;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* List analyses with optional filters.
|
|
155
|
+
*/
|
|
156
|
+
export function listAnalyses(
|
|
157
|
+
db: Database,
|
|
158
|
+
filters?: {
|
|
159
|
+
sourcePlanId?: string;
|
|
160
|
+
agent?: string;
|
|
161
|
+
archived?: boolean;
|
|
162
|
+
projectPath?: string;
|
|
163
|
+
limit?: number;
|
|
164
|
+
},
|
|
165
|
+
): Analysis[] {
|
|
166
|
+
const clauses: string[] = [];
|
|
167
|
+
const params: (string | number)[] = [];
|
|
168
|
+
|
|
169
|
+
if (filters?.sourcePlanId) {
|
|
170
|
+
clauses.push("source_plan_id = ?");
|
|
171
|
+
params.push(filters.sourcePlanId);
|
|
172
|
+
}
|
|
173
|
+
if (filters?.agent) {
|
|
174
|
+
clauses.push("agent = ?");
|
|
175
|
+
params.push(filters.agent);
|
|
176
|
+
}
|
|
177
|
+
if (filters?.projectPath) {
|
|
178
|
+
clauses.push("project_path = ?");
|
|
179
|
+
params.push(filters.projectPath);
|
|
180
|
+
}
|
|
181
|
+
if (!filters?.archived) {
|
|
182
|
+
clauses.push("archived_at IS NULL");
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const where = clauses.length > 0 ? `WHERE ${clauses.join(" AND ")}` : "";
|
|
186
|
+
const limit = filters?.limit ?? 50;
|
|
187
|
+
params.push(limit);
|
|
188
|
+
|
|
189
|
+
const rows = db
|
|
190
|
+
.query(`SELECT * FROM analyses ${where} ORDER BY created_at DESC LIMIT ?`)
|
|
191
|
+
.all(...params);
|
|
192
|
+
return (rows as unknown[]).map(analysisFromRow);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* FTS5 search over title+summary+findings_json.
|
|
197
|
+
* Uses escapeFtsQuery to safely wrap query terms.
|
|
198
|
+
*/
|
|
199
|
+
export function searchAnalyses(
|
|
200
|
+
db: Database,
|
|
201
|
+
query: string,
|
|
202
|
+
filters?: {
|
|
203
|
+
sourcePlanId?: string;
|
|
204
|
+
agent?: string;
|
|
205
|
+
archived?: boolean;
|
|
206
|
+
limit?: number;
|
|
207
|
+
},
|
|
208
|
+
): Analysis[] {
|
|
209
|
+
const clauses: string[] = [];
|
|
210
|
+
const params: (string | number)[] = [];
|
|
211
|
+
|
|
212
|
+
if (filters?.sourcePlanId) {
|
|
213
|
+
clauses.push("a.source_plan_id = ?");
|
|
214
|
+
params.push(filters.sourcePlanId);
|
|
215
|
+
}
|
|
216
|
+
if (filters?.agent) {
|
|
217
|
+
clauses.push("a.agent = ?");
|
|
218
|
+
params.push(filters.agent);
|
|
219
|
+
}
|
|
220
|
+
if (!filters?.archived) {
|
|
221
|
+
clauses.push("a.archived_at IS NULL");
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const extraWhere = clauses.length > 0 ? `AND ${clauses.join(" AND ")}` : "";
|
|
225
|
+
const limit = filters?.limit ?? 20;
|
|
226
|
+
params.push(limit);
|
|
227
|
+
|
|
228
|
+
const rows = db
|
|
229
|
+
.query(
|
|
230
|
+
`SELECT a.* FROM analyses a
|
|
231
|
+
JOIN analyses_fts fts ON a.rowid = fts.rowid
|
|
232
|
+
WHERE analyses_fts MATCH ?
|
|
233
|
+
${extraWhere}
|
|
234
|
+
ORDER BY rank
|
|
235
|
+
LIMIT ?`,
|
|
236
|
+
)
|
|
237
|
+
.all(escapeFtsQuery(query), ...params);
|
|
238
|
+
return (rows as unknown[]).map(analysisFromRow);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Update analysis fields. Returns updated analysis.
|
|
243
|
+
* Automatically bumps updated_at. Throws if analysis not found.
|
|
244
|
+
*/
|
|
245
|
+
export function updateAnalysis(
|
|
246
|
+
db: Database,
|
|
247
|
+
id: string,
|
|
248
|
+
updates: Partial<InsertAnalysis>,
|
|
249
|
+
): Analysis {
|
|
250
|
+
const existing = getAnalysis(db, id);
|
|
251
|
+
if (!existing) {
|
|
252
|
+
throw new Error(`ndomo: analysis '${id}' not found`);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const setClauses: string[] = [];
|
|
256
|
+
const params: (string | null)[] = [];
|
|
257
|
+
|
|
258
|
+
if (updates.slug !== undefined) {
|
|
259
|
+
validateSlug(updates.slug);
|
|
260
|
+
setClauses.push("slug = ?");
|
|
261
|
+
params.push(updates.slug.trim());
|
|
262
|
+
}
|
|
263
|
+
if (updates.title !== undefined) {
|
|
264
|
+
setClauses.push("title = ?");
|
|
265
|
+
params.push(updates.title.trim());
|
|
266
|
+
}
|
|
267
|
+
if (updates.projectPath !== undefined) {
|
|
268
|
+
validateProjectPath(updates.projectPath);
|
|
269
|
+
setClauses.push("project_path = ?");
|
|
270
|
+
params.push(updates.projectPath.trim());
|
|
271
|
+
}
|
|
272
|
+
if (updates.summary !== undefined) {
|
|
273
|
+
setClauses.push("summary = ?");
|
|
274
|
+
params.push(updates.summary);
|
|
275
|
+
}
|
|
276
|
+
if (updates.findingsJson !== undefined) {
|
|
277
|
+
setClauses.push("findings_json = ?");
|
|
278
|
+
params.push(updates.findingsJson);
|
|
279
|
+
}
|
|
280
|
+
if (updates.sourcePlanId !== undefined) {
|
|
281
|
+
setClauses.push("source_plan_id = ?");
|
|
282
|
+
params.push(updates.sourcePlanId ?? null);
|
|
283
|
+
}
|
|
284
|
+
if (updates.agent !== undefined) {
|
|
285
|
+
setClauses.push("agent = ?");
|
|
286
|
+
params.push(updates.agent);
|
|
287
|
+
}
|
|
288
|
+
if (updates.sessionId !== undefined) {
|
|
289
|
+
setClauses.push("session_id = ?");
|
|
290
|
+
params.push(updates.sessionId ?? null);
|
|
291
|
+
}
|
|
292
|
+
if (updates.createdBy !== undefined) {
|
|
293
|
+
setClauses.push("created_by = ?");
|
|
294
|
+
params.push(updates.createdBy ?? null);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
if (setClauses.length === 0) return existing; // no-op
|
|
298
|
+
|
|
299
|
+
// Always bump updated_at
|
|
300
|
+
setClauses.push("updated_at = datetime('now')");
|
|
301
|
+
params.push(id);
|
|
302
|
+
|
|
303
|
+
db.query(
|
|
304
|
+
`UPDATE analyses SET ${setClauses.join(", ")} WHERE id = ?`,
|
|
305
|
+
).run(...params);
|
|
306
|
+
|
|
307
|
+
return getAnalysis(db, id)!;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Soft-delete: set archived_at to current timestamp.
|
|
312
|
+
*/
|
|
313
|
+
export function archiveAnalysis(db: Database, id: string): Analysis {
|
|
314
|
+
const existing = getAnalysis(db, id, { includeArchived: true });
|
|
315
|
+
if (!existing) {
|
|
316
|
+
throw new Error(`ndomo: analysis '${id}' not found`);
|
|
317
|
+
}
|
|
318
|
+
// Idempotent: if already archived, just return
|
|
319
|
+
if (existing.archivedAt) return existing;
|
|
320
|
+
|
|
321
|
+
db.query(
|
|
322
|
+
"UPDATE analyses SET archived_at = datetime('now'), updated_at = datetime('now') WHERE id = ?",
|
|
323
|
+
).run(id);
|
|
324
|
+
|
|
325
|
+
return getAnalysis(db, id, { includeArchived: true })!;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Link analysis to a plan (set source_plan_id).
|
|
330
|
+
* Use when analysis is created standalone and later linked to a plan.
|
|
331
|
+
*/
|
|
332
|
+
export function linkAnalysisToPlan(
|
|
333
|
+
db: Database,
|
|
334
|
+
id: string,
|
|
335
|
+
planId: string,
|
|
336
|
+
): Analysis {
|
|
337
|
+
const existing = getAnalysis(db, id);
|
|
338
|
+
if (!existing) {
|
|
339
|
+
throw new Error(`ndomo: analysis '${id}' not found`);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// FK enforces plan existence, but validate at app level for better error message
|
|
343
|
+
const plan = db.query("SELECT id FROM plans WHERE id = ?").get(planId) as
|
|
344
|
+
| { id: string }
|
|
345
|
+
| null;
|
|
346
|
+
if (!plan) {
|
|
347
|
+
throw new Error(`ndomo: plan '${planId}' not found (FK violation)`);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
db.query(
|
|
351
|
+
"UPDATE analyses SET source_plan_id = ?, updated_at = datetime('now') WHERE id = ?",
|
|
352
|
+
).run(planId, id);
|
|
353
|
+
|
|
354
|
+
return getAnalysis(db, id)!;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Unlink analysis from its source plan (set source_plan_id to NULL).
|
|
359
|
+
* Idempotent: if already unlinked, just returns the analysis.
|
|
360
|
+
*/
|
|
361
|
+
export function unlinkAnalysisFromPlan(
|
|
362
|
+
db: Database,
|
|
363
|
+
id: string,
|
|
364
|
+
): Analysis {
|
|
365
|
+
const existing = getAnalysis(db, id);
|
|
366
|
+
if (!existing) {
|
|
367
|
+
throw new Error(`ndomo: analysis '${id}' not found`);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
db.query(
|
|
371
|
+
"UPDATE analyses SET source_plan_id = NULL, updated_at = datetime('now') WHERE id = ?",
|
|
372
|
+
).run(id);
|
|
373
|
+
|
|
374
|
+
return getAnalysis(db, id)!;
|
|
375
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ndomo DB — Auto-checkpoint dispatcher.
|
|
3
|
+
*
|
|
4
|
+
* Captures orchestrator state into session checkpoints on configurable
|
|
5
|
+
* triggers (phase_transition, task_batch_complete). Debounced, async,
|
|
6
|
+
* loop-safe.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { Database } from "bun:sqlite"
|
|
10
|
+
import { getPlan } from "./plans.ts"
|
|
11
|
+
import { checkpointSession, ensureSession } from "./sessions.ts"
|
|
12
|
+
import { listTasksByPlan } from "./tasks.ts"
|
|
13
|
+
|
|
14
|
+
// ─── Types ──────────────────────────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
export interface AutoCheckpointConfig {
|
|
17
|
+
enabled?: boolean
|
|
18
|
+
triggers?: string[]
|
|
19
|
+
minIntervalMs?: number
|
|
20
|
+
captureState?: {
|
|
21
|
+
completedTasks?: boolean
|
|
22
|
+
currentPhase?: boolean
|
|
23
|
+
blockers?: boolean
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface AutoCheckpointContext {
|
|
28
|
+
planId?: string
|
|
29
|
+
sessionId?: string
|
|
30
|
+
blockers?: string[] | undefined
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// ─── Defaults ───────────────────────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
const DEFAULT_ENABLED = true
|
|
36
|
+
const DEFAULT_TRIGGERS = ["phase_transition", "task_batch_complete"]
|
|
37
|
+
const DEFAULT_MIN_INTERVAL_MS = 30_000
|
|
38
|
+
const DEFAULT_CAPTURE_COMPLETED = true
|
|
39
|
+
const DEFAULT_CAPTURE_PHASE = true
|
|
40
|
+
const DEFAULT_CAPTURE_BLOCKERS = true
|
|
41
|
+
|
|
42
|
+
// ─── Dispatcher ─────────────────────────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Async, debounced checkpoint dispatcher.
|
|
46
|
+
*
|
|
47
|
+
* Instantiate with a Database + optional config. Call `dispatch()` from
|
|
48
|
+
* tool executors after a successful state mutation. The actual
|
|
49
|
+
* `checkpointSession` call runs in a microtask (non-blocking) with
|
|
50
|
+
* error swallowing so it never breaks the caller.
|
|
51
|
+
*
|
|
52
|
+
* Loop prevention: an `isAutoCheckpointing` flag prevents re-entrant
|
|
53
|
+
* dispatch. Since `checkpointSession` only writes to the sessions
|
|
54
|
+
* table (not plans/tasks), loops are structurally impossible — but the
|
|
55
|
+
* flag is a safety net.
|
|
56
|
+
*/
|
|
57
|
+
export class AutoCheckpointDispatcher {
|
|
58
|
+
private lastCheckpointAt = 0
|
|
59
|
+
private isAutoCheckpointing = false
|
|
60
|
+
|
|
61
|
+
private readonly enabled: boolean
|
|
62
|
+
private readonly triggers: Set<string>
|
|
63
|
+
private readonly minIntervalMs: number
|
|
64
|
+
private readonly captureCompleted: boolean
|
|
65
|
+
private readonly capturePhase: boolean
|
|
66
|
+
private readonly captureBlockers: boolean
|
|
67
|
+
private readonly db: Database
|
|
68
|
+
|
|
69
|
+
constructor(db: Database, config?: AutoCheckpointConfig) {
|
|
70
|
+
this.db = db
|
|
71
|
+
this.enabled = config?.enabled ?? DEFAULT_ENABLED
|
|
72
|
+
this.triggers = new Set(config?.triggers ?? DEFAULT_TRIGGERS)
|
|
73
|
+
this.minIntervalMs = config?.minIntervalMs ?? DEFAULT_MIN_INTERVAL_MS
|
|
74
|
+
this.captureCompleted = config?.captureState?.completedTasks ?? DEFAULT_CAPTURE_COMPLETED
|
|
75
|
+
this.capturePhase = config?.captureState?.currentPhase ?? DEFAULT_CAPTURE_PHASE
|
|
76
|
+
this.captureBlockers = config?.captureState?.blockers ?? DEFAULT_CAPTURE_BLOCKERS
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Fire an auto-checkpoint if conditions are met.
|
|
81
|
+
*
|
|
82
|
+
* Checks (in order): loop guard → enabled → trigger allowed → debounce → sessionId present.
|
|
83
|
+
* If all pass, schedules async checkpoint via microtask.
|
|
84
|
+
*/
|
|
85
|
+
dispatch(trigger: string, ctx: AutoCheckpointContext): void {
|
|
86
|
+
if (this.isAutoCheckpointing) return
|
|
87
|
+
if (!this.enabled) return
|
|
88
|
+
if (!this.triggers.has(trigger)) return
|
|
89
|
+
|
|
90
|
+
const now = Date.now()
|
|
91
|
+
if (now - this.lastCheckpointAt < this.minIntervalMs) return
|
|
92
|
+
if (!ctx.sessionId) return
|
|
93
|
+
|
|
94
|
+
this.lastCheckpointAt = now
|
|
95
|
+
this.isAutoCheckpointing = true
|
|
96
|
+
|
|
97
|
+
// Async dispatch — does NOT block the caller
|
|
98
|
+
Promise.resolve().then(() => {
|
|
99
|
+
try {
|
|
100
|
+
ensureSession(this.db, ctx.sessionId!, "auto-checkpoint")
|
|
101
|
+
|
|
102
|
+
const state: Record<string, unknown> = { trigger }
|
|
103
|
+
|
|
104
|
+
if (ctx.planId) {
|
|
105
|
+
if (this.captureCompleted) {
|
|
106
|
+
const doneTasks = listTasksByPlan(this.db, ctx.planId, { status: "done" })
|
|
107
|
+
state.completedTasks = doneTasks.length
|
|
108
|
+
}
|
|
109
|
+
if (this.capturePhase) {
|
|
110
|
+
const plan = getPlan(this.db, ctx.planId)
|
|
111
|
+
state.currentPhase = plan?.status ?? "unknown"
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (this.captureBlockers && ctx.blockers && ctx.blockers.length > 0) {
|
|
116
|
+
state.blockers = ctx.blockers
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
checkpointSession(this.db, ctx.sessionId!, state)
|
|
120
|
+
} catch (err) {
|
|
121
|
+
// Auto-checkpoint must never break the caller
|
|
122
|
+
console.error(
|
|
123
|
+
"[ndomo] auto_checkpoint failed:",
|
|
124
|
+
err instanceof Error ? err.message : String(err),
|
|
125
|
+
)
|
|
126
|
+
} finally {
|
|
127
|
+
this.isAutoCheckpointing = false
|
|
128
|
+
}
|
|
129
|
+
})
|
|
130
|
+
}
|
|
131
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for openDb() input validation — plan 4dc34202.
|
|
3
|
+
*
|
|
4
|
+
* Validates that openDb rejects invalid projectDir values (empty, "/",
|
|
5
|
+
* relative) with a clear Error BEFORE attempting mkdirSync, and that the
|
|
6
|
+
* happy path (valid absolute path from mkdtempSync) still creates
|
|
7
|
+
* `.ndomo/state.db` with foreign keys enabled.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
11
|
+
import { existsSync, mkdtempSync, rmSync } from "node:fs";
|
|
12
|
+
import { tmpdir } from "node:os";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
import { closeDb, openDb } from "./client.ts";
|
|
15
|
+
|
|
16
|
+
describe("openDb — input validation", () => {
|
|
17
|
+
test('openDb("") throws clear Error', () => {
|
|
18
|
+
expect(() => openDb("")).toThrow(/invalid projectDir/);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test('openDb("/") throws clear Error', () => {
|
|
22
|
+
expect(() => openDb("/")).toThrow(/invalid projectDir/);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test('openDb("./relative") throws clear Error', () => {
|
|
26
|
+
expect(() => openDb("./relative")).toThrow(/invalid projectDir/);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("openDb rejects other relative paths", () => {
|
|
30
|
+
expect(() => openDb("relative/no-slash")).toThrow(/invalid projectDir/);
|
|
31
|
+
expect(() => openDb("../parent")).toThrow(/invalid projectDir/);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("error message includes the offending value for debuggability", () => {
|
|
35
|
+
try {
|
|
36
|
+
openDb("/");
|
|
37
|
+
expect.unreachable("should have thrown");
|
|
38
|
+
} catch (err) {
|
|
39
|
+
expect(err).toBeInstanceOf(Error);
|
|
40
|
+
expect((err as Error).message).toContain('"/"');
|
|
41
|
+
expect((err as Error).message).toContain("absolute");
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe("openDb — happy path", () => {
|
|
47
|
+
let tmpDir: string;
|
|
48
|
+
|
|
49
|
+
beforeEach(() => {
|
|
50
|
+
tmpDir = mkdtempSync(join(tmpdir(), "ndomo-client-test-"));
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
afterEach(() => {
|
|
54
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("openDb(valid absolute path) creates .ndomo/state.db", () => {
|
|
58
|
+
const db = openDb(tmpDir);
|
|
59
|
+
const dbPath = join(tmpDir, ".ndomo", "state.db");
|
|
60
|
+
expect(existsSync(dbPath)).toBe(true);
|
|
61
|
+
closeDb(db);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("openDb enables PRAGMA foreign_keys = ON", () => {
|
|
65
|
+
const db = openDb(tmpDir);
|
|
66
|
+
const fk = db.query("PRAGMA foreign_keys").get() as Record<string, unknown> | null;
|
|
67
|
+
expect(fk).not.toBeNull();
|
|
68
|
+
expect(fk?.foreign_keys).toBe(1);
|
|
69
|
+
closeDb(db);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("openDb is idempotent — second call reuses existing .ndomo dir", () => {
|
|
73
|
+
const db1 = openDb(tmpDir);
|
|
74
|
+
closeDb(db1);
|
|
75
|
+
// Second call on same dir should not throw (mkdirSync recursive is idempotent)
|
|
76
|
+
const db2 = openDb(tmpDir);
|
|
77
|
+
const dbPath = join(tmpDir, ".ndomo", "state.db");
|
|
78
|
+
expect(existsSync(dbPath)).toBe(true);
|
|
79
|
+
closeDb(db2);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe("openDb — PRAGMA config (plan fcb12dc5 #4)", () => {
|
|
84
|
+
let tmpDir: string;
|
|
85
|
+
|
|
86
|
+
beforeEach(() => {
|
|
87
|
+
tmpDir = mkdtempSync(join(tmpdir(), "ndomo-pragma-test-"));
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
afterEach(() => {
|
|
91
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("PRAGMA journal_mode = WAL (sticky, applies to file-based DBs)", () => {
|
|
95
|
+
const db = openDb(tmpDir);
|
|
96
|
+
const result = db.query("PRAGMA journal_mode").get() as Record<string, unknown> | null;
|
|
97
|
+
expect(result).not.toBeNull();
|
|
98
|
+
expect(result?.journal_mode).toBe("wal");
|
|
99
|
+
closeDb(db);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("PRAGMA synchronous = NORMAL (safe with WAL, faster than FULL)", () => {
|
|
103
|
+
const db = openDb(tmpDir);
|
|
104
|
+
const result = db.query("PRAGMA synchronous").get() as Record<string, unknown> | null;
|
|
105
|
+
expect(result).not.toBeNull();
|
|
106
|
+
// SQLite reports 0=OFF, 1=NORMAL, 2=FULL
|
|
107
|
+
expect(Number(result?.synchronous)).toBe(1);
|
|
108
|
+
closeDb(db);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("PRAGMA auto_vacuum = INCREMENTAL (prevents unbounded growth)", () => {
|
|
112
|
+
const db = openDb(tmpDir);
|
|
113
|
+
const result = db.query("PRAGMA auto_vacuum").get() as Record<string, unknown> | null;
|
|
114
|
+
expect(result).not.toBeNull();
|
|
115
|
+
// SQLite reports 0=NONE, 1=FULL, 2=INCREMENTAL
|
|
116
|
+
expect(Number(result?.auto_vacuum)).toBe(2);
|
|
117
|
+
closeDb(db);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("PRAGMA settings persist across reopen (sticky migration)", () => {
|
|
121
|
+
const db1 = openDb(tmpDir);
|
|
122
|
+
closeDb(db1);
|
|
123
|
+
// Reopen — journal_mode is sticky in DB file
|
|
124
|
+
const db2 = openDb(tmpDir);
|
|
125
|
+
const journal = db2.query("PRAGMA journal_mode").get() as Record<string, unknown> | null;
|
|
126
|
+
expect(journal?.journal_mode).toBe("wal");
|
|
127
|
+
closeDb(db2);
|
|
128
|
+
});
|
|
129
|
+
});
|
package/src/db/client.ts
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ndomo DB — SQLite client factory.
|
|
3
|
+
*
|
|
4
|
+
* Creates a project-level database in `.ndomo/state.db`.
|
|
5
|
+
* Uses bun:sqlite (built-in, zero deps).
|
|
6
|
+
*
|
|
7
|
+
* NOTE on "async" terminology: bun:sqlite ops are SYNCHRONOUS (no async I/O).
|
|
8
|
+
* In ndomo architecture, "async" refers to inter-agent coordination via DB
|
|
9
|
+
* state + manual TUI switch between primaries (foreman↔craftsman), NOT to
|
|
10
|
+
* async database I/O. All db.exec / db.prepare / stmt.run calls are sync.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { Database } from "bun:sqlite";
|
|
14
|
+
import { mkdirSync } from "node:fs";
|
|
15
|
+
import { isAbsolute, join } from "node:path";
|
|
16
|
+
|
|
17
|
+
const NDOMO_DIR = ".ndomo";
|
|
18
|
+
const DB_FILE = "state.db";
|
|
19
|
+
|
|
20
|
+
export function openDb(projectDir: string): Database {
|
|
21
|
+
// Defensive validation: reject paths that would place .ndomo at the
|
|
22
|
+
// filesystem root (e.g. projectDir="/" → "/.ndomo" → EACCES) or relative
|
|
23
|
+
// paths that resolve against CWD unpredictably. See plan
|
|
24
|
+
// 4dc34202 (harden-open-db-path-validation).
|
|
25
|
+
if (projectDir === "" || projectDir === "/" || !isAbsolute(projectDir)) {
|
|
26
|
+
throw new Error(
|
|
27
|
+
`openDb: invalid projectDir ${JSON.stringify(projectDir)} — must be a non-root absolute path (e.g. /home/user/project). Received "/" or a relative path would create ".ndomo" at the filesystem root or an unpredictable CWD-relative location.`,
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
const dir = join(projectDir, NDOMO_DIR);
|
|
31
|
+
mkdirSync(dir, { recursive: true });
|
|
32
|
+
const path = join(dir, DB_FILE);
|
|
33
|
+
const db = new Database(path, { create: true });
|
|
34
|
+
// Enable foreign key enforcement (OFF by default in SQLite/bun:sqlite)
|
|
35
|
+
db.exec("PRAGMA foreign_keys = ON");
|
|
36
|
+
// INCREMENTAL auto_vacuum — reclaims space from deleted rows on demand via
|
|
37
|
+
// PRAGMA incremental_vacuum (run by `ndomo vacuum` CLI). Prevents unbounded
|
|
38
|
+
// growth of .ndomo/state.db on long-running installs (audit fcb12dc5 #4).
|
|
39
|
+
// NOTE: must be set BEFORE journal_mode = WAL — SQLite/bun:sqlite silently
|
|
40
|
+
// ignores auto_vacuum when set after WAL is enabled on a fresh DB. Empirically
|
|
41
|
+
// confirmed via /tmp/test-combo.ts (2026-06-23, plan fcb12dc5).
|
|
42
|
+
db.exec("PRAGMA auto_vacuum = INCREMENTAL");
|
|
43
|
+
// WAL journal mode — better concurrency, persistent across opens (sticky
|
|
44
|
+
// in DB file). For long-running installs this prevents the .ndomo/state.db
|
|
45
|
+
// from blocking readers while a writer is active.
|
|
46
|
+
db.exec("PRAGMA journal_mode = WAL");
|
|
47
|
+
// NORMAL synchronous — safe with WAL (durability on checkpoint, not on every
|
|
48
|
+
// commit). Faster than FULL on write-heavy workloads.
|
|
49
|
+
db.exec("PRAGMA synchronous = NORMAL");
|
|
50
|
+
return db;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function closeDb(db: Database): void {
|
|
54
|
+
db.close();
|
|
55
|
+
}
|