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.
- package/.env +4 -3
- package/Cargo.lock +6450 -0
- package/Cargo.toml +35 -0
- package/README.md +5 -1
- package/TAURI-MIGRATION-PLAN.md +840 -0
- package/apps/dashboard/app/connect-claude/page.tsx +5 -6
- package/apps/dashboard/app/decision/[id]/page.tsx +63 -58
- package/apps/dashboard/app/demo/gates/page.tsx +43 -45
- package/apps/dashboard/app/design-system/page.tsx +868 -0
- package/apps/dashboard/app/globals.css +80 -4
- package/apps/dashboard/app/install-claude/page.tsx +4 -6
- package/apps/dashboard/app/login/page.tsx +72 -54
- package/apps/dashboard/app/page.tsx +101 -48
- package/apps/dashboard/app/settings/page.tsx +61 -13
- package/apps/dashboard/app/signup/page.tsx +242 -0
- package/apps/dashboard/app/subscribe/page.tsx +0 -2
- package/apps/dashboard/app/tests/page.tsx +37 -4
- package/apps/dashboard/app/welcome/page.tsx +13 -16
- package/apps/dashboard/app/work/[id]/page.tsx +117 -118
- package/apps/dashboard/app/work/[id]/proof/page.tsx +1489 -0
- package/apps/dashboard/components/AppShell.tsx +92 -85
- package/apps/dashboard/components/CardMenu.tsx +45 -12
- package/apps/dashboard/components/ClaudePanel.tsx +771 -850
- package/apps/dashboard/components/ClaudePanelInput.tsx +43 -15
- package/apps/dashboard/components/ConnectClaudeScreen.tsx +17 -34
- package/apps/dashboard/components/CopyableId.tsx +3 -4
- package/apps/dashboard/components/DetailReviewActions.tsx +100 -0
- package/apps/dashboard/components/DragContext.tsx +134 -63
- package/apps/dashboard/components/DraggableCard.tsx +3 -5
- package/apps/dashboard/components/DropZone.tsx +6 -7
- package/apps/dashboard/components/EditableDetailDescription.tsx +7 -13
- package/apps/dashboard/components/EditableDetailTitle.tsx +6 -13
- package/apps/dashboard/components/EditableTitle.tsx +26 -7
- package/apps/dashboard/components/ElapsedTimer.tsx +66 -0
- package/apps/dashboard/components/EpicGroup.tsx +359 -0
- package/apps/dashboard/components/GateCard.tsx +79 -17
- package/apps/dashboard/components/GateChoiceCard.tsx +15 -18
- package/apps/dashboard/components/InstallClaudeScreen.tsx +15 -32
- package/apps/dashboard/components/JettyLoader.tsx +37 -0
- package/apps/dashboard/components/KanbanBoard.tsx +368 -958
- package/apps/dashboard/components/KanbanCard.tsx +740 -0
- package/apps/dashboard/components/LazyCard.tsx +62 -0
- package/apps/dashboard/components/LazyMarkdown.tsx +11 -0
- package/apps/dashboard/components/MainNav.tsx +38 -73
- package/apps/dashboard/components/MessageBlock.tsx +468 -0
- package/apps/dashboard/components/ModeStartCard.tsx +15 -16
- package/apps/dashboard/components/OnboardingWelcome.tsx +213 -0
- package/apps/dashboard/components/PlaceholderCard.tsx +3 -4
- package/apps/dashboard/components/ProjectSwitcher.tsx +30 -30
- package/apps/dashboard/components/PrototypeTimeline.tsx +72 -51
- package/apps/dashboard/components/RealTimeKanbanWrapper.tsx +406 -388
- package/apps/dashboard/components/RealTimeTestsWrapper.tsx +373 -235
- package/apps/dashboard/components/ReviewFooter.tsx +139 -0
- package/apps/dashboard/components/SessionList.tsx +19 -19
- package/apps/dashboard/components/SubscribeContent.tsx +91 -47
- package/apps/dashboard/components/TestTree.tsx +16 -16
- package/apps/dashboard/components/TipCard.tsx +16 -17
- package/apps/dashboard/components/Toast.tsx +5 -6
- package/apps/dashboard/components/TypeIcon.tsx +55 -0
- package/apps/dashboard/components/ViewModeToolbar.tsx +104 -0
- package/apps/dashboard/components/WaveCompletionAnimation.tsx +52 -65
- package/apps/dashboard/components/WelcomeScreen.tsx +19 -35
- package/apps/dashboard/components/WorkItemHeader.tsx +4 -5
- package/apps/dashboard/components/WorkItemTree.tsx +11 -32
- package/apps/dashboard/components/settings/AccountSection.tsx +55 -35
- package/apps/dashboard/components/settings/AiContextSection.tsx +89 -0
- package/apps/dashboard/components/settings/ContextDocumentsSection.tsx +317 -0
- package/apps/dashboard/components/settings/EnvVarsSection.tsx +74 -152
- package/apps/dashboard/components/settings/GeneralSection.tsx +162 -56
- package/apps/dashboard/components/settings/ProjectStackSection.tsx +948 -0
- package/apps/dashboard/components/settings/SettingsLayout.tsx +4 -5
- package/apps/dashboard/components/ui/Button.tsx +104 -0
- package/apps/dashboard/components/ui/Input.tsx +78 -0
- package/apps/dashboard/components.json +1 -1
- package/apps/dashboard/contexts/ClaudeSessionContext.tsx +711 -418
- package/apps/dashboard/contexts/ConnectionStatusContext.tsx +25 -5
- package/apps/dashboard/contexts/UsageContext.tsx +87 -32
- package/apps/dashboard/dev.sh +35 -0
- package/apps/dashboard/eslint.config.mjs +9 -9
- package/apps/dashboard/hooks/useKanbanAnimation.ts +29 -0
- package/apps/dashboard/hooks/useKanbanUndo.ts +83 -0
- package/apps/dashboard/hooks/useWebSocket.ts +138 -83
- package/apps/dashboard/index.html +73 -0
- package/apps/dashboard/lib/constants.ts +43 -0
- package/apps/dashboard/lib/data-bridge.ts +722 -0
- package/apps/dashboard/lib/db.ts +69 -1265
- package/apps/dashboard/lib/environment-config.ts +173 -0
- package/apps/dashboard/lib/environment-verification.ts +119 -0
- package/apps/dashboard/lib/kanban-utils.ts +270 -0
- package/apps/dashboard/lib/proof-run.ts +495 -0
- package/apps/dashboard/lib/proof-scenario-runner.ts +346 -0
- package/apps/dashboard/lib/run-migrations.js +27 -2
- package/apps/dashboard/lib/service-recovery.ts +326 -0
- package/apps/dashboard/lib/session-state-machine.ts +1 -0
- package/apps/dashboard/lib/session-state-utils.ts +0 -164
- package/apps/dashboard/lib/session-stream-manager.ts +308 -134
- package/apps/dashboard/lib/shadows.ts +7 -0
- package/apps/dashboard/lib/stream-manager-registry.ts +46 -6
- package/apps/dashboard/lib/tauri-bridge.ts +102 -0
- package/apps/dashboard/lib/tauri.ts +106 -0
- package/apps/dashboard/lib/utils.ts +6 -0
- package/apps/dashboard/next-env.d.ts +1 -1
- package/apps/dashboard/package.json +21 -32
- package/apps/dashboard/public/bug-icon.png +0 -0
- package/apps/dashboard/public/buoy-icon.png +0 -0
- package/apps/dashboard/public/fonts/Satoshi-Variable.woff2 +0 -0
- package/apps/dashboard/public/fonts/Satoshi-VariableItalic.woff2 +0 -0
- package/apps/dashboard/public/in-flight-seagull.png +0 -0
- package/apps/dashboard/public/jetty-icon-loading-alt.svg +11 -0
- package/apps/dashboard/public/jetty-icon-loading.svg +11 -0
- package/apps/dashboard/public/jettypod_logo.png +0 -0
- package/apps/dashboard/public/pier-icon.png +0 -0
- package/apps/dashboard/public/star-icon.png +0 -0
- package/apps/dashboard/public/wrench-icon.png +0 -0
- package/apps/dashboard/scripts/tauri-build.js +228 -0
- package/apps/dashboard/scripts/upload-tauri-to-r2.js +125 -0
- package/apps/dashboard/scripts/ws-server.js +191 -0
- package/apps/dashboard/src/main.tsx +12 -0
- package/apps/dashboard/src/router.tsx +107 -0
- package/apps/dashboard/src/vite-env.d.ts +1 -0
- package/apps/dashboard/tsconfig.json +7 -12
- package/apps/dashboard/tsconfig.tsbuildinfo +1 -1
- package/apps/dashboard/vite.config.ts +33 -0
- package/apps/update-server/src/index.ts +228 -80
- package/claude-hooks/global-guardrails.js +14 -13
- package/crates/jettypod-cli/Cargo.toml +19 -0
- package/crates/jettypod-cli/src/commands.rs +1249 -0
- package/crates/jettypod-cli/src/main.rs +595 -0
- package/crates/jettypod-core/Cargo.toml +26 -0
- package/crates/jettypod-core/build.rs +98 -0
- package/crates/jettypod-core/migrations/V1__baseline.sql +197 -0
- package/crates/jettypod-core/migrations/V2__work_items_indexes.sql +6 -0
- package/crates/jettypod-core/migrations/V3__qa_steps.sql +2 -0
- package/crates/jettypod-core/src/auth.rs +294 -0
- package/crates/jettypod-core/src/config.rs +397 -0
- package/crates/jettypod-core/src/db/mod.rs +507 -0
- package/crates/jettypod-core/src/db/recovery.rs +114 -0
- package/crates/jettypod-core/src/db/startup.rs +101 -0
- package/crates/jettypod-core/src/db/validate.rs +149 -0
- package/crates/jettypod-core/src/error.rs +76 -0
- package/crates/jettypod-core/src/git.rs +458 -0
- package/crates/jettypod-core/src/lib.rs +20 -0
- package/crates/jettypod-core/src/sessions.rs +625 -0
- package/crates/jettypod-core/src/skills.rs +556 -0
- package/crates/jettypod-core/src/work.rs +1086 -0
- package/crates/jettypod-core/src/worktree.rs +628 -0
- package/crates/jettypod-core/src/ws.rs +767 -0
- package/cucumber-test.cjs +6 -0
- package/cucumber.js +9 -3
- package/docs/COMMAND_REFERENCE.md +34 -0
- package/hooks/post-checkout +32 -75
- package/hooks/post-merge +111 -10
- package/jest.setup.js +1 -0
- package/jettypod.js +145 -116
- package/lib/bdd-preflight.js +96 -0
- package/lib/chore-taxonomy.js +33 -10
- package/lib/database.js +36 -16
- package/lib/db-watcher.js +1 -1
- package/lib/git-hooks/pre-commit +1 -1
- package/lib/jettypod-backup.js +27 -4
- package/lib/merge-lock.js +111 -253
- package/lib/migrations/027-plan-at-creation-column.js +3 -1
- package/lib/migrations/029-remove-autoincrement.js +307 -0
- package/lib/migrations/029-rename-corrupted-to-cleaned.js +149 -0
- package/lib/migrations/030-rejection-round-columns.js +54 -0
- package/lib/migrations/031-session-isolation-index.js +17 -0
- package/lib/migrations/index.js +47 -4
- package/lib/schema.js +10 -5
- package/lib/seed-onboarding.js +1 -1
- package/lib/update-command/index.js +9 -175
- package/lib/work-commands/index.js +144 -19
- package/lib/work-tracking/index.js +148 -27
- package/lib/worktree-diagnostics.js +16 -16
- package/lib/worktree-facade.js +1 -1
- package/lib/worktree-manager.js +8 -8
- package/lib/worktree-reconciler.js +5 -5
- package/package.json +9 -2
- package/scripts/ndjson-to-cucumber-json.js +152 -0
- package/scripts/postinstall.js +25 -0
- package/skills-templates/bug-mode/SKILL.md +79 -20
- package/skills-templates/bug-planning/SKILL.md +25 -29
- package/skills-templates/chore-mode/SKILL.md +171 -69
- package/skills-templates/chore-mode/verification.js +51 -10
- package/skills-templates/chore-planning/SKILL.md +47 -18
- package/skills-templates/design-system-selection/SKILL.md +273 -0
- package/skills-templates/epic-planning/SKILL.md +82 -48
- package/skills-templates/external-transition/SKILL.md +47 -47
- package/skills-templates/feature-planning/SKILL.md +173 -74
- package/skills-templates/production-mode/SKILL.md +69 -49
- package/skills-templates/request-routing/SKILL.md +4 -4
- package/skills-templates/simple-improvement/SKILL.md +74 -29
- package/skills-templates/speed-mode/SKILL.md +217 -141
- package/skills-templates/stable-mode/SKILL.md +148 -89
- package/apps/dashboard/README.md +0 -36
- package/apps/dashboard/app/api/claude/[workItemId]/message/route.ts +0 -386
- package/apps/dashboard/app/api/claude/[workItemId]/pin/route.ts +0 -24
- package/apps/dashboard/app/api/claude/[workItemId]/route.ts +0 -167
- package/apps/dashboard/app/api/claude/sessions/[sessionId]/content/route.ts +0 -52
- package/apps/dashboard/app/api/claude/sessions/[sessionId]/message/route.ts +0 -378
- package/apps/dashboard/app/api/claude/sessions/[sessionId]/pin/route.ts +0 -24
- package/apps/dashboard/app/api/claude/sessions/cleanup/route.ts +0 -34
- package/apps/dashboard/app/api/claude/sessions/route.ts +0 -184
- package/apps/dashboard/app/api/decisions/[id]/route.ts +0 -25
- package/apps/dashboard/app/api/internal/set-project/route.ts +0 -17
- package/apps/dashboard/app/api/kanban/route.ts +0 -15
- package/apps/dashboard/app/api/settings/env-vars/route.ts +0 -125
- package/apps/dashboard/app/api/settings/general/route.ts +0 -21
- package/apps/dashboard/app/api/tests/route.ts +0 -9
- package/apps/dashboard/app/api/tests/run/route.ts +0 -82
- package/apps/dashboard/app/api/tests/run/stream/route.ts +0 -71
- package/apps/dashboard/app/api/tests/undefined/route.ts +0 -9
- package/apps/dashboard/app/api/usage/route.ts +0 -17
- package/apps/dashboard/app/api/work/[id]/description/route.ts +0 -21
- package/apps/dashboard/app/api/work/[id]/epic/route.ts +0 -21
- package/apps/dashboard/app/api/work/[id]/order/route.ts +0 -21
- package/apps/dashboard/app/api/work/[id]/status/route.ts +0 -21
- package/apps/dashboard/app/api/work/[id]/title/route.ts +0 -21
- package/apps/dashboard/app/layout.tsx +0 -43
- package/apps/dashboard/components/UpgradeBanner.tsx +0 -29
- package/apps/dashboard/electron/ipc-handlers.js +0 -1028
- package/apps/dashboard/electron/main.js +0 -2124
- package/apps/dashboard/electron/preload.js +0 -123
- package/apps/dashboard/electron/session-manager.js +0 -141
- package/apps/dashboard/electron-builder.config.js +0 -357
- package/apps/dashboard/hooks/useClaudeSessions.ts +0 -299
- package/apps/dashboard/lib/claude-process-manager.ts +0 -492
- package/apps/dashboard/lib/db-bridge.ts +0 -282
- package/apps/dashboard/lib/prototypes.ts +0 -202
- package/apps/dashboard/lib/test-results-db.ts +0 -307
- package/apps/dashboard/lib/tests.ts +0 -282
- package/apps/dashboard/next.config.js +0 -50
- package/apps/dashboard/postcss.config.mjs +0 -7
- package/apps/dashboard/public/file.svg +0 -1
- package/apps/dashboard/public/globe.svg +0 -1
- package/apps/dashboard/public/next.svg +0 -1
- package/apps/dashboard/public/vercel.svg +0 -1
- package/apps/dashboard/public/window.svg +0 -1
- package/apps/dashboard/scripts/download-node.js +0 -104
- package/apps/dashboard/scripts/upload-to-r2.js +0 -89
- 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
|
+
}
|