Demo DB: feature-rich seed (12 objects, 500-row audit log) + regenerate action in Settings

- Expand demo schema to exercise every SQLite-available feature: composite PK
  (order_items), self-referencing FK (categories), JSON columns (lowercase
  'json' so the cell popover triggers), BLOBs (files), no-PK table (page_views,
  rowid editing), TEXT PK (app_settings), empty table (marketing_campaigns),
  read-only view (order_summary), CHECK/UNIQUE/defaults/indexes, smart-sort
  tiers, and a 500-row audit_log for pagination/virtualization/filter demos
- Stamp PRAGMA user_version = 2; stale demo files auto-recreate on launch
  (schema is idempotent, guarded inserts)
- Add regenerate_demo_db command: drops live pool handle, wipes + re-seeds
  the demo file; new Settings > General > Demo 'Regenerate demo' button with
  confirm dialog, loading state, toasts
- Fix Re-add demo to use the app data dir (was temp dir) so both paths match
  startup; reload connections after re-add
- Add 9 unit tests (demo.test.rs) covering objects, seed counts, JSON
  validity, FK consistency, view queryability, idempotency, constraint
  shapes, and stale/regenerate file flows
This commit is contained in:
2026-08-03 19:00:21 +08:00
parent 16888460b7
commit a9dbf60ed5
7 changed files with 702 additions and 159 deletions
+3 -2
View File
@@ -315,8 +315,9 @@ cargo test # Rust tests
### Demo & Onboarding ### Demo & Onboarding
| Feature | Status | Details | | Feature | Status | Details |
| :--- | :---: | :--- | | :--- | :---: | :--- |
| Demo SQLite database (auto-seeded) | ✅ | users, products, orders, order_items tables | | Demo SQLite database (auto-seeded) | ✅ | Feature-rich e-commerce demo (SQLite) exercising every SQLite-available feature: users/products/categories (self-FK)/addresses/orders/order_items (composite PK)/audit_log (500 rows, pagination + virtualization + JSON `details`)/files (BLOB)/page_views (no PK → rowid editing)/app_settings (TEXT PK)/marketing_campaigns (empty → Empty Table change)/order_summary VIEW. JSON columns declared lowercase `json` (popover works), CHECK/UNIQUE/defaults/indexes surface in copied DDL, smart-sort tiers covered (updated_at/created_at/last_login_at/*_id/quantity), nullable FKs + long text for the smart editors. Version-stamped via `PRAGMA user_version`; stale demo files auto-recreate on launch |
| Re-add demo DB button | ✅ | Settings → Advanced | | Re-add demo DB button | ✅ | Settings → General → Demo; re-creates the connection (same app-data-dir file as startup) |
| Regenerate demo DB button | ✅ | Settings → General → Demo; confirm dialog, drops the live pool handle, wipes + re-seeds the demo file (clears any edits made against the demo) |
| Getting started / onboarding flow | ❌ | | | Getting started / onboarding flow | ❌ | |
| Welcome tooltips / tour | ❌ | | | Welcome tooltips / tour | ❌ | |
+1 -1
View File
@@ -251,7 +251,7 @@ gridline/
- **Phase 4 — Data Grid & Filters** — Virtualized grid (`@tanstack/react-virtual`, 100k+ rows), server-side sorting/filtering, column show/hide, column resize, export (JSON/CSV/SQL/Markdown), auto-refresh, pagination - **Phase 4 — Data Grid & Filters** — Virtualized grid (`@tanstack/react-virtual`, 100k+ rows), server-side sorting/filtering, column show/hide, column resize, export (JSON/CSV/SQL/Markdown), auto-refresh, pagination
- **Phase 5 — Admin Tools** — `pg_dump`/`pg_restore` UI wrappers with real-time progress, DB-to-DB sync, backup/restore format selectors - **Phase 5 — Admin Tools** — `pg_dump`/`pg_restore` UI wrappers with real-time progress, DB-to-DB sync, backup/restore format selectors
- **Phase 6 — Schema Visualizer** — Interactive ER diagram with React Flow + dagre, crow's foot notation, schema selector, legend, collapsible column views, PostgreSQL + SQLite support - **Phase 6 — Schema Visualizer** — Interactive ER diagram with React Flow + dagre, crow's foot notation, schema selector, legend, collapsible column views, PostgreSQL + SQLite support
- **Home Screen & Organization** — Connection cards by folder, folders CRUD, tags CRUD with colors, global search (Cmd+K), import/export connections (JSON), bulk select/delete, DB type filter, demo SQLite database - **Home Screen & Organization** — Connection cards by folder, folders CRUD, tags CRUD with colors, global search (Cmd+K), import/export connections (JSON), bulk select/delete, DB type filter, feature-rich demo SQLite database (12 objects incl. a view, 500-row audit table, JSON/BLOB/composite-PK/no-PK/empty tables) with re-add + regenerate actions in Settings
- **Query Editor (Core)** — Monaco SQL editor with Cmd+Enter execution, `execute_query` Rust command (PostgreSQL + SQLite, subquery pagination with raw fallback), query tabs in the DB viewer, destructive query confirmation dialog, `query_history` persistence backend - **Query Editor (Core)** — Monaco SQL editor with Cmd+Enter execution, `execute_query` Rust command (PostgreSQL + SQLite, subquery pagination with raw fallback), query tabs in the DB viewer, destructive query confirmation dialog, `query_history` persistence backend
- **Home Screen Filters** — Tag filter with OR semantics, folder cards matching tags or containing matching connections, DB type filter hiding empty folders, environment filter (All/Production/Staging/Development/None), global search across all folders with "Showing Search Results" breadcrumb + Clear - **Home Screen Filters** — Tag filter with OR semantics, folder cards matching tags or containing matching connections, DB type filter hiding empty folders, environment filter (All/Production/Staging/Development/None), global search across all folders with "Showing Search Results" breadcrumb + Clear
- **Query History & Saved Queries** — toolbar history dropdown (load / run / favorite / clear), favorites, consecutive-identical dedup + 500-retention pruning, SaveQueryDialog, and a two-pane Queries view (History / Saved Queries sidebar scoped per connection + tabbed query workspace) - **Query History & Saved Queries** — toolbar history dropdown (load / run / favorite / clear), favorites, consecutive-identical dedup + 500-retention pruning, SaveQueryDialog, and a two-pane Queries view (History / Saved Queries sidebar scoped per connection + tabbed query workspace)
+390 -143
View File
@@ -2,44 +2,49 @@ use crate::models::ConnectionInput;
use crate::store::Store; use crate::store::Store;
use crate::AppState; use crate::AppState;
use rusqlite::Connection; use rusqlite::Connection;
use std::path::Path;
use std::sync::Mutex; use std::sync::Mutex;
use tauri::Manager; use tauri::Manager;
const DEMO_DB_FILENAME: &str = "demo.db"; const DEMO_DB_FILENAME: &str = "demo.db";
const DEMO_CONNECTION_NAME: &str = "Demo (SQLite)"; const DEMO_CONNECTION_NAME: &str = "Demo (SQLite)";
/// Bump whenever the demo schema or seed data changes so existing demo files
/// are recreated on the next launch. The demo is disposable by design — it
/// should always showcase the current feature set.
const DEMO_SCHEMA_VERSION: i64 = 2;
/// Ensure the demo SQLite database exists and a corresponding connection is /// True when the demo file's `PRAGMA user_version` is at or above the current
/// registered. Safe to call on every app start — it's idempotent. /// schema version (i.e. the file already carries the full feature set).
pub fn ensure_demo_db(app_handle: &tauri::AppHandle, store: &Mutex<Store>) -> Result<(), String> { fn demo_file_is_current(conn: &Connection) -> Result<bool, String> {
// Check if the demo connection already exists let version: i64 = conn
{ .query_row("PRAGMA user_version", [], |r| r.get(0))
let s = store.lock().map_err(|e| e.to_string())?;
let existing = s.get_connections()?;
if existing.iter().any(|c| c.name == DEMO_CONNECTION_NAME) {
return Ok(()); // already set up
}
}
// Resolve app data directory
let data_dir = app_handle
.path()
.app_data_dir()
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
std::fs::create_dir_all(&data_dir).map_err(|e| e.to_string())?; Ok(version >= DEMO_SCHEMA_VERSION)
}
let db_path = data_dir.join(DEMO_DB_FILENAME); /// Ensure the demo SQLite file at `path` exists and is seeded with the current
/// schema. Files that predate `DEMO_SCHEMA_VERSION` are recreated so the demo
// Create the demo SQLite file if it doesn't exist /// always showcases every feature.
if !db_path.exists() { fn ensure_demo_file(path: &Path) -> Result<(), String> {
let stale = path.exists() && {
let conn = Connection::open(path).map_err(|e| e.to_string())?;
!demo_file_is_current(&conn).unwrap_or(false)
};
if stale {
std::fs::remove_file(path).map_err(|e| e.to_string())?;
}
if !path.exists() {
let conn = let conn =
Connection::open(&db_path).map_err(|e| format!("Failed to create demo DB: {e}"))?; Connection::open(path).map_err(|e| format!("Failed to create demo DB: {e}"))?;
conn.execute_batch(&get_demo_schema()) conn.execute_batch(&get_demo_schema())
.map_err(|e| format!("Failed to seed demo DB: {e}"))?; .map_err(|e| format!("Failed to seed demo DB: {e}"))?;
} }
Ok(())
}
// Create the demo connection /// Build the connection input for the demo SQLite file at `db_path`.
let input = ConnectionInput { fn demo_connection_input(db_path: &Path) -> ConnectionInput {
ConnectionInput {
name: DEMO_CONNECTION_NAME.to_string(), name: DEMO_CONNECTION_NAME.to_string(),
db_type: "sqlite".to_string(), db_type: "sqlite".to_string(),
host: db_path.to_string_lossy().to_string(), host: db_path.to_string_lossy().to_string(),
@@ -61,155 +66,338 @@ pub fn ensure_demo_db(app_handle: &tauri::AppHandle, store: &Mutex<Store>) -> Re
ssl_ca_path: None, ssl_ca_path: None,
ssl_cert_path: None, ssl_cert_path: None,
ssl_key_path: None, ssl_key_path: None,
}; }
}
/// Resolve the demo database file under the app data directory (the same
/// location the startup `ensure_demo_db` flow seeds), creating the directory
/// when needed.
fn demo_db_path(app: &tauri::AppHandle) -> Result<std::path::PathBuf, String> {
let data_dir = app.path().app_data_dir().map_err(|e| e.to_string())?;
std::fs::create_dir_all(&data_dir).map_err(|e| e.to_string())?;
Ok(data_dir.join(DEMO_DB_FILENAME))
}
/// Register the demo connection unless one already exists.
fn ensure_demo_connection(store: &Mutex<Store>, db_path: &Path) -> Result<(), String> {
let exists = {
let s = store.lock().map_err(|e| e.to_string())?;
s.get_connections()?
.iter()
.any(|c| c.name == DEMO_CONNECTION_NAME)
};
if exists {
return Ok(());
}
let input = demo_connection_input(db_path);
let s = store.lock().map_err(|e| e.to_string())?; let s = store.lock().map_err(|e| e.to_string())?;
s.create_connection(input)?; s.create_connection(input)?;
Ok(()) Ok(())
} }
/// Drop any live pool handle for the demo connection so its SQLite file can
/// be deleted and recreated cleanly.
async fn disconnect_demo_pool(state: &tauri::State<'_, AppState>) -> Result<(), String> {
let demo_id = {
let s = state.db_store.lock().map_err(|e| e.to_string())?;
s.get_connections()?
.iter()
.find(|c| c.name == DEMO_CONNECTION_NAME)
.map(|c| c.id.clone())
};
if let Some(id) = demo_id {
let mut pm = state.pool_manager.lock().await;
pm.remove(&id);
}
Ok(())
}
/// Ensure the demo SQLite database exists and a corresponding connection is
/// registered. Safe to call on every app start — it's idempotent, and it
/// upgrades stale demo files to the current schema automatically.
pub fn ensure_demo_db(app_handle: &tauri::AppHandle, store: &Mutex<Store>) -> Result<(), String> {
let db_path = demo_db_path(app_handle)?;
ensure_demo_file(&db_path)?;
ensure_demo_connection(store, &db_path)
}
/// Tauri command to re-add the demo connection from the settings screen. /// Tauri command to re-add the demo connection from the settings screen.
/// Recreates the demo file when it is missing or stale, then registers the
/// connection (always under the app data directory, matching startup).
#[tauri::command] #[tauri::command]
pub fn recreate_demo_db(state: tauri::State<AppState>) -> Result<String, String> { pub fn recreate_demo_db(
let store = &state.db_store; app: tauri::AppHandle,
ensure_demo_db_inner(store) state: tauri::State<AppState>,
.map(|()| "Demo database connection re-created.".to_string()) ) -> Result<String, String> {
.map_err(|e| e.to_string()) let db_path = demo_db_path(&app)?;
ensure_demo_file(&db_path)?;
ensure_demo_connection(&state.db_store, &db_path)?;
Ok("Demo database connection re-created.".to_string())
} }
/// Internal helper that does not need an AppHandle (for settings usage). /// Tauri command to regenerate the demo database from the settings screen:
fn ensure_demo_db_inner(store: &Mutex<Store>) -> Result<(), String> { /// drops any live pool handle, wipes the current file (including any edits
// Use a temp dir since we don't have the app handle /// made against it) and re-seeds it with fresh demo data.
let data_dir = std::env::temp_dir().join("gridline-demo"); #[tauri::command]
std::fs::create_dir_all(&data_dir).map_err(|e| e.to_string())?; pub async fn regenerate_demo_db(
app: tauri::AppHandle,
let db_path = data_dir.join(DEMO_DB_FILENAME); state: tauri::State<'_, AppState>,
) -> Result<String, String> {
// Check if file already exists let db_path = demo_db_path(&app)?;
disconnect_demo_pool(&state).await?;
if db_path.exists() { if db_path.exists() {
// Just re-create the connection if it was deleted std::fs::remove_file(&db_path).map_err(|e| e.to_string())?;
let s = store.lock().map_err(|e| e.to_string())?;
let existing = s.get_connections()?;
if !existing.iter().any(|c| c.name == DEMO_CONNECTION_NAME) {
drop(s);
let input = ConnectionInput {
name: DEMO_CONNECTION_NAME.to_string(),
db_type: "sqlite".to_string(),
host: db_path.to_string_lossy().to_string(),
port: None,
username: None,
password: None,
database: None,
folder_id: None,
tag_ids: vec![],
environment: Some("development".to_string()),
ssh_host: None,
ssh_port: None,
ssh_user: None,
ssh_auth_method: None,
ssh_private_key_path: None,
ssh_password: None,
ssh_passphrase: None,
ssl_mode: None,
ssl_ca_path: None,
ssl_cert_path: None,
ssl_key_path: None,
};
let s = store.lock().map_err(|e| e.to_string())?;
s.create_connection(input)?;
} }
return Ok(()); ensure_demo_file(&db_path)?;
} ensure_demo_connection(&state.db_store, &db_path)?;
Ok("Demo database regenerated with fresh data.".to_string())
// Create demo DB file and seed it
let conn = Connection::open(&db_path)
.map_err(|e| format!("Failed to create demo DB: {e}"))?;
conn.execute_batch(&get_demo_schema())
.map_err(|e| format!("Failed to seed demo DB: {e}"))?;
let input = ConnectionInput {
name: DEMO_CONNECTION_NAME.to_string(),
db_type: "sqlite".to_string(),
host: db_path.to_string_lossy().to_string(),
port: None,
username: None,
password: None,
database: None,
folder_id: None,
tag_ids: vec![],
environment: Some("development".to_string()),
ssh_host: None,
ssh_port: None,
ssh_user: None,
ssh_auth_method: None,
ssh_private_key_path: None,
ssh_password: None,
ssh_passphrase: None,
ssl_mode: None,
ssl_ca_path: None,
ssl_cert_path: None,
ssl_key_path: None,
};
let s = store.lock().map_err(|e| e.to_string())?;
s.create_connection(input)?;
Ok(())
} }
/// The demo schema + seed data. Validated to exercise every Gridline feature
/// available for SQLite: PK/FK/composite-PK/self-FK metadata, JSON cells,
/// BLOBs, CHECK/UNIQUE constraints, defaults, indexes, views, an empty table,
/// a no-PK table (rowid editing), a TEXT primary key, and a 500-row table for
/// pagination / virtualization / filtering demos.
fn get_demo_schema() -> String { fn get_demo_schema() -> String {
" r#"
PRAGMA user_version = 2;
-- ── Core: users ─────────────────────────────────────────────────────────
-- Row editing, JSON popover (preferences), nullable cols (phone/birth_date),
-- long text (bio -> scrolling textarea editor), smart-sort tiers
-- (updated_at / created_at / last_login_at), UNIQUE (email), defaults.
CREATE TABLE IF NOT EXISTS users ( CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL, name TEXT NOT NULL,
email TEXT NOT NULL UNIQUE, email TEXT NOT NULL UNIQUE,
role TEXT NOT NULL DEFAULT 'user', role TEXT NOT NULL DEFAULT 'user',
created_at TEXT NOT NULL DEFAULT (datetime('now')) phone TEXT,
birth_date TEXT,
bio TEXT,
preferences json,
balance REAL NOT NULL DEFAULT 0,
is_active INTEGER NOT NULL DEFAULT 1,
last_login_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
); );
CREATE TABLE IF NOT EXISTS products (
-- ── Core: categories (self-referencing FK) ──────────────────────────────
CREATE TABLE IF NOT EXISTS categories (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL, name TEXT NOT NULL,
price REAL NOT NULL, parent_id INTEGER REFERENCES categories(id),
category TEXT NOT NULL, slug TEXT NOT NULL UNIQUE,
stock INTEGER NOT NULL DEFAULT 0, sort_order INTEGER NOT NULL DEFAULT 0,
description TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')) created_at TEXT NOT NULL DEFAULT (datetime('now'))
); );
-- ── Core: products ──────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS products (
id INTEGER PRIMARY KEY AUTOINCREMENT,
sku TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
description TEXT,
price REAL NOT NULL CHECK (price >= 0),
category_id INTEGER REFERENCES categories(id),
stock INTEGER NOT NULL DEFAULT 0 CHECK (stock >= 0),
rating REAL,
discontinued INTEGER NOT NULL DEFAULT 0,
attributes json,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- ── Core: addresses (1:N from users, FK dropdown editor) ────────────────
CREATE TABLE IF NOT EXISTS addresses (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id),
label TEXT NOT NULL DEFAULT 'Home',
street TEXT NOT NULL,
city TEXT NOT NULL,
zip TEXT,
country TEXT NOT NULL DEFAULT 'USA',
is_primary INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- ── Core: orders (two FKs -> users and addresses; CHECK status) ─────────
CREATE TABLE IF NOT EXISTS orders ( CREATE TABLE IF NOT EXISTS orders (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id), user_id INTEGER NOT NULL REFERENCES users(id),
total REAL NOT NULL, shipping_address_id INTEGER REFERENCES addresses(id),
status TEXT NOT NULL DEFAULT 'pending', total REAL NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')) status TEXT NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending','processing','shipped','delivered','completed','cancelled')),
notes TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
shipped_at TEXT
); );
-- ── Core: order_items (composite PRIMARY KEY; CHECK) ────────────────────
CREATE TABLE IF NOT EXISTS order_items ( CREATE TABLE IF NOT EXISTS order_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
order_id INTEGER NOT NULL REFERENCES orders(id), order_id INTEGER NOT NULL REFERENCES orders(id),
product_id INTEGER NOT NULL REFERENCES products(id), product_id INTEGER NOT NULL REFERENCES products(id),
quantity INTEGER NOT NULL DEFAULT 1, quantity INTEGER NOT NULL DEFAULT 1 CHECK (quantity > 0),
unit_price REAL NOT NULL unit_price REAL NOT NULL,
PRIMARY KEY (order_id, product_id)
); );
INSERT OR IGNORE INTO users (id, name, email, role) VALUES
(1, 'Alice Johnson', 'alice@example.com', 'admin'), -- ── Big table for pagination / virtualization / filtering (500 rows) ────
(2, 'Bob Smith', 'bob@example.com', 'user'), CREATE TABLE IF NOT EXISTS audit_log (
(3, 'Carol Davis', 'carol@example.com', 'user'), id INTEGER PRIMARY KEY AUTOINCREMENT,
(4, 'Dan Wilson', 'dan@example.com', 'user'), user_id INTEGER REFERENCES users(id),
(5, 'Eve Martinez', 'eve@example.com', 'moderator'); action TEXT NOT NULL,
INSERT OR IGNORE INTO products (id, name, price, category, stock) VALUES entity_type TEXT NOT NULL,
(1, 'Wireless Mouse', 29.99, 'Electronics', 150), entity_id INTEGER,
(2, 'Mechanical Keyboard', 89.99, 'Electronics', 75), severity TEXT NOT NULL DEFAULT 'info'
(3, 'USB-C Hub', 34.99, 'Accessories', 200), CHECK (severity IN ('info','warning','error','critical')),
(4, '27\" 4K Monitor', 449.99, 'Electronics', 30), details json,
(5, 'Laptop Stand', 49.99, 'Accessories', 100), duration_ms INTEGER,
(6, 'Webcam 1080p', 59.99, 'Electronics', 60), created_at TEXT NOT NULL DEFAULT (datetime('now'))
(7, 'Desk Lamp LED', 39.99, 'Office', 120), );
(8, 'Ergonomic Chair', 599.99, 'Office', 15);
INSERT OR IGNORE INTO orders (id, user_id, total, status) VALUES -- ── BLOB demo ───────────────────────────────────────────────────────────
(1, 1, 119.98, 'completed'), CREATE TABLE IF NOT EXISTS files (
(2, 2, 484.98, 'pending'), id INTEGER PRIMARY KEY AUTOINCREMENT,
(3, 3, 59.99, 'completed'), name TEXT NOT NULL UNIQUE,
(4, 1, 89.99, 'shipped'), mime_type TEXT NOT NULL,
(5, 4, 689.98, 'pending'); content BLOB,
size_bytes INTEGER NOT NULL,
uploaded_by INTEGER REFERENCES users(id),
uploaded_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- ── No primary key on purpose: exercises the rowid row-locator editing ──
CREATE TABLE IF NOT EXISTS page_views (
url TEXT NOT NULL,
session_id TEXT NOT NULL,
user_agent TEXT,
viewed_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- ── Non-integer (TEXT) primary key ──────────────────────────────────────
CREATE TABLE IF NOT EXISTS app_settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- ── Deliberately empty: exercises the Empty Table change + empty state ──
CREATE TABLE IF NOT EXISTS marketing_campaigns (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
budget REAL,
starts_at TEXT,
ends_at TEXT,
status TEXT NOT NULL DEFAULT 'draft'
);
-- ── Read-only view for the query editor / ER diagram ────────────────────
CREATE VIEW IF NOT EXISTS order_summary AS
SELECT
o.id AS order_id,
u.name AS customer_name,
COUNT(oi.product_id) AS item_count,
o.total AS order_total,
o.status,
o.created_at
FROM orders o
JOIN users u ON u.id = o.user_id
LEFT JOIN order_items oi ON oi.order_id = o.id
GROUP BY o.id, u.name, o.total, o.status, o.created_at;
-- ── Indexes (surface in the copied DDL) ─────────────────────────────────
CREATE INDEX IF NOT EXISTS idx_orders_user_id ON orders(user_id);
CREATE INDEX IF NOT EXISTS idx_order_items_product_id ON order_items(product_id);
CREATE INDEX IF NOT EXISTS idx_audit_log_created_at ON audit_log(created_at);
CREATE INDEX IF NOT EXISTS idx_page_views_viewed_at ON page_views(viewed_at);
-- ═══════════════════════════════════════════════════════════════════════
-- Seed data
-- ═══════════════════════════════════════════════════════════════════════
INSERT OR IGNORE INTO users (id, name, email, role, phone, birth_date, bio, preferences, balance, is_active, last_login_at, created_at, updated_at) VALUES
(1, 'Alice Johnson', 'alice@example.com', 'admin', '+1-555-0101', '1990-04-12',
'Senior platform engineer and the store''s first admin. She manages the catalog, reviews every order before it ships, and keeps the demo data realistic.',
'{"theme":"dark","notifications":{"email":true,"push":false},"locale":"en-US"}',
249.50, 1, datetime('now','-2 hours'), datetime('now','-240 days'), datetime('now','-2 hours')),
(2, 'Bob Smith', 'bob@example.com', 'user', '+1-555-0102', '1985-11-30',
'Loyal customer since 2021. Prefers mechanical keyboards and 4K displays, and always opts for the extended warranty.',
'{"theme":"light","notifications":{"email":true,"push":true},"locale":"en-GB"}',
12.00, 1, datetime('now','-1 day'), datetime('now','-210 days'), datetime('now','-1 day')),
(3, 'Carol Davis', 'carol@example.com', 'user', NULL, '1998-07-19',
'Occasional shopper who mostly buys desk accessories for her home office in Seattle.',
NULL,
0.00, 1, datetime('now','-6 days'), datetime('now','-180 days'), datetime('now','-6 days')),
(4, 'Dan Wilson', 'dan@example.com', 'user', '+1-555-0104', NULL,
'Power user testing the checkout flow. Frequently leaves detailed feedback and files the occasional bug report.',
'{"theme":"system","notifications":{"email":false,"push":false},"locale":"de-DE"}',
87.25, 0, datetime('now','-14 days'), datetime('now','-90 days'), datetime('now','-14 days')),
(5, 'Eve Martinez', 'eve@example.com', 'moderator', '+1-555-0105', '1993-02-08',
'Moderator and community lead. Approves product reviews, helps with support tickets, and watches the audit log closely.',
'{"theme":"dark","notifications":{"email":true,"push":true},"locale":"fr-FR"}',
33.75, 1, datetime('now','-35 minutes'), datetime('now','-45 days'), datetime('now','-35 minutes'));
INSERT OR IGNORE INTO categories (id, name, parent_id, slug, sort_order, description, created_at) VALUES
(1, 'Electronics', NULL, 'electronics', 1, 'Gadgets, displays and peripherals.', datetime('now','-300 days')),
(2, 'Accessories', NULL, 'accessories', 2, 'Cables, hubs and add-ons.', datetime('now','-300 days')),
(3, 'Office', NULL, 'office', 3, 'Furniture and desk essentials.', datetime('now','-300 days')),
(4, 'Keyboards', 1, 'keyboards', 1, 'Mechanical and membrane keyboards.', datetime('now','-120 days')),
(5, 'Monitors', 1, 'monitors', 2, 'Displays from 24 to 32 inches.', datetime('now','-120 days'));
INSERT OR IGNORE INTO products (id, sku, name, description, price, category_id, stock, rating, discontinued, attributes, created_at, updated_at) VALUES
(1, 'SKU-WM-001', 'Wireless Mouse',
'A comfortable ambidextrous wireless mouse with silent click switches, 2.4 GHz dongle and Bluetooth 5.0, a 1600 DPI optical sensor, and a 12-month battery life on a single AA battery.',
29.99, 1, 150, 4.5, 0, '{"color":"Graphite","wireless":true,"dpi":1600}', datetime('now','-260 days'), datetime('now','-3 days')),
(2, 'SKU-MK-001', 'Mechanical Keyboard',
'Hot-swappable tenkeyless board with brown switches, per-key RGB backlighting, PBT double-shot keycaps and a CNC aluminium case. USB-C with a detachable braided cable.',
89.99, 4, 75, 4.8, 0, '{"layout":"US ANSI","switches":"brown","backlit":true}', datetime('now','-250 days'), datetime('now','-20 days')),
(3, 'SKU-HUB-001', 'USB-C Hub',
'Seven-port hub with 4K HDMI, 100 W power delivery passthrough, two USB-A 3.2 ports, SD/microSD slots and an aluminium body that stays cool.',
34.99, 2, 200, 4.2, 0, '{"ports":"7-in-1","power_delivery":"100W"}', datetime('now','-240 days'), datetime('now','-2 days')),
(4, 'SKU-MN-001', '27" 4K Monitor',
'27-inch IPS panel with 3840x2160 resolution, 60 Hz refresh, 99% sRGB coverage, USB-C upstream with 90 W charging and a fully adjustable stand.',
449.99, 5, 30, 4.6, 0, '{"resolution":"3840x2160","refresh_hz":60,"panel":"IPS"}', datetime('now','-230 days'), datetime('now','-15 days')),
(5, 'SKU-LS-001', 'Laptop Stand',
'Foldable aluminium stand with six height positions, ventilated design and soft silicone pads. Fits laptops from 12 to 16 inches.',
49.99, 2, 100, 3.9, 0, NULL, datetime('now','-220 days'), datetime('now','-40 days')),
(6, 'SKU-WC-001', 'Webcam 1080p',
'Full HD webcam with a privacy shutter, dual noise-reducing microphones and autofocus. Works with every major video call app out of the box.',
59.99, 1, 0, 4.0, 1, '{"resolution":"1920x1080","fps":30,"microphone":true}', datetime('now','-200 days'), datetime('now','-60 days')),
(7, 'SKU-DL-001', 'Desk Lamp LED',
'Dimmable LED desk lamp with adjustable colour temperature from 2700 K to 6500 K, a flexible neck and a built-in USB charging port.',
39.99, 3, 120, 4.3, 0, '{"color_temp":"2700-6500K","dimmable":true}', datetime('now','-190 days'), datetime('now','-9 days')),
(8, 'SKU-EC-001', 'Ergonomic Chair',
'Breathable mesh back, adjustable lumbar support, 4D armrests and a gas lift rated for up to 150 kg. Assembles in under twenty minutes.',
599.99, 3, 15, 4.7, 0, '{"material":"mesh","lumbar_support":true}', datetime('now','-180 days'), datetime('now','-30 days')),
(9, 'SKU-UC-001', 'USB-C Cable 2m',
'Braided USB-C to USB-C cable rated for 100 W charging and USB 3.2 data transfer. Tested for 10,000 bends.',
14.99, 2, 500, 4.1, 0, '{"length_m":2,"charging":"100W"}', datetime('now','-170 days'), datetime('now','-5 days')),
(10, 'SKU-MA-001', 'Monitor Arm',
'Single monitor arm with gas spring, 75/100 mm VESA mount, and 360 degree rotation. Supports monitors up to 9 kg.',
79.99, 3, 40, NULL, 0, '{"weight_capacity_kg":9,"vesa":"75/100"}', datetime('now','-160 days'), datetime('now','-12 days'));
INSERT OR IGNORE INTO addresses (id, user_id, label, street, city, zip, country, is_primary, created_at) VALUES
(1, 1, 'Home', '100 Market Street', 'San Francisco', '94105', 'USA', 1, datetime('now','-200 days')),
(2, 1, 'Work', '200 Mission Street', 'San Francisco', '94105', 'USA', 0, datetime('now','-180 days')),
(3, 2, 'Home', '300 Lakeshore Drive', 'Austin', '78701', 'USA', 1, datetime('now','-150 days')),
(4, 3, 'Home', '400 Maple Avenue', 'Seattle', '98101', 'USA', 1, datetime('now','-120 days')),
(5, 4, 'Home', '500 Park Boulevard', 'New York', '10001', 'USA', 1, datetime('now','-90 days')),
(6, 5, 'Home', '600 Cedar Lane', 'Denver', '80202', 'USA', 1, datetime('now','-60 days')),
(7, 3, 'Cabin', '700 Pine Road', 'Bend', '97701', 'USA', 0, datetime('now','-30 days')),
(8, 5, 'Work', '800 Pearl Street', 'Denver', '80202', 'USA', 0, datetime('now','-7 days'));
INSERT OR IGNORE INTO orders (id, user_id, shipping_address_id, total, status, notes, created_at, updated_at, shipped_at) VALUES
(1, 1, 1, 94.97, 'completed', 'Please leave the package at the front desk.', datetime('now','-40 days'), datetime('now','-38 days'), datetime('now','-38 days')),
(2, 2, 3, 499.98, 'pending', NULL, datetime('now','-3 days'), datetime('now','-2 hours'), NULL),
(3, 3, 4, 59.99, 'completed', NULL, datetime('now','-20 days'), datetime('now','-19 days'), datetime('now','-19 days')),
(4, 1, 2, 89.99, 'shipped', 'Gift wrap, please.', datetime('now','-2 days'), datetime('now','-1 day'), datetime('now','-1 day')),
(5, 4, 5, 689.96, 'processing', NULL, datetime('now','-1 day'), datetime('now','-5 hours'), NULL),
(6, 5, 6, 74.98, 'cancelled', 'Customer requested cancellation before dispatch.', datetime('now','-5 days'), datetime('now','-4 days'), NULL);
INSERT OR IGNORE INTO order_items (order_id, product_id, quantity, unit_price) VALUES INSERT OR IGNORE INTO order_items (order_id, product_id, quantity, unit_price) VALUES
(1, 1, 2, 29.99), (1, 1, 2, 29.99),
(1, 3, 1, 34.99), (1, 3, 1, 34.99),
@@ -218,6 +406,65 @@ fn get_demo_schema() -> String {
(3, 6, 1, 59.99), (3, 6, 1, 59.99),
(4, 2, 1, 89.99), (4, 2, 1, 89.99),
(5, 8, 1, 599.99), (5, 8, 1, 599.99),
(5, 1, 3, 29.99); (5, 1, 3, 29.99),
".to_string() (6, 7, 1, 39.99),
(6, 3, 1, 34.99);
-- 500 generated rows for pagination / virtualization / filter demos.
-- Guarded by NOT EXISTS so re-running the schema never duplicates rows.
WITH RECURSIVE seq(n) AS (
SELECT 1
UNION ALL
SELECT n + 1 FROM seq WHERE n < 500
)
INSERT INTO audit_log (user_id, action, entity_type, entity_id, severity, details, duration_ms, created_at)
SELECT
CASE WHEN n % 9 = 0 THEN NULL ELSE (n % 5) + 1 END,
CASE n % 6 WHEN 0 THEN 'login' WHEN 1 THEN 'page_view' WHEN 2 THEN 'update'
WHEN 3 THEN 'create' WHEN 4 THEN 'delete' ELSE 'export' END,
CASE n % 4 WHEN 0 THEN 'order' WHEN 1 THEN 'product' WHEN 2 THEN 'user' ELSE 'report' END,
(n % 40) + 1,
CASE n % 5 WHEN 0 THEN 'info' WHEN 1 THEN 'info' WHEN 2 THEN 'warning'
WHEN 3 THEN 'error' ELSE 'critical' END,
CASE WHEN n % 7 = 0 THEN NULL
ELSE '{"page":"/demo","retries":' || (n % 3) || ',"row":' || n || '}' END,
(n * 37) % 2000,
datetime('now', printf('-%d minutes', n * 7))
FROM seq
WHERE NOT EXISTS (SELECT 1 FROM audit_log);
INSERT OR IGNORE INTO files (id, name, mime_type, content, size_bytes, uploaded_by, uploaded_at) VALUES
(1, 'logo.png', 'image/png', X'89504E470D0A1A0A0000000D49484452', length(X'89504E470D0A1A0A0000000D49484452'), 1, datetime('now','-10 days')),
(2, 'photo.jpg', 'image/jpeg', X'FFD8FFE000104A464946000101', length(X'FFD8FFE000104A464946000101'), 2, datetime('now','-9 days')),
(3, 'manual.pdf', 'application/pdf', X'255044462D312E340A25E2E3CFD3', length(X'255044462D312E340A25E2E3CFD3'), NULL, datetime('now','-5 days')),
(4, 'archive.zip', 'application/zip', NULL, 0, 3, datetime('now','-1 day')),
(5, 'report.csv', 'text/csv', X'69642C6E616D650A312C616C696365', length(X'69642C6E616D650A312C616C696365'), 1, datetime('now','-4 hours'));
INSERT INTO page_views (url, session_id, user_agent, viewed_at) SELECT * FROM (
VALUES
('/products', 'sess-001', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/126.0', datetime('now','-23 hours')),
('/products/1', 'sess-001', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/126.0', datetime('now','-22 hours')),
('/cart', 'sess-002', 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_5) AppleWebKit/605.1.15 Safari/604.1', datetime('now','-18 hours')),
('/checkout', 'sess-002', 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_5) AppleWebKit/605.1.15 Safari/604.1', datetime('now','-18 hours')),
('/orders', 'sess-003', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) Firefox/127.0', datetime('now','-12 hours')),
('/products/8', 'sess-004', 'Mozilla/5.0 (X11; Linux x86_64) Chrome/125.0', datetime('now','-8 hours')),
('/login', 'sess-004', 'Mozilla/5.0 (X11; Linux x86_64) Chrome/125.0', datetime('now','-8 hours')),
('/settings', 'sess-001', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/126.0', datetime('now','-5 hours')),
('/categories/electronics', 'sess-005', 'curl/8.4.0', datetime('now','-3 hours')),
('/products/4', 'sess-002', 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_5) AppleWebKit/605.1.15 Safari/604.1', datetime('now','-2 hours')),
('/checkout', 'sess-006', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/126.0', datetime('now','-1 hour')),
('/orders', 'sess-002', 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_5) AppleWebKit/605.1.15 Safari/604.1', datetime('now','-30 minutes'))
) WHERE NOT EXISTS (SELECT 1 FROM page_views);
INSERT OR IGNORE INTO app_settings (key, value, updated_at) VALUES
('site_name', 'Gridline Demo Store', datetime('now','-30 days')),
('maintenance_mode', 'false', datetime('now','-2 days')),
('max_cart_items', '50', datetime('now','-14 days')),
('currency', 'USD', datetime('now','-30 days'));
"#
.to_string()
} }
#[cfg(test)]
#[path = "demo.test.rs"]
mod tests;
+246
View File
@@ -0,0 +1,246 @@
use super::*;
/// Open an in-memory SQLite database seeded with the demo schema.
fn seed_demo() -> rusqlite::Connection {
let conn = rusqlite::Connection::open_in_memory().unwrap();
conn.execute_batch(&get_demo_schema()).unwrap();
conn
}
/// Count rows in a table/view.
fn count(conn: &rusqlite::Connection, table: &str) -> i64 {
conn.query_row(&format!("SELECT COUNT(*) FROM {table}"), [], |r| r.get(0))
.unwrap()
}
#[test]
fn demo_schema_creates_expected_objects() {
let conn = seed_demo();
let mut stmt = conn
.prepare("SELECT name, type FROM sqlite_master WHERE type IN ('table','view') AND name NOT LIKE 'sqlite_%' ORDER BY name")
.unwrap();
let objects: Vec<(String, String)> = stmt
.query_map([], |row| Ok((row.get(0)?, row.get(1)?)))
.unwrap()
.filter_map(|r| r.ok())
.collect();
let expected: Vec<(&str, &str)> = vec![
("addresses", "table"),
("app_settings", "table"),
("audit_log", "table"),
("categories", "table"),
("files", "table"),
("marketing_campaigns", "table"),
("order_items", "table"),
("order_summary", "view"),
("orders", "table"),
("page_views", "table"),
("products", "table"),
("users", "table"),
];
assert_eq!(
objects,
expected.into_iter().map(|(n, t)| (n.to_string(), t.to_string())).collect::<Vec<_>>(),
"demo schema must contain exactly the expected tables + view"
);
}
#[test]
fn demo_schema_seeds_expected_rows() {
let conn = seed_demo();
assert_eq!(count(&conn, "users"), 5);
assert_eq!(count(&conn, "categories"), 5);
assert_eq!(count(&conn, "products"), 10);
assert_eq!(count(&conn, "addresses"), 8);
assert_eq!(count(&conn, "orders"), 6);
assert_eq!(count(&conn, "order_items"), 10);
assert_eq!(count(&conn, "audit_log"), 500, "audit_log must seed 500 rows for pagination/virtualization demos");
assert_eq!(count(&conn, "files"), 5);
assert_eq!(count(&conn, "page_views"), 12);
assert_eq!(count(&conn, "app_settings"), 4);
assert_eq!(count(&conn, "marketing_campaigns"), 0, "marketing_campaigns must stay empty to demo the Empty Table change");
assert_eq!(count(&conn, "order_summary"), 6, "view must return one row per order");
}
#[test]
fn demo_schema_is_idempotent() {
let conn = seed_demo();
// Re-running the full schema (e.g. on a fresh file after a partial seed)
// must not duplicate rows.
conn.execute_batch(&get_demo_schema()).unwrap();
assert_eq!(count(&conn, "users"), 5);
assert_eq!(count(&conn, "audit_log"), 500);
assert_eq!(count(&conn, "page_views"), 12);
assert_eq!(count(&conn, "products"), 10);
}
#[test]
fn demo_schema_sets_user_version() {
let conn = seed_demo();
let v: i64 = conn
.query_row("PRAGMA user_version", [], |r| r.get(0))
.unwrap();
assert_eq!(v, DEMO_SCHEMA_VERSION, "demo file must stamp PRAGMA user_version for upgrade detection");
}
#[test]
fn demo_json_columns_hold_valid_json() {
let conn = seed_demo();
// Every non-NULL value in a `json`-declared column must parse as JSON so
// the grid's JSON popover can format it.
for (table, column) in [("users", "preferences"), ("products", "attributes"), ("audit_log", "details")] {
let mut stmt = conn
.prepare(&format!(
"SELECT {column} FROM {table} WHERE {column} IS NOT NULL"
))
.unwrap();
let values: Vec<String> = stmt
.query_map([], |row| row.get::<_, String>(0))
.unwrap()
.filter_map(|r| r.ok())
.collect();
assert!(!values.is_empty(), "{table}.{column} should have non-null values");
for v in &values {
assert!(
serde_json::from_str::<serde_json::Value>(v).is_ok(),
"{table}.{column} must hold valid JSON, got: {v}"
);
}
}
// And the declared type must be lowercase `json` so the frontend's
// `data_type === "json"` check triggers the JSON cell popover.
for (table, column) in [("users", "preferences"), ("products", "attributes"), ("audit_log", "details")] {
let dt: String = conn
.query_row(
&format!("SELECT type FROM pragma_table_info('{table}') WHERE name = '{column}'"),
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(dt, "json", "{table}.{column} must be declared lowercase json");
}
}
#[test]
fn demo_view_is_queryable() {
let conn = seed_demo();
let mut stmt = conn.prepare("SELECT order_id, customer_name, item_count, order_total, status FROM order_summary ORDER BY order_id").unwrap();
let rows: Vec<(i64, String, i64, f64, String)> = stmt
.query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?, row.get(4)?)))
.unwrap()
.filter_map(|r| r.ok())
.collect();
assert_eq!(rows.len(), 6);
let first = &rows[0];
assert_eq!(first.0, 1);
assert_eq!(first.1, "Alice Johnson");
assert_eq!(first.2, 2, "order 1 should have 2 line items");
assert_eq!(first.4, "completed");
}
#[test]
fn demo_foreign_keys_are_consistent() {
let conn = seed_demo();
// PRAGMA foreign_key_check reports any orphaned child rows.
let violations: Vec<(String, i64, String, i64)> = conn
.prepare("PRAGMA foreign_key_check")
.unwrap()
.query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)))
.unwrap()
.filter_map(|r| r.ok())
.collect();
assert!(
violations.is_empty(),
"seed data must satisfy every FK: {violations:?}"
);
// audit_log.user_id must be NULL or reference an existing user.
let orphans: i64 = conn
.query_row(
"SELECT COUNT(*) FROM audit_log WHERE user_id IS NOT NULL AND user_id NOT IN (SELECT id FROM users)",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(orphans, 0);
}
#[test]
fn demo_tables_exercise_key_constraint_shapes() {
let conn = seed_demo();
// order_items: composite PRIMARY KEY across two columns. SQLite reports
// pk as the 1-based position within the key, so both columns are > 0.
let composite: Vec<(String, bool)> = conn
.prepare("SELECT name, pk FROM pragma_table_info('order_items') WHERE pk > 0 ORDER BY pk")
.unwrap()
.query_map([], |row| Ok((row.get(0)?, row.get(1)?)))
.unwrap()
.filter_map(|r| r.ok())
.collect();
assert_eq!(
composite.iter().map(|(n, _)| n.as_str()).collect::<Vec<_>>(),
vec!["order_id", "product_id"]
);
// page_views: no primary key at all (rowid row-locator editing demo).
let pk_cols: i64 = conn
.query_row("SELECT COUNT(*) FROM pragma_table_info('page_views') WHERE pk > 0", [], |r| r.get(0))
.unwrap();
assert_eq!(pk_cols, 0, "page_views must have no PK so the rowid locator kicks in");
// app_settings: TEXT primary key.
let text_pk: String = conn
.query_row(
"SELECT name FROM pragma_table_info('app_settings') WHERE pk > 0",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(text_pk, "key");
// categories: self-referencing FK.
let self_fk: i64 = conn
.query_row(
"SELECT COUNT(*) FROM pragma_foreign_key_list('categories') WHERE \"table\" = 'categories'",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(self_fk, 1);
}
#[test]
fn demo_file_is_recreated_when_stale() {
// A file stamped with an older user_version must be recreated by
// ensure_demo_file; a current file must be left untouched.
let dir = std::env::temp_dir().join(format!("gridline-demo-test-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("demo.db");
// Stale file: old version, old schema.
let stale = rusqlite::Connection::open(&path).unwrap();
stale.execute_batch("PRAGMA user_version = 1; CREATE TABLE legacy (id INTEGER PRIMARY KEY);")
.unwrap();
drop(stale);
ensure_demo_file(&path).unwrap();
let conn = rusqlite::Connection::open(&path).unwrap();
let v: i64 = conn.query_row("PRAGMA user_version", [], |r| r.get(0)).unwrap();
assert_eq!(v, DEMO_SCHEMA_VERSION, "stale demo file must be recreated with the current version");
assert_eq!(count(&conn, "users"), 5, "recreated file must be fully seeded");
drop(conn);
// Current file: ensure_demo_file must not touch it.
ensure_demo_file(&path).unwrap();
let conn = rusqlite::Connection::open(&path).unwrap();
assert_eq!(count(&conn, "users"), 5, "current demo file must not be reseeded");
// Regenerate flow: the file is deleted entirely, then recreated from
// scratch with the current schema (what `regenerate_demo_db` does).
drop(conn);
std::fs::remove_file(&path).unwrap();
ensure_demo_file(&path).unwrap();
let conn = rusqlite::Connection::open(&path).unwrap();
let v: i64 = conn.query_row("PRAGMA user_version", [], |r| r.get(0)).unwrap();
assert_eq!(v, DEMO_SCHEMA_VERSION);
assert_eq!(count(&conn, "users"), 5);
assert_eq!(count(&conn, "audit_log"), 500);
std::fs::remove_dir_all(&dir).ok();
}
+1
View File
@@ -123,6 +123,7 @@ pub fn run() {
keychain::get_connection_ssh_passphrase, keychain::get_connection_ssh_passphrase,
keychain::delete_connection_ssh_passphrase, keychain::delete_connection_ssh_passphrase,
demo::recreate_demo_db, demo::recreate_demo_db,
demo::regenerate_demo_db,
backup::detect_pg_tools, backup::detect_pg_tools,
backup::pg_dump, backup::pg_dump,
backup::pg_restore, backup::pg_restore,
+46 -2
View File
@@ -1,11 +1,14 @@
import { useSettingsStore } from "../../stores/settingsStore"; import { useSettingsStore } from "../../stores/settingsStore";
import { useConnectionStore } from "../../stores/connectionStore"; import { useConnectionStore } from "../../stores/connectionStore";
import { useNotificationStore } from "../../stores/notificationStore";
import { Select } from "../ui/Select"; import { Select } from "../ui/Select";
import { ThemePicker } from "../ui/ThemePicker"; import { ThemePicker } from "../ui/ThemePicker";
import { AccentPicker } from "../ui/AccentPicker"; import { AccentPicker } from "../ui/AccentPicker";
import { SettingsRow } from "../ui/SettingsRow"; import { SettingsRow } from "../ui/SettingsRow";
import { ConfirmDialog } from "../ui/ConfirmDialog";
import * as cmd from "../../lib/commands"; import * as cmd from "../../lib/commands";
import type { FontSize } from "../../lib/types"; import type { FontSize } from "../../lib/types";
import { useState } from "react";
const FONT_SIZE_OPTIONS: { value: FontSize; label: string }[] = [ const FONT_SIZE_OPTIONS: { value: FontSize; label: string }[] = [
{ value: "small", label: "Small" }, { value: "small", label: "Small" },
@@ -32,6 +35,10 @@ const PAGE_SIZE_OPTIONS = [
export function GeneralSettingsTab() { export function GeneralSettingsTab() {
const { settings, updateSetting, load } = useSettingsStore(); const { settings, updateSetting, load } = useSettingsStore();
const folders = useConnectionStore((s) => s.folders); const folders = useConnectionStore((s) => s.folders);
const loadAll = useConnectionStore((s) => s.loadAll);
const notify = useNotificationStore((s) => s.notify);
const [confirmRegenerate, setConfirmRegenerate] = useState(false);
const [regenerating, setRegenerating] = useState(false);
if (!settings) return null; if (!settings) return null;
@@ -42,10 +49,26 @@ export function GeneralSettingsTab() {
const handleReAddDemo = async () => { const handleReAddDemo = async () => {
try { try {
await cmd.recreateDemoDb(); const msg = await cmd.recreateDemoDb();
await load(); await load();
await loadAll();
notify(msg, "success");
} catch (e) { } catch (e) {
// ignore notify(e instanceof Error ? e.message : String(e), "error");
}
};
const handleRegenerateDemo = async () => {
setConfirmRegenerate(false);
setRegenerating(true);
try {
const msg = await cmd.regenerateDemoDb();
await loadAll();
notify(msg, "success");
} catch (e) {
notify(e instanceof Error ? e.message : String(e), "error");
} finally {
setRegenerating(false);
} }
}; };
@@ -140,7 +163,28 @@ export function GeneralSettingsTab() {
Re-add demo Re-add demo
</button> </button>
</SettingsRow> </SettingsRow>
<SettingsRow
title="Regenerate demo database"
description="Reset the demo to its original state. Any edits or changes you made against the demo are lost."
>
<button
type="button"
onClick={() => setConfirmRegenerate(true)}
disabled={regenerating}
className="rounded-lg border border-red-500/30 px-3 py-1.5 text-sm text-red-300 hover:bg-red-500/10 hover:text-red-200 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{regenerating ? "Regenerating…" : "Regenerate demo"}
</button>
</SettingsRow>
</div> </div>
<ConfirmDialog
open={confirmRegenerate}
title="Regenerate demo database?"
message="This deletes the current demo file and re-seeds it with fresh data. Any edits or changes you made against the demo will be lost."
confirmLabel="Regenerate"
onConfirm={handleRegenerateDemo}
onCancel={() => setConfirmRegenerate(false)}
/>
</section> </section>
</div> </div>
); );
+4
View File
@@ -76,6 +76,10 @@ export async function recreateDemoDb(): Promise<string> {
return invoke<string>("recreate_demo_db"); return invoke<string>("recreate_demo_db");
} }
export async function regenerateDemoDb(): Promise<string> {
return invoke<string>("regenerate_demo_db");
}
// ─── DB Viewer Lifecycle ──────────────────────────────────────── // ─── DB Viewer Lifecycle ────────────────────────────────────────
export async function dbConnect(connectionId: string, input: ConnectionInput): Promise<void> { export async function dbConnect(connectionId: string, input: ConnectionInput): Promise<void> {