jettypod 4.4.118 → 4.4.121

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 (240) hide show
  1. package/.env +4 -3
  2. package/Cargo.lock +6450 -0
  3. package/Cargo.toml +35 -0
  4. package/README.md +5 -1
  5. package/TAURI-MIGRATION-PLAN.md +840 -0
  6. package/apps/dashboard/app/connect-claude/page.tsx +5 -6
  7. package/apps/dashboard/app/decision/[id]/page.tsx +63 -58
  8. package/apps/dashboard/app/demo/gates/page.tsx +43 -45
  9. package/apps/dashboard/app/design-system/page.tsx +868 -0
  10. package/apps/dashboard/app/globals.css +80 -4
  11. package/apps/dashboard/app/install-claude/page.tsx +4 -6
  12. package/apps/dashboard/app/login/page.tsx +72 -54
  13. package/apps/dashboard/app/page.tsx +101 -48
  14. package/apps/dashboard/app/settings/page.tsx +61 -13
  15. package/apps/dashboard/app/signup/page.tsx +242 -0
  16. package/apps/dashboard/app/subscribe/page.tsx +0 -2
  17. package/apps/dashboard/app/tests/page.tsx +37 -4
  18. package/apps/dashboard/app/welcome/page.tsx +13 -16
  19. package/apps/dashboard/app/work/[id]/page.tsx +117 -118
  20. package/apps/dashboard/app/work/[id]/proof/page.tsx +1489 -0
  21. package/apps/dashboard/components/AppShell.tsx +92 -85
  22. package/apps/dashboard/components/CardMenu.tsx +45 -12
  23. package/apps/dashboard/components/ClaudePanel.tsx +771 -850
  24. package/apps/dashboard/components/ClaudePanelInput.tsx +43 -15
  25. package/apps/dashboard/components/ConnectClaudeScreen.tsx +17 -34
  26. package/apps/dashboard/components/CopyableId.tsx +3 -4
  27. package/apps/dashboard/components/DetailReviewActions.tsx +100 -0
  28. package/apps/dashboard/components/DragContext.tsx +134 -63
  29. package/apps/dashboard/components/DraggableCard.tsx +3 -5
  30. package/apps/dashboard/components/DropZone.tsx +6 -7
  31. package/apps/dashboard/components/EditableDetailDescription.tsx +7 -13
  32. package/apps/dashboard/components/EditableDetailTitle.tsx +6 -13
  33. package/apps/dashboard/components/EditableTitle.tsx +26 -7
  34. package/apps/dashboard/components/ElapsedTimer.tsx +66 -0
  35. package/apps/dashboard/components/EpicGroup.tsx +359 -0
  36. package/apps/dashboard/components/GateCard.tsx +79 -17
  37. package/apps/dashboard/components/GateChoiceCard.tsx +15 -18
  38. package/apps/dashboard/components/InstallClaudeScreen.tsx +15 -32
  39. package/apps/dashboard/components/JettyLoader.tsx +37 -0
  40. package/apps/dashboard/components/KanbanBoard.tsx +368 -958
  41. package/apps/dashboard/components/KanbanCard.tsx +740 -0
  42. package/apps/dashboard/components/LazyCard.tsx +62 -0
  43. package/apps/dashboard/components/LazyMarkdown.tsx +11 -0
  44. package/apps/dashboard/components/MainNav.tsx +38 -73
  45. package/apps/dashboard/components/MessageBlock.tsx +468 -0
  46. package/apps/dashboard/components/ModeStartCard.tsx +15 -16
  47. package/apps/dashboard/components/OnboardingWelcome.tsx +213 -0
  48. package/apps/dashboard/components/PlaceholderCard.tsx +3 -4
  49. package/apps/dashboard/components/ProjectSwitcher.tsx +30 -30
  50. package/apps/dashboard/components/PrototypeTimeline.tsx +72 -51
  51. package/apps/dashboard/components/RealTimeKanbanWrapper.tsx +406 -388
  52. package/apps/dashboard/components/RealTimeTestsWrapper.tsx +373 -235
  53. package/apps/dashboard/components/ReviewFooter.tsx +139 -0
  54. package/apps/dashboard/components/SessionList.tsx +19 -19
  55. package/apps/dashboard/components/SubscribeContent.tsx +91 -47
  56. package/apps/dashboard/components/TestTree.tsx +16 -16
  57. package/apps/dashboard/components/TipCard.tsx +16 -17
  58. package/apps/dashboard/components/Toast.tsx +5 -6
  59. package/apps/dashboard/components/TypeIcon.tsx +55 -0
  60. package/apps/dashboard/components/ViewModeToolbar.tsx +104 -0
  61. package/apps/dashboard/components/WaveCompletionAnimation.tsx +52 -65
  62. package/apps/dashboard/components/WelcomeScreen.tsx +19 -35
  63. package/apps/dashboard/components/WorkItemHeader.tsx +4 -5
  64. package/apps/dashboard/components/WorkItemTree.tsx +11 -32
  65. package/apps/dashboard/components/settings/AccountSection.tsx +55 -35
  66. package/apps/dashboard/components/settings/AiContextSection.tsx +89 -0
  67. package/apps/dashboard/components/settings/ContextDocumentsSection.tsx +317 -0
  68. package/apps/dashboard/components/settings/EnvVarsSection.tsx +74 -152
  69. package/apps/dashboard/components/settings/GeneralSection.tsx +162 -56
  70. package/apps/dashboard/components/settings/ProjectStackSection.tsx +948 -0
  71. package/apps/dashboard/components/settings/SettingsLayout.tsx +4 -5
  72. package/apps/dashboard/components/ui/Button.tsx +104 -0
  73. package/apps/dashboard/components/ui/Input.tsx +78 -0
  74. package/apps/dashboard/components.json +1 -1
  75. package/apps/dashboard/contexts/ClaudeSessionContext.tsx +711 -418
  76. package/apps/dashboard/contexts/ConnectionStatusContext.tsx +25 -5
  77. package/apps/dashboard/contexts/UsageContext.tsx +87 -32
  78. package/apps/dashboard/dev.sh +35 -0
  79. package/apps/dashboard/eslint.config.mjs +9 -9
  80. package/apps/dashboard/hooks/useKanbanAnimation.ts +29 -0
  81. package/apps/dashboard/hooks/useKanbanUndo.ts +83 -0
  82. package/apps/dashboard/hooks/useWebSocket.ts +138 -83
  83. package/apps/dashboard/index.html +73 -0
  84. package/apps/dashboard/lib/constants.ts +43 -0
  85. package/apps/dashboard/lib/data-bridge.ts +722 -0
  86. package/apps/dashboard/lib/db.ts +69 -1265
  87. package/apps/dashboard/lib/environment-config.ts +173 -0
  88. package/apps/dashboard/lib/environment-verification.ts +119 -0
  89. package/apps/dashboard/lib/kanban-utils.ts +270 -0
  90. package/apps/dashboard/lib/proof-run.ts +495 -0
  91. package/apps/dashboard/lib/proof-scenario-runner.ts +346 -0
  92. package/apps/dashboard/lib/run-migrations.js +27 -2
  93. package/apps/dashboard/lib/service-recovery.ts +326 -0
  94. package/apps/dashboard/lib/session-state-machine.ts +1 -0
  95. package/apps/dashboard/lib/session-state-utils.ts +0 -164
  96. package/apps/dashboard/lib/session-stream-manager.ts +308 -134
  97. package/apps/dashboard/lib/shadows.ts +7 -0
  98. package/apps/dashboard/lib/stream-manager-registry.ts +46 -6
  99. package/apps/dashboard/lib/tauri-bridge.ts +102 -0
  100. package/apps/dashboard/lib/tauri.ts +106 -0
  101. package/apps/dashboard/lib/utils.ts +6 -0
  102. package/apps/dashboard/next-env.d.ts +1 -1
  103. package/apps/dashboard/package.json +21 -32
  104. package/apps/dashboard/public/bug-icon.png +0 -0
  105. package/apps/dashboard/public/buoy-icon.png +0 -0
  106. package/apps/dashboard/public/fonts/Satoshi-Variable.woff2 +0 -0
  107. package/apps/dashboard/public/fonts/Satoshi-VariableItalic.woff2 +0 -0
  108. package/apps/dashboard/public/in-flight-seagull.png +0 -0
  109. package/apps/dashboard/public/jetty-icon-loading-alt.svg +11 -0
  110. package/apps/dashboard/public/jetty-icon-loading.svg +11 -0
  111. package/apps/dashboard/public/jettypod_logo.png +0 -0
  112. package/apps/dashboard/public/pier-icon.png +0 -0
  113. package/apps/dashboard/public/star-icon.png +0 -0
  114. package/apps/dashboard/public/wrench-icon.png +0 -0
  115. package/apps/dashboard/scripts/tauri-build.js +228 -0
  116. package/apps/dashboard/scripts/upload-tauri-to-r2.js +125 -0
  117. package/apps/dashboard/scripts/ws-server.js +191 -0
  118. package/apps/dashboard/src/main.tsx +12 -0
  119. package/apps/dashboard/src/router.tsx +107 -0
  120. package/apps/dashboard/src/vite-env.d.ts +1 -0
  121. package/apps/dashboard/tsconfig.json +7 -12
  122. package/apps/dashboard/tsconfig.tsbuildinfo +1 -1
  123. package/apps/dashboard/vite.config.ts +33 -0
  124. package/apps/update-server/src/index.ts +228 -80
  125. package/claude-hooks/global-guardrails.js +14 -13
  126. package/crates/jettypod-cli/Cargo.toml +19 -0
  127. package/crates/jettypod-cli/src/commands.rs +1249 -0
  128. package/crates/jettypod-cli/src/main.rs +595 -0
  129. package/crates/jettypod-core/Cargo.toml +26 -0
  130. package/crates/jettypod-core/build.rs +98 -0
  131. package/crates/jettypod-core/migrations/V1__baseline.sql +197 -0
  132. package/crates/jettypod-core/migrations/V2__work_items_indexes.sql +6 -0
  133. package/crates/jettypod-core/migrations/V3__qa_steps.sql +2 -0
  134. package/crates/jettypod-core/src/auth.rs +294 -0
  135. package/crates/jettypod-core/src/config.rs +397 -0
  136. package/crates/jettypod-core/src/db/mod.rs +507 -0
  137. package/crates/jettypod-core/src/db/recovery.rs +114 -0
  138. package/crates/jettypod-core/src/db/startup.rs +101 -0
  139. package/crates/jettypod-core/src/db/validate.rs +149 -0
  140. package/crates/jettypod-core/src/error.rs +76 -0
  141. package/crates/jettypod-core/src/git.rs +458 -0
  142. package/crates/jettypod-core/src/lib.rs +20 -0
  143. package/crates/jettypod-core/src/sessions.rs +625 -0
  144. package/crates/jettypod-core/src/skills.rs +556 -0
  145. package/crates/jettypod-core/src/work.rs +1086 -0
  146. package/crates/jettypod-core/src/worktree.rs +628 -0
  147. package/crates/jettypod-core/src/ws.rs +767 -0
  148. package/cucumber-test.cjs +6 -0
  149. package/cucumber.js +9 -3
  150. package/docs/COMMAND_REFERENCE.md +34 -0
  151. package/hooks/post-checkout +32 -75
  152. package/hooks/post-merge +111 -10
  153. package/jest.setup.js +1 -0
  154. package/jettypod.js +145 -116
  155. package/lib/bdd-preflight.js +96 -0
  156. package/lib/chore-taxonomy.js +33 -10
  157. package/lib/database.js +36 -16
  158. package/lib/db-watcher.js +1 -1
  159. package/lib/git-hooks/pre-commit +1 -1
  160. package/lib/jettypod-backup.js +27 -4
  161. package/lib/merge-lock.js +111 -253
  162. package/lib/migrations/027-plan-at-creation-column.js +3 -1
  163. package/lib/migrations/029-remove-autoincrement.js +307 -0
  164. package/lib/migrations/029-rename-corrupted-to-cleaned.js +149 -0
  165. package/lib/migrations/030-rejection-round-columns.js +54 -0
  166. package/lib/migrations/031-session-isolation-index.js +17 -0
  167. package/lib/migrations/index.js +47 -4
  168. package/lib/schema.js +10 -5
  169. package/lib/seed-onboarding.js +1 -1
  170. package/lib/update-command/index.js +9 -175
  171. package/lib/work-commands/index.js +144 -19
  172. package/lib/work-tracking/index.js +148 -27
  173. package/lib/worktree-diagnostics.js +16 -16
  174. package/lib/worktree-facade.js +1 -1
  175. package/lib/worktree-manager.js +8 -8
  176. package/lib/worktree-reconciler.js +5 -5
  177. package/package.json +9 -2
  178. package/scripts/ndjson-to-cucumber-json.js +152 -0
  179. package/scripts/postinstall.js +25 -0
  180. package/skills-templates/bug-mode/SKILL.md +79 -20
  181. package/skills-templates/bug-planning/SKILL.md +25 -29
  182. package/skills-templates/chore-mode/SKILL.md +171 -69
  183. package/skills-templates/chore-mode/verification.js +51 -10
  184. package/skills-templates/chore-planning/SKILL.md +47 -18
  185. package/skills-templates/design-system-selection/SKILL.md +273 -0
  186. package/skills-templates/epic-planning/SKILL.md +82 -48
  187. package/skills-templates/external-transition/SKILL.md +47 -47
  188. package/skills-templates/feature-planning/SKILL.md +173 -74
  189. package/skills-templates/production-mode/SKILL.md +69 -49
  190. package/skills-templates/request-routing/SKILL.md +4 -4
  191. package/skills-templates/simple-improvement/SKILL.md +74 -29
  192. package/skills-templates/speed-mode/SKILL.md +217 -141
  193. package/skills-templates/stable-mode/SKILL.md +148 -89
  194. package/apps/dashboard/README.md +0 -36
  195. package/apps/dashboard/app/api/claude/[workItemId]/message/route.ts +0 -386
  196. package/apps/dashboard/app/api/claude/[workItemId]/pin/route.ts +0 -24
  197. package/apps/dashboard/app/api/claude/[workItemId]/route.ts +0 -167
  198. package/apps/dashboard/app/api/claude/sessions/[sessionId]/content/route.ts +0 -52
  199. package/apps/dashboard/app/api/claude/sessions/[sessionId]/message/route.ts +0 -378
  200. package/apps/dashboard/app/api/claude/sessions/[sessionId]/pin/route.ts +0 -24
  201. package/apps/dashboard/app/api/claude/sessions/cleanup/route.ts +0 -34
  202. package/apps/dashboard/app/api/claude/sessions/route.ts +0 -184
  203. package/apps/dashboard/app/api/decisions/[id]/route.ts +0 -25
  204. package/apps/dashboard/app/api/internal/set-project/route.ts +0 -17
  205. package/apps/dashboard/app/api/kanban/route.ts +0 -15
  206. package/apps/dashboard/app/api/settings/env-vars/route.ts +0 -125
  207. package/apps/dashboard/app/api/settings/general/route.ts +0 -21
  208. package/apps/dashboard/app/api/tests/route.ts +0 -9
  209. package/apps/dashboard/app/api/tests/run/route.ts +0 -82
  210. package/apps/dashboard/app/api/tests/run/stream/route.ts +0 -71
  211. package/apps/dashboard/app/api/tests/undefined/route.ts +0 -9
  212. package/apps/dashboard/app/api/usage/route.ts +0 -17
  213. package/apps/dashboard/app/api/work/[id]/description/route.ts +0 -21
  214. package/apps/dashboard/app/api/work/[id]/epic/route.ts +0 -21
  215. package/apps/dashboard/app/api/work/[id]/order/route.ts +0 -21
  216. package/apps/dashboard/app/api/work/[id]/status/route.ts +0 -21
  217. package/apps/dashboard/app/api/work/[id]/title/route.ts +0 -21
  218. package/apps/dashboard/app/layout.tsx +0 -43
  219. package/apps/dashboard/components/UpgradeBanner.tsx +0 -29
  220. package/apps/dashboard/electron/ipc-handlers.js +0 -1028
  221. package/apps/dashboard/electron/main.js +0 -2124
  222. package/apps/dashboard/electron/preload.js +0 -123
  223. package/apps/dashboard/electron/session-manager.js +0 -141
  224. package/apps/dashboard/electron-builder.config.js +0 -357
  225. package/apps/dashboard/hooks/useClaudeSessions.ts +0 -299
  226. package/apps/dashboard/lib/claude-process-manager.ts +0 -492
  227. package/apps/dashboard/lib/db-bridge.ts +0 -282
  228. package/apps/dashboard/lib/prototypes.ts +0 -202
  229. package/apps/dashboard/lib/test-results-db.ts +0 -307
  230. package/apps/dashboard/lib/tests.ts +0 -282
  231. package/apps/dashboard/next.config.js +0 -50
  232. package/apps/dashboard/postcss.config.mjs +0 -7
  233. package/apps/dashboard/public/file.svg +0 -1
  234. package/apps/dashboard/public/globe.svg +0 -1
  235. package/apps/dashboard/public/next.svg +0 -1
  236. package/apps/dashboard/public/vercel.svg +0 -1
  237. package/apps/dashboard/public/window.svg +0 -1
  238. package/apps/dashboard/scripts/download-node.js +0 -104
  239. package/apps/dashboard/scripts/upload-to-r2.js +0 -89
  240. package/docs/bdd-guidance.md +0 -390
@@ -0,0 +1,507 @@
1
+ //! Database connection management.
2
+ //!
3
+ //! Opens the project's `.jettypod/work.db` with WAL mode and busy timeout,
4
+ //! initializes the schema for new databases, and provides integrity/checkpoint
5
+ //! utilities.
6
+ //!
7
+ //! Port of `lib/database.js` — same pragmas, same schema, same behavior.
8
+
9
+ mod recovery;
10
+ mod startup;
11
+ mod validate;
12
+
13
+ pub use validate::{IntegrityResult, WalCheckpointResult};
14
+
15
+ use crate::error::{CoreError, Result};
16
+ use crate::ws::BroadcastMessage;
17
+ use rusqlite::hooks::Action;
18
+ use rusqlite::Connection;
19
+ use std::path::{Path, PathBuf};
20
+ use tokio::sync::broadcast;
21
+
22
+ mod embedded {
23
+ use refinery::embed_migrations;
24
+ embed_migrations!("migrations");
25
+ }
26
+
27
+ // ---------------------------------------------------------------------------
28
+ // Database
29
+ // ---------------------------------------------------------------------------
30
+
31
+ /// The work database — wraps a rusqlite `Connection` with JettyPod-specific
32
+ /// pragma configuration.
33
+ pub struct Database {
34
+ conn: Connection,
35
+ }
36
+
37
+ impl Database {
38
+ /// Open a database at a specific file path **without** startup validation.
39
+ ///
40
+ /// Use `open_path` (which adds integrity checks and recovery) for
41
+ /// production callers. This unchecked variant is for tests, recovery
42
+ /// internals, and other cases where you need a bare connection.
43
+ pub fn open_path_unchecked(path: &Path) -> Result<Self> {
44
+ if let Some(parent) = path.parent() {
45
+ std::fs::create_dir_all(parent)?;
46
+ }
47
+
48
+ let mut conn = Connection::open(path)?;
49
+
50
+ // Match the Node.js pragma configuration exactly:
51
+ conn.pragma_update(None, "journal_mode", "WAL")?;
52
+ conn.pragma_update(None, "synchronous", "NORMAL")?;
53
+ conn.pragma_update(None, "busy_timeout", 5000)?;
54
+ conn.pragma_update(None, "foreign_keys", "ON")?;
55
+
56
+ // Run migrations — V1 is the full baseline schema (all IF NOT EXISTS),
57
+ // so it's idempotent on both fresh and existing databases.
58
+ // Future schema changes are added as V2, V3, etc.
59
+ embedded::migrations::runner()
60
+ .run(&mut conn)
61
+ .map_err(|e| CoreError::Config(format!("Migration failed: {e}")))?;
62
+
63
+ let db = Self { conn };
64
+
65
+ // Sync _meta.schema_version so Node.js knows the current schema state.
66
+ db.sync_meta_version()?;
67
+
68
+ Ok(db)
69
+ }
70
+
71
+ /// Get a reference to the underlying rusqlite connection.
72
+ pub fn conn(&self) -> &Connection {
73
+ &self.conn
74
+ }
75
+
76
+ /// Register a SQLite update hook that sends delta events to the WebSocket
77
+ /// broadcast channel. Only fires for writes through this connection (Tauri
78
+ /// IPC); external writes (CLI) are still detected by file mtime polling.
79
+ pub fn register_update_hook(&self, broadcast_tx: broadcast::Sender<String>) {
80
+ self.conn.update_hook(Some(
81
+ move |action: Action, _db: &str, table: &str, rowid: i64| {
82
+ let action_str = match action {
83
+ Action::SQLITE_INSERT => "insert",
84
+ Action::SQLITE_UPDATE => "update",
85
+ Action::SQLITE_DELETE => "delete",
86
+ _ => return,
87
+ };
88
+ let msg = BroadcastMessage::db_delta(action_str, table, rowid).to_json();
89
+ // Non-blocking send — if no receivers, that's fine
90
+ let _ = broadcast_tx.send(msg);
91
+ },
92
+ ));
93
+ }
94
+ }
95
+
96
+ // ---------------------------------------------------------------------------
97
+ // Path resolution
98
+ // ---------------------------------------------------------------------------
99
+
100
+ /// Find the project root from the current working directory.
101
+ ///
102
+ /// Strategy:
103
+ /// 1. Walk up from CWD looking for a `.jettypod` directory
104
+ /// 2. Fall back to `git rev-parse --show-toplevel`
105
+ pub fn find_project_root() -> Result<PathBuf> {
106
+ let cwd = std::env::current_dir()?;
107
+
108
+ // Walk up looking for .jettypod/
109
+ let mut dir = cwd.as_path();
110
+ loop {
111
+ if dir.join(".jettypod").is_dir() {
112
+ return Ok(dir.to_path_buf());
113
+ }
114
+ match dir.parent() {
115
+ Some(parent) => dir = parent,
116
+ None => break,
117
+ }
118
+ }
119
+
120
+ // Fall back to git root
121
+ let output = std::process::Command::new("git")
122
+ .args(["rev-parse", "--show-toplevel"])
123
+ .output()?;
124
+
125
+ if output.status.success() {
126
+ let root = String::from_utf8_lossy(&output.stdout).trim().to_string();
127
+ return Ok(PathBuf::from(root));
128
+ }
129
+
130
+ Err(CoreError::Config(
131
+ "Not in a JettyPod project (no .jettypod directory found)".into(),
132
+ ))
133
+ }
134
+
135
+ // ---------------------------------------------------------------------------
136
+ // Tests
137
+ // ---------------------------------------------------------------------------
138
+
139
+ #[cfg(test)]
140
+ mod tests {
141
+ use super::*;
142
+
143
+ fn temp_db() -> (tempfile::TempDir, Database) {
144
+ let dir = tempfile::tempdir().unwrap();
145
+ let db_path = dir.path().join("test.db");
146
+ let db = Database::open_path_unchecked(&db_path).unwrap();
147
+ (dir, db)
148
+ }
149
+
150
+ #[test]
151
+ fn open_creates_schema() {
152
+ let (_dir, db) = temp_db();
153
+ let count: i32 = db
154
+ .conn()
155
+ .query_row(
156
+ "SELECT COUNT(*) FROM pragma_table_info('work_items')",
157
+ [],
158
+ |row| row.get(0),
159
+ )
160
+ .unwrap();
161
+ assert!(count >= 30, "Expected at least 30 columns, got {}", count);
162
+ }
163
+
164
+ #[test]
165
+ fn integrity_check_passes() {
166
+ let (_dir, db) = temp_db();
167
+ let result = db.check_integrity().unwrap();
168
+ assert!(result.ok);
169
+ assert!(result.errors.is_empty());
170
+ }
171
+
172
+ #[test]
173
+ fn wal_checkpoint_works() {
174
+ let (_dir, db) = temp_db();
175
+ db.conn()
176
+ .execute(
177
+ "INSERT INTO work_items (type, title) VALUES ('chore', 'test')",
178
+ [],
179
+ )
180
+ .unwrap();
181
+ let result = db.wal_checkpoint().unwrap();
182
+ assert!(result.success);
183
+ }
184
+
185
+ #[test]
186
+ fn validate_schema_passes() {
187
+ let (_dir, db) = temp_db();
188
+ db.validate_schema().unwrap();
189
+ }
190
+
191
+ #[test]
192
+ fn open_creates_parent_dirs() {
193
+ let dir = tempfile::tempdir().unwrap();
194
+ let db_path = dir.path().join("deep").join("nested").join("test.db");
195
+ let db = Database::open_path_unchecked(&db_path).unwrap();
196
+ assert!(db.check_integrity().unwrap().ok);
197
+ }
198
+
199
+ #[test]
200
+ fn schema_is_idempotent() {
201
+ let (_dir, db) = temp_db();
202
+ // Running the baseline migration SQL again should not error
203
+ const BASELINE_SQL: &str = include_str!("../../migrations/V1__baseline.sql");
204
+ db.conn().execute_batch(BASELINE_SQL).unwrap();
205
+
206
+ db.conn()
207
+ .execute(
208
+ "INSERT INTO work_items (type, title) VALUES ('epic', 'test')",
209
+ [],
210
+ )
211
+ .unwrap();
212
+ let title: String = db
213
+ .conn()
214
+ .query_row("SELECT title FROM work_items WHERE id = 1", [], |row| {
215
+ row.get(0)
216
+ })
217
+ .unwrap();
218
+ assert_eq!(title, "test");
219
+ }
220
+
221
+ #[test]
222
+ fn foreign_keys_enforced() {
223
+ let (_dir, db) = temp_db();
224
+ let result = db.conn().execute(
225
+ "INSERT INTO discovery_decisions (work_item_id, aspect, decision, rationale) VALUES (999, 'a', 'b', 'c')",
226
+ [],
227
+ );
228
+ assert!(result.is_err());
229
+ }
230
+
231
+ #[test]
232
+ fn validate_tables_passes() {
233
+ let (_dir, db) = temp_db();
234
+ db.validate_tables().unwrap();
235
+ }
236
+
237
+ #[test]
238
+ fn sync_meta_version_sets_value() {
239
+ let (_dir, db) = temp_db();
240
+ let version: String = db
241
+ .conn()
242
+ .query_row(
243
+ "SELECT value FROM _meta WHERE key = 'schema_version'",
244
+ [],
245
+ |row| row.get(0),
246
+ )
247
+ .unwrap();
248
+ assert_eq!(version, "33");
249
+ }
250
+
251
+ #[test]
252
+ fn open_from_project_root() {
253
+ let dir = tempfile::tempdir().unwrap();
254
+ std::fs::create_dir_all(dir.path().join(".jettypod")).unwrap();
255
+ let db = Database::open(dir.path()).unwrap();
256
+ assert!(dir.path().join(".jettypod").join("work.db").exists());
257
+ assert!(db.check_integrity().unwrap().ok);
258
+ }
259
+
260
+ #[test]
261
+ fn open_validated_on_fresh_db() {
262
+ let dir = tempfile::tempdir().unwrap();
263
+ let db_path = dir.path().join("test.db");
264
+ let db = Database::open_path(&db_path).unwrap();
265
+ assert!(db.check_integrity().unwrap().ok);
266
+ db.validate_schema().unwrap();
267
+ db.validate_tables().unwrap();
268
+ }
269
+
270
+ #[test]
271
+ fn open_validated_from_project_root() {
272
+ let dir = tempfile::tempdir().unwrap();
273
+ std::fs::create_dir_all(dir.path().join(".jettypod")).unwrap();
274
+ let db = Database::open(dir.path()).unwrap();
275
+ assert!(db.check_integrity().unwrap().ok);
276
+ }
277
+
278
+ #[test]
279
+ fn recover_from_snapshot() {
280
+ // 1. Create a DB with some data
281
+ let dir = tempfile::tempdir().unwrap();
282
+ let jettypod_dir = dir.path().join(".jettypod");
283
+ std::fs::create_dir_all(&jettypod_dir).unwrap();
284
+ let db_path = jettypod_dir.join("work.db");
285
+
286
+ let db = Database::open_path_unchecked(&db_path).unwrap();
287
+ db.conn().execute(
288
+ "INSERT INTO work_items (type, title) VALUES ('epic', 'My Epic')",
289
+ [],
290
+ ).unwrap();
291
+ db.conn().execute(
292
+ "INSERT INTO work_items (type, title) VALUES ('chore', 'My Chore')",
293
+ [],
294
+ ).unwrap();
295
+ drop(db);
296
+
297
+ // 2. Create a snapshot JSON
298
+ let snapshots_dir = jettypod_dir.join("snapshots");
299
+ std::fs::create_dir_all(&snapshots_dir).unwrap();
300
+ let snapshot = serde_json::json!({
301
+ "work_items": [
302
+ {"id": 1, "type": "epic", "title": "Recovered Epic", "status": "backlog"},
303
+ {"id": 2, "type": "chore", "title": "Recovered Chore", "status": "backlog"}
304
+ ]
305
+ });
306
+ std::fs::write(
307
+ snapshots_dir.join("work.json"),
308
+ serde_json::to_string(&snapshot).unwrap(),
309
+ ).unwrap();
310
+
311
+ // 3. Delete the DB to simulate missing file
312
+ std::fs::remove_file(&db_path).unwrap();
313
+ assert!(!db_path.exists());
314
+
315
+ // 4. open_path_validated should recover from snapshot
316
+ let db = Database::open_path(&db_path).unwrap();
317
+ let title: String = db.conn().query_row(
318
+ "SELECT title FROM work_items WHERE id = 1",
319
+ [],
320
+ |row| row.get(0),
321
+ ).unwrap();
322
+ assert_eq!(title, "Recovered Epic");
323
+
324
+ let count: i32 = db.conn().query_row(
325
+ "SELECT COUNT(*) FROM work_items",
326
+ [],
327
+ |row| row.get(0),
328
+ ).unwrap();
329
+ assert_eq!(count, 2);
330
+ }
331
+
332
+ #[test]
333
+ fn open_validated_missing_db_no_snapshot() {
334
+ // Missing DB + no snapshot = creates fresh DB
335
+ let dir = tempfile::tempdir().unwrap();
336
+ let db_path = dir.path().join("test.db");
337
+ assert!(!db_path.exists());
338
+ let db = Database::open_path(&db_path).unwrap();
339
+ assert!(db.check_integrity().unwrap().ok);
340
+ }
341
+
342
+ #[test]
343
+ fn snapshot_rejects_sql_injection_in_table_name() {
344
+ let dir = tempfile::tempdir().unwrap();
345
+ let jettypod_dir = dir.path().join(".jettypod");
346
+ std::fs::create_dir_all(&jettypod_dir).unwrap();
347
+ let db_path = jettypod_dir.join("work.db");
348
+
349
+ let snapshots_dir = jettypod_dir.join("snapshots");
350
+ std::fs::create_dir_all(&snapshots_dir).unwrap();
351
+ let malicious = serde_json::json!({
352
+ "work_items; DROP TABLE work_items; --": [
353
+ {"id": 1, "type": "epic", "title": "hacked"}
354
+ ]
355
+ });
356
+ std::fs::write(
357
+ snapshots_dir.join("work.json"),
358
+ serde_json::to_string(&malicious).unwrap(),
359
+ ).unwrap();
360
+
361
+ let result = Database::recover_from_snapshots(&db_path);
362
+ assert!(result.is_err());
363
+ let err = result.unwrap_err().to_string();
364
+ assert!(err.contains("Unsafe SQL identifier"), "Expected injection rejection, got: {err}");
365
+ }
366
+
367
+ #[test]
368
+ fn update_hook_sends_delta_on_insert() {
369
+ let (_dir, db) = temp_db();
370
+ let (tx, mut rx) = broadcast::channel::<String>(16);
371
+ db.register_update_hook(tx);
372
+
373
+ db.conn()
374
+ .execute(
375
+ "INSERT INTO work_items (type, title) VALUES ('chore', 'Hook test')",
376
+ [],
377
+ )
378
+ .unwrap();
379
+
380
+ let msg = rx.try_recv().unwrap();
381
+ let parsed: serde_json::Value = serde_json::from_str(&msg).unwrap();
382
+ assert_eq!(parsed["type"], "db_delta");
383
+ assert_eq!(parsed["table"], "work_items");
384
+ assert_eq!(parsed["action"], "insert");
385
+ assert!(parsed["rowid"].as_i64().unwrap() > 0);
386
+ }
387
+
388
+ #[test]
389
+ fn update_hook_sends_delta_on_update() {
390
+ let (_dir, db) = temp_db();
391
+ let (tx, mut rx) = broadcast::channel::<String>(16);
392
+
393
+ db.conn()
394
+ .execute(
395
+ "INSERT INTO work_items (type, title) VALUES ('chore', 'Before hook')",
396
+ [],
397
+ )
398
+ .unwrap();
399
+
400
+ db.register_update_hook(tx);
401
+
402
+ db.conn()
403
+ .execute(
404
+ "UPDATE work_items SET title = 'After hook' WHERE id = 1",
405
+ [],
406
+ )
407
+ .unwrap();
408
+
409
+ let msg = rx.try_recv().unwrap();
410
+ let parsed: serde_json::Value = serde_json::from_str(&msg).unwrap();
411
+ assert_eq!(parsed["type"], "db_delta");
412
+ assert_eq!(parsed["action"], "update");
413
+ assert_eq!(parsed["rowid"], 1);
414
+ }
415
+
416
+ #[test]
417
+ fn update_hook_sends_delta_on_delete() {
418
+ let (_dir, db) = temp_db();
419
+ let (tx, mut rx) = broadcast::channel::<String>(16);
420
+
421
+ db.conn()
422
+ .execute(
423
+ "INSERT INTO work_items (type, title) VALUES ('chore', 'To delete')",
424
+ [],
425
+ )
426
+ .unwrap();
427
+
428
+ db.register_update_hook(tx);
429
+
430
+ db.conn()
431
+ .execute("DELETE FROM work_items WHERE id = 1", [])
432
+ .unwrap();
433
+
434
+ let msg = rx.try_recv().unwrap();
435
+ let parsed: serde_json::Value = serde_json::from_str(&msg).unwrap();
436
+ assert_eq!(parsed["type"], "db_delta");
437
+ assert_eq!(parsed["action"], "delete");
438
+ assert_eq!(parsed["rowid"], 1);
439
+ }
440
+
441
+ #[test]
442
+ fn update_hook_fires_for_each_row() {
443
+ let (_dir, db) = temp_db();
444
+ let (tx, mut rx) = broadcast::channel::<String>(32);
445
+ db.register_update_hook(tx);
446
+
447
+ db.conn()
448
+ .execute(
449
+ "INSERT INTO work_items (type, title) VALUES ('chore', 'First')",
450
+ [],
451
+ )
452
+ .unwrap();
453
+ db.conn()
454
+ .execute(
455
+ "INSERT INTO work_items (type, title) VALUES ('chore', 'Second')",
456
+ [],
457
+ )
458
+ .unwrap();
459
+
460
+ let msg1 = rx.try_recv().unwrap();
461
+ let msg2 = rx.try_recv().unwrap();
462
+ let p1: serde_json::Value = serde_json::from_str(&msg1).unwrap();
463
+ let p2: serde_json::Value = serde_json::from_str(&msg2).unwrap();
464
+ assert_eq!(p1["action"], "insert");
465
+ assert_eq!(p2["action"], "insert");
466
+ assert_ne!(p1["rowid"], p2["rowid"]);
467
+ }
468
+
469
+ #[test]
470
+ fn update_hook_no_panic_without_receivers() {
471
+ let (_dir, db) = temp_db();
472
+ let (tx, _) = broadcast::channel::<String>(16);
473
+ db.register_update_hook(tx);
474
+ // All receivers dropped — send should not panic
475
+ db.conn()
476
+ .execute(
477
+ "INSERT INTO work_items (type, title) VALUES ('chore', 'No receiver')",
478
+ [],
479
+ )
480
+ .unwrap();
481
+ }
482
+
483
+ #[test]
484
+ fn snapshot_rejects_sql_injection_in_column_name() {
485
+ let dir = tempfile::tempdir().unwrap();
486
+ let jettypod_dir = dir.path().join(".jettypod");
487
+ std::fs::create_dir_all(&jettypod_dir).unwrap();
488
+ let db_path = jettypod_dir.join("work.db");
489
+
490
+ let snapshots_dir = jettypod_dir.join("snapshots");
491
+ std::fs::create_dir_all(&snapshots_dir).unwrap();
492
+ let malicious = serde_json::json!({
493
+ "work_items": [
494
+ {"id": 1, "type) VALUES (1,'epic'); DROP TABLE work_items; --": "hacked"}
495
+ ]
496
+ });
497
+ std::fs::write(
498
+ snapshots_dir.join("work.json"),
499
+ serde_json::to_string(&malicious).unwrap(),
500
+ ).unwrap();
501
+
502
+ let result = Database::recover_from_snapshots(&db_path);
503
+ assert!(result.is_err());
504
+ let err = result.unwrap_err().to_string();
505
+ assert!(err.contains("Unsafe SQL identifier"), "Expected injection rejection, got: {err}");
506
+ }
507
+ }
@@ -0,0 +1,114 @@
1
+ //! Snapshot-based database recovery.
2
+ //!
3
+ //! Reads `.jettypod/snapshots/work.json` to rebuild a database from a JSON
4
+ //! export when the database file is missing or corrupted.
5
+
6
+ use crate::error::{CoreError, Result};
7
+ use rusqlite::types::Value as SqlValue;
8
+ use std::collections::HashMap;
9
+ use std::path::Path;
10
+ use super::Database;
11
+
12
+ impl Database {
13
+ /// Attempt to recover a database from a JSON snapshot.
14
+ ///
15
+ /// Reads `.jettypod/snapshots/work.json`, deletes the corrupted database
16
+ /// files, creates a fresh database, and imports all rows.
17
+ ///
18
+ /// Returns the total number of rows imported on success.
19
+ pub(super) fn recover_from_snapshots(db_path: &Path) -> Result<usize> {
20
+ let jettypod_dir = db_path
21
+ .parent()
22
+ .ok_or_else(|| CoreError::Config("Invalid database path".into()))?;
23
+ let json_path = jettypod_dir.join("snapshots").join("work.json");
24
+
25
+ if !json_path.exists() {
26
+ return Err(CoreError::Config("No snapshot file found".into()));
27
+ }
28
+
29
+ // Read and parse snapshot
30
+ let content = std::fs::read_to_string(&json_path)?;
31
+ let data: HashMap<String, Vec<HashMap<String, serde_json::Value>>> =
32
+ serde_json::from_str(&content)?;
33
+
34
+ // Remove corrupted database files
35
+ let db_str = db_path.to_string_lossy();
36
+ for suffix in ["", "-wal", "-shm"] {
37
+ let file = format!("{db_str}{suffix}");
38
+ let p = Path::new(&file);
39
+ if p.exists() {
40
+ std::fs::remove_file(p)?;
41
+ }
42
+ }
43
+
44
+ // Create fresh database (runs migrations + schema)
45
+ let db = Self::open_path_unchecked(db_path)?;
46
+
47
+ // Import rows from snapshot
48
+ let mut total = 0usize;
49
+ for (table_name, rows) in &data {
50
+ if rows.is_empty() {
51
+ continue;
52
+ }
53
+
54
+ validate_identifier(table_name)?;
55
+ let columns: Vec<&String> = rows[0].keys().collect();
56
+ for col in &columns {
57
+ validate_identifier(col)?;
58
+ }
59
+ let col_names = columns.iter().map(|c| c.as_str()).collect::<Vec<_>>().join(", ");
60
+ let placeholders = columns.iter().map(|_| "?").collect::<Vec<_>>().join(", ");
61
+ let sql = format!("INSERT INTO {table_name} ({col_names}) VALUES ({placeholders})");
62
+
63
+ for row in rows {
64
+ let values: Vec<SqlValue> = columns
65
+ .iter()
66
+ .map(|col| json_to_sql(row.get(*col).unwrap_or(&serde_json::Value::Null)))
67
+ .collect();
68
+
69
+ let params: Vec<&dyn rusqlite::types::ToSql> =
70
+ values.iter().map(|v| v as &dyn rusqlite::types::ToSql).collect();
71
+
72
+ db.conn.execute(&sql, params.as_slice())?;
73
+ total += 1;
74
+ }
75
+ }
76
+
77
+ Ok(total)
78
+ }
79
+ }
80
+
81
+ /// Validate that a SQL identifier (table/column name) contains only safe characters.
82
+ fn validate_identifier(name: &str) -> Result<()> {
83
+ if name.is_empty() {
84
+ return Err(CoreError::Config("Empty SQL identifier".into()));
85
+ }
86
+ if !name
87
+ .chars()
88
+ .all(|c| c.is_ascii_alphanumeric() || c == '_')
89
+ {
90
+ return Err(CoreError::Config(format!(
91
+ "Unsafe SQL identifier: {name:?}"
92
+ )));
93
+ }
94
+ Ok(())
95
+ }
96
+
97
+ /// Convert a serde_json value to a rusqlite value for dynamic SQL inserts.
98
+ fn json_to_sql(val: &serde_json::Value) -> SqlValue {
99
+ match val {
100
+ serde_json::Value::Null => SqlValue::Null,
101
+ serde_json::Value::Bool(b) => SqlValue::Integer(*b as i64),
102
+ serde_json::Value::Number(n) => {
103
+ if let Some(i) = n.as_i64() {
104
+ SqlValue::Integer(i)
105
+ } else if let Some(f) = n.as_f64() {
106
+ SqlValue::Real(f)
107
+ } else {
108
+ SqlValue::Null
109
+ }
110
+ }
111
+ serde_json::Value::String(s) => SqlValue::Text(s.clone()),
112
+ other => SqlValue::Text(other.to_string()),
113
+ }
114
+ }