Page /trash alignée sur ce que fait ce type de section :
- sélection multiple (Select all porté au filtre courant) + barre d'actions
groupées Restore / Delete / Clear ;
- Empty Trash via POST /board/api/trash/empty (purge en masse, 1 requête) ;
- tri Recently deleted / Oldest first / Name et filtre emplacement RÉELS —
remplacent les deux boutons décoratifs « Last edited by ▾ » et « In ▾ » qui
ne faisaient rien ;
- date de suppression + jours restants par élément (rétention 30 j alignée sur
app/services/trash.py, horodatages UTC/ISO gérés) ;
- compteur de résultats, états vides distincts (vide vs filtre sans résultat +
Clear filters), toasts sur chaque action.
Bugs trouvés en route et corrigés :
1. Restore/Delete de la page ne marchaient JAMAIS : getCsrf() renvoie la chaîne
du jeton mais le code faisait csrf?.[1] → 2e caractère → 403 CSRF silencieux
avalé par if (r.ok).
2. « Move to Trash » de l'éditeur = 404 permanent : route appelée
/board/api/pages/{id}/trash (inexistante) alors que la route réelle est
/api/pages/{id}/trash, et le .then() naviguait quand même → la page partait
à l'accueil SANS être mise à la poubelle. URL corrigée + r.ok vérifié.
3. Page restaurée invisible en Library/Recents/Private : la suppression éditeur
réécrivait parent_section='Trash' et la restauration ne le remettait pas,
alors que tous les listings filtrent parent_section != 'Trash'. Écriture
retirée (deleted_at = source de vérité unique) + réparation idempotente au
boot dans db.init_db + requête sidebar /trash alignée sur deleted_at.
4. Routes trash sans aucune authentification (le CSRF ne protège pas : cookie
lisible + en-tête forgé) : 401 ajouté sur list/restore/delete/empty et sur
POST /api/pages/{id}/trash — vérifié anonyme → 401.
Gates : tests/test_trash_api.py (5) · e2e/trash_ui.spec.js (1, avec garde « on
ne vide jamais la poubelle d'autrui ») · pytest 1099 passed · ruff OK ·
e2e probe_nav_perf + partial_nav + editor_mount + logout_dnd + smoke = 10 passed ·
OpenAPI 510 chemins / 7.45.5.
859 lines
40 KiB
Python
859 lines
40 KiB
Python
"""FlowDeck — Database layer (SQLite with WAL mode)."""
|
|
from __future__ import annotations
|
|
|
|
import sqlite3
|
|
from contextlib import contextmanager
|
|
from pathlib import Path
|
|
|
|
|
|
def get_db_path() -> Path:
|
|
from app.config import settings
|
|
return settings.db_path
|
|
|
|
|
|
def init_db():
|
|
"""Create tables if they don't exist."""
|
|
db_path = get_db_path()
|
|
if str(db_path) != ":memory:":
|
|
db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
with get_conn() as conn:
|
|
conn.execute("PRAGMA journal_mode=WAL")
|
|
conn.execute("PRAGMA foreign_keys=ON")
|
|
conn.executescript("""
|
|
CREATE TABLE IF NOT EXISTS users (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
login TEXT NOT NULL UNIQUE,
|
|
full_name TEXT NOT NULL DEFAULT '',
|
|
email TEXT NOT NULL DEFAULT '',
|
|
avatar_url TEXT NOT NULL DEFAULT '',
|
|
avatar_color TEXT NOT NULL DEFAULT '#3A3A3A',
|
|
password_hash TEXT,
|
|
is_admin INTEGER NOT NULL DEFAULT 0,
|
|
is_active INTEGER NOT NULL DEFAULT 1,
|
|
last_login TIMESTAMP,
|
|
login_attempts INTEGER NOT NULL DEFAULT 0,
|
|
locked_until TIMESTAMP,
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS login_history (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
user_id INTEGER NOT NULL REFERENCES users(id),
|
|
ip_address TEXT DEFAULT '',
|
|
user_agent TEXT DEFAULT '',
|
|
logged_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS user_tokens (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
gitea_user_id INTEGER NOT NULL UNIQUE,
|
|
gitea_token TEXT NOT NULL,
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS user_oauth_tokens (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
user_id INTEGER NOT NULL REFERENCES users(id),
|
|
provider TEXT NOT NULL,
|
|
access_token TEXT NOT NULL,
|
|
refresh_token TEXT,
|
|
expires_at TIMESTAMP,
|
|
instance_url TEXT DEFAULT '',
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
UNIQUE(user_id, provider)
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS boards (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
project_owner TEXT NOT NULL,
|
|
project_name TEXT NOT NULL,
|
|
columns_json TEXT NOT NULL DEFAULT '["Backlog","À faire","En cours","Révision","Terminé"]',
|
|
wip_limits_json TEXT DEFAULT '{}',
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
UNIQUE(project_owner, project_name)
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS cards (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
board_id INTEGER NOT NULL REFERENCES boards(id),
|
|
gitea_issue_id INTEGER,
|
|
column_name TEXT NOT NULL DEFAULT 'Backlog',
|
|
position INTEGER NOT NULL DEFAULT 0,
|
|
priority TEXT DEFAULT 'Medium',
|
|
due_date TEXT,
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS notes (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
project_owner TEXT NOT NULL,
|
|
project_name TEXT NOT NULL,
|
|
title TEXT NOT NULL DEFAULT 'Notes',
|
|
content TEXT NOT NULL DEFAULT '',
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
UNIQUE(project_owner, project_name, title)
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS col_mapping (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
board_id INTEGER NOT NULL REFERENCES boards(id),
|
|
column_name TEXT NOT NULL,
|
|
gitea_label TEXT NOT NULL,
|
|
close_issue BOOLEAN NOT NULL DEFAULT 0,
|
|
UNIQUE(board_id, column_name)
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS checklists (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
board_id INTEGER NOT NULL,
|
|
gitea_issue_id INTEGER NOT NULL,
|
|
title TEXT NOT NULL DEFAULT 'Checklist',
|
|
position INTEGER NOT NULL DEFAULT 0,
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
UNIQUE(board_id, gitea_issue_id, title)
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS checklist_items (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
checklist_id INTEGER NOT NULL REFERENCES checklists(id) ON DELETE CASCADE,
|
|
content TEXT NOT NULL DEFAULT '',
|
|
checked BOOLEAN NOT NULL DEFAULT 0,
|
|
position INTEGER NOT NULL DEFAULT 0,
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS project_properties (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
project_owner TEXT NOT NULL,
|
|
project_name TEXT NOT NULL,
|
|
name TEXT NOT NULL,
|
|
prop_type TEXT NOT NULL DEFAULT 'select',
|
|
options_json TEXT DEFAULT '[]',
|
|
position INTEGER NOT NULL DEFAULT 0,
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
UNIQUE(project_owner, project_name, name)
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS property_values (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
property_id INTEGER NOT NULL REFERENCES project_properties(id) ON DELETE CASCADE,
|
|
gitea_issue_id INTEGER NOT NULL,
|
|
value TEXT NOT NULL DEFAULT '',
|
|
UNIQUE(property_id, gitea_issue_id)
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS ai_keywords (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
project_owner TEXT NOT NULL,
|
|
project_name TEXT NOT NULL,
|
|
keyword TEXT NOT NULL,
|
|
color TEXT NOT NULL DEFAULT '#787774',
|
|
usage_count INTEGER NOT NULL DEFAULT 1,
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
UNIQUE(project_owner, project_name, keyword)
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS pages (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
workspace TEXT NOT NULL,
|
|
workspace_id INTEGER REFERENCES workspaces(id),
|
|
title TEXT NOT NULL DEFAULT 'New page',
|
|
content TEXT NOT NULL DEFAULT '',
|
|
parent_section TEXT DEFAULT 'Private',
|
|
parent_id INTEGER REFERENCES pages(id),
|
|
share_mode TEXT DEFAULT 'private',
|
|
published INTEGER DEFAULT 0,
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
);
|
|
|
|
-- v1.3.0: Database Concept — Notion-style collections
|
|
CREATE TABLE IF NOT EXISTS collections (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
name TEXT NOT NULL,
|
|
description TEXT DEFAULT '',
|
|
icon TEXT DEFAULT '📋',
|
|
schema_json TEXT NOT NULL DEFAULT '[]',
|
|
gitea_owner TEXT,
|
|
gitea_repo TEXT,
|
|
is_locked BOOLEAN NOT NULL DEFAULT 0,
|
|
is_inline BOOLEAN NOT NULL DEFAULT 0,
|
|
parent_page_id INTEGER,
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS collection_pages (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
collection_id INTEGER NOT NULL REFERENCES collections(id) ON DELETE CASCADE,
|
|
title TEXT NOT NULL DEFAULT '',
|
|
icon TEXT DEFAULT 'file',
|
|
position INTEGER NOT NULL DEFAULT 0,
|
|
parent_id INTEGER REFERENCES collection_pages(id),
|
|
gitea_issue_id INTEGER,
|
|
gitea_issue_number INTEGER,
|
|
property_values_json TEXT NOT NULL DEFAULT '{}',
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_cp_collection ON collection_pages(collection_id, position);
|
|
CREATE INDEX IF NOT EXISTS idx_cp_parent ON collection_pages(parent_id);
|
|
CREATE INDEX IF NOT EXISTS idx_cp_gitea ON collection_pages(gitea_issue_id);
|
|
|
|
CREATE TABLE IF NOT EXISTS collection_views (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
collection_id INTEGER NOT NULL REFERENCES collections(id) ON DELETE CASCADE,
|
|
name TEXT NOT NULL DEFAULT 'Default View',
|
|
view_type TEXT NOT NULL DEFAULT 'table',
|
|
config_json TEXT NOT NULL DEFAULT '{}',
|
|
position INTEGER NOT NULL DEFAULT 0,
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
);
|
|
|
|
-- v1.4.0: Propriétés Avancées — collection_properties (remplace project_properties)
|
|
CREATE TABLE IF NOT EXISTS collection_properties (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
collection_id INTEGER NOT NULL REFERENCES collections(id) ON DELETE CASCADE,
|
|
name TEXT NOT NULL,
|
|
prop_type TEXT NOT NULL DEFAULT 'text',
|
|
options_json TEXT DEFAULT '[]',
|
|
number_format TEXT DEFAULT 'number',
|
|
related_collection_id INTEGER REFERENCES collections(id) ON DELETE SET NULL,
|
|
reverse_name TEXT,
|
|
relation_property_id INTEGER REFERENCES collection_properties(id) ON DELETE SET NULL,
|
|
target_property_id INTEGER REFERENCES collection_properties(id) ON DELETE SET NULL,
|
|
rollup_function TEXT,
|
|
formula_expression TEXT,
|
|
position INTEGER NOT NULL DEFAULT 0,
|
|
required BOOLEAN NOT NULL DEFAULT 0,
|
|
visible_in_views BOOLEAN NOT NULL DEFAULT 1,
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
UNIQUE(collection_id, name)
|
|
);
|
|
|
|
-- v2.0.0: Multi-user — workspaces, members, comments, history, favorites, templates
|
|
CREATE TABLE IF NOT EXISTS workspaces (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
name TEXT NOT NULL,
|
|
owner_id INTEGER NOT NULL REFERENCES users(id),
|
|
settings_json TEXT NOT NULL DEFAULT '{}',
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS workspace_members (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
workspace_id INTEGER NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
|
|
user_id INTEGER NOT NULL REFERENCES users(id),
|
|
role TEXT NOT NULL DEFAULT 'editor',
|
|
joined_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
UNIQUE(workspace_id, user_id)
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS comments (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
page_id INTEGER NOT NULL REFERENCES collection_pages(id) ON DELETE CASCADE,
|
|
user_id INTEGER NOT NULL REFERENCES users(id),
|
|
body TEXT NOT NULL DEFAULT '',
|
|
parent_id INTEGER REFERENCES comments(id),
|
|
resolved BOOLEAN NOT NULL DEFAULT 0,
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS page_history (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
page_id INTEGER NOT NULL REFERENCES collection_pages(id) ON DELETE CASCADE,
|
|
user_id INTEGER REFERENCES users(id),
|
|
change_type TEXT NOT NULL,
|
|
snapshot_json TEXT NOT NULL DEFAULT '{}',
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
);
|
|
|
|
-- v2.2.0: Migrate favorites — drop old schema referencing collection_pages
|
|
DROP TABLE IF EXISTS favorites;
|
|
|
|
CREATE TABLE IF NOT EXISTS favorites (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
user_id INTEGER NOT NULL REFERENCES users(id),
|
|
page_id INTEGER NOT NULL REFERENCES pages(id),
|
|
position INTEGER NOT NULL DEFAULT 0,
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
UNIQUE(user_id, page_id)
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS database_templates (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
name TEXT NOT NULL,
|
|
description TEXT DEFAULT '',
|
|
schema_json TEXT NOT NULL DEFAULT '[]',
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS page_templates (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
collection_id INTEGER NOT NULL REFERENCES collections(id) ON DELETE CASCADE,
|
|
name TEXT NOT NULL DEFAULT 'Default',
|
|
property_values_json TEXT NOT NULL DEFAULT '{}',
|
|
content_json TEXT DEFAULT '[]',
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
);
|
|
|
|
-- v2.2.0: Tags system
|
|
CREATE TABLE IF NOT EXISTS tags (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
name TEXT NOT NULL,
|
|
color TEXT DEFAULT '#787774',
|
|
user_id INTEGER NOT NULL DEFAULT 0 REFERENCES users(id),
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
UNIQUE(name, user_id)
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS page_tags (
|
|
page_id INTEGER NOT NULL REFERENCES pages(id) ON DELETE CASCADE,
|
|
tag_id INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
PRIMARY KEY (page_id, tag_id)
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_pt_page ON page_tags(page_id);
|
|
CREATE INDEX IF NOT EXISTS idx_pt_tag ON page_tags(tag_id);
|
|
""")
|
|
# Migration: add parent_id if missing (v1.0.0+)
|
|
try:
|
|
conn.execute("ALTER TABLE pages ADD COLUMN parent_id INTEGER REFERENCES pages(id)")
|
|
except sqlite3.OperationalError:
|
|
pass
|
|
# Migration: add avatar_color (v2.3.0+)
|
|
try:
|
|
conn.execute("ALTER TABLE users ADD COLUMN avatar_color TEXT NOT NULL DEFAULT '#3A3A3A'")
|
|
except sqlite3.OperationalError:
|
|
pass
|
|
# Migration: add sort_order for drag & drop tree reordering (v1.1.0+)
|
|
try:
|
|
conn.execute("ALTER TABLE pages ADD COLUMN sort_order INTEGER NOT NULL DEFAULT 0")
|
|
except sqlite3.OperationalError:
|
|
pass
|
|
# Migration: add content_format for Notion-style block editor (v1.2.0+)
|
|
# 'markdown' = legacy textarea, 'blocks' = JSON array of block objects
|
|
try:
|
|
conn.execute("ALTER TABLE pages ADD COLUMN content_format TEXT NOT NULL DEFAULT 'markdown'")
|
|
except sqlite3.OperationalError:
|
|
pass
|
|
# Migration: add deleted_at for trash/soft-delete
|
|
try:
|
|
conn.execute("ALTER TABLE pages ADD COLUMN deleted_at TEXT")
|
|
except sqlite3.OperationalError:
|
|
pass
|
|
# v7.45.5 : réparation des pages restaurées marquées 'Trash' — l'ancien
|
|
# soft-delete (éditeur) réécrivait parent_section='Trash' et la
|
|
# restauration ne le remettait pas → page invisible en Library/Recents/
|
|
# Private. Idempotent (WHERE restrictif), rejoué à chaque boot.
|
|
# ponytail: un dossier restauré repasse en 'Private' (l'état d'origine
|
|
# n'est pas stocké) → icône/dossier à remettre à 'Workspace' si besoin.
|
|
conn.execute(
|
|
"UPDATE pages SET parent_section='Private' "
|
|
"WHERE parent_section='Trash' AND deleted_at IS NULL"
|
|
)
|
|
try:
|
|
conn.execute("ALTER TABLE pages ADD COLUMN share_mode TEXT DEFAULT 'private'")
|
|
except sqlite3.OperationalError:
|
|
pass
|
|
try:
|
|
conn.execute("ALTER TABLE pages ADD COLUMN published INTEGER DEFAULT 0")
|
|
except sqlite3.OperationalError:
|
|
pass
|
|
try:
|
|
conn.execute("ALTER TABLE pages ADD COLUMN workspace_id INTEGER REFERENCES workspaces(id)")
|
|
except sqlite3.OperationalError:
|
|
pass
|
|
# v2.2: Auth locale
|
|
for col in ["password_hash", "is_active", "last_login", "login_attempts", "locked_until"]:
|
|
try:
|
|
conn.execute(f"ALTER TABLE users ADD COLUMN {col} {'TEXT' if col in ('password_hash','last_login','locked_until') else 'INTEGER NOT NULL DEFAULT ' + ('1' if col=='is_active' else '0')}")
|
|
except sqlite3.OperationalError:
|
|
pass
|
|
# v2.5: Login history
|
|
try:
|
|
conn.execute("ALTER TABLE users ADD COLUMN is_admin INTEGER NOT NULL DEFAULT 0")
|
|
except sqlite3.OperationalError:
|
|
pass
|
|
try:
|
|
conn.execute("""CREATE TABLE IF NOT EXISTS login_history (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
user_id INTEGER NOT NULL REFERENCES users(id),
|
|
ip_address TEXT DEFAULT '',
|
|
user_agent TEXT DEFAULT '',
|
|
logged_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
)""")
|
|
except sqlite3.OperationalError:
|
|
pass
|
|
# v2.6: Tags per-user
|
|
try:
|
|
conn.execute("ALTER TABLE tags ADD COLUMN user_id INTEGER NOT NULL DEFAULT 0 REFERENCES users(id)")
|
|
except sqlite3.OperationalError:
|
|
pass
|
|
try:
|
|
# Recreate UNIQUE constraint for per-user tags
|
|
conn.execute("CREATE TABLE IF NOT EXISTS tags_new (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, color TEXT DEFAULT '#787774', user_id INTEGER NOT NULL DEFAULT 0 REFERENCES users(id), created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, UNIQUE(name, user_id))")
|
|
conn.execute("INSERT OR IGNORE INTO tags_new (id, name, color, user_id, created_at) SELECT id, name, color, COALESCE(user_id,0), created_at FROM tags")
|
|
conn.execute("DROP TABLE tags")
|
|
conn.execute("ALTER TABLE tags_new RENAME TO tags")
|
|
except sqlite3.OperationalError:
|
|
pass
|
|
conn.commit()
|
|
# v2.7: Private pages for Gitea workspaces
|
|
conn.execute("""
|
|
CREATE TABLE IF NOT EXISTS gitea_private_pages (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
user_id INTEGER NOT NULL REFERENCES users(id),
|
|
gitea_owner TEXT NOT NULL,
|
|
gitea_repo TEXT NOT NULL,
|
|
title TEXT NOT NULL DEFAULT 'Untitled',
|
|
content TEXT DEFAULT '',
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
)
|
|
""")
|
|
conn.commit()
|
|
|
|
# ── v4.0: Account & Integrations ──
|
|
# 1) auth_method on users
|
|
try:
|
|
conn.execute("ALTER TABLE users ADD COLUMN auth_method TEXT DEFAULT 'local'")
|
|
except sqlite3.OperationalError:
|
|
pass
|
|
# Detect existing auth: if user has a gitea_token in user_tokens → 'gitea'
|
|
conn.execute(
|
|
"UPDATE users SET auth_method='gitea' WHERE id IN (SELECT gitea_user_id FROM user_tokens)"
|
|
)
|
|
conn.execute(
|
|
"UPDATE users SET auth_method='local' WHERE auth_method IS NULL"
|
|
)
|
|
# 2) Publishing & sharing columns on pages
|
|
try:
|
|
conn.execute("ALTER TABLE pages ADD COLUMN is_published INTEGER NOT NULL DEFAULT 0")
|
|
except sqlite3.OperationalError:
|
|
pass
|
|
try:
|
|
conn.execute("ALTER TABLE pages ADD COLUMN publish_slug TEXT DEFAULT ''")
|
|
except sqlite3.OperationalError:
|
|
pass
|
|
try:
|
|
conn.execute("ALTER TABLE pages ADD COLUMN is_shared INTEGER NOT NULL DEFAULT 0")
|
|
except sqlite3.OperationalError:
|
|
pass
|
|
# 3) page_shares table
|
|
conn.execute("""
|
|
CREATE TABLE IF NOT EXISTS page_shares (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
page_id INTEGER NOT NULL REFERENCES pages(id) ON DELETE CASCADE,
|
|
shared_with_user_id INTEGER REFERENCES users(id),
|
|
shared_with_group_id INTEGER REFERENCES user_groups(id) ON DELETE CASCADE,
|
|
shared_with_email TEXT DEFAULT '',
|
|
permission TEXT NOT NULL DEFAULT 'view',
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
created_by INTEGER REFERENCES users(id)
|
|
)
|
|
""")
|
|
# v5.x: migration — partage par groupes (colonne manquante sur DB existantes)
|
|
try:
|
|
conn.execute("ALTER TABLE page_shares ADD COLUMN shared_with_group_id INTEGER REFERENCES user_groups(id) ON DELETE CASCADE")
|
|
except sqlite3.OperationalError:
|
|
pass
|
|
# 4) recents table
|
|
conn.execute("""
|
|
CREATE TABLE IF NOT EXISTS recents (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
user_id INTEGER NOT NULL REFERENCES users(id),
|
|
page_id INTEGER NOT NULL REFERENCES pages(id),
|
|
workspace TEXT NOT NULL DEFAULT '',
|
|
source_type TEXT NOT NULL DEFAULT 'local',
|
|
accessed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
UNIQUE(user_id, page_id)
|
|
)
|
|
""")
|
|
conn.commit()
|
|
|
|
# v4.1.0: Data Sources & Linked Databases
|
|
conn.execute("""
|
|
CREATE TABLE IF NOT EXISTS collection_data_sources (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
collection_id INTEGER NOT NULL REFERENCES collections(id) ON DELETE CASCADE,
|
|
source_collection_id INTEGER NOT NULL REFERENCES collections(id),
|
|
source_name TEXT DEFAULT '',
|
|
is_linked BOOLEAN NOT NULL DEFAULT 0,
|
|
position INTEGER NOT NULL DEFAULT 0,
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
UNIQUE(collection_id, source_collection_id)
|
|
)
|
|
""")
|
|
# Add workspace_id to collections if missing (v4.1.0 migration)
|
|
try:
|
|
conn.execute("ALTER TABLE collections ADD COLUMN workspace_id INTEGER REFERENCES workspaces(id)")
|
|
except sqlite3.OperationalError:
|
|
pass
|
|
# Add created_by to collections if missing
|
|
try:
|
|
conn.execute("ALTER TABLE collections ADD COLUMN created_by INTEGER REFERENCES users(id)")
|
|
except sqlite3.OperationalError:
|
|
pass
|
|
conn.commit()
|
|
|
|
# v4.6.0: Add collection_id to pages (page ↔ collection link for full-page DBs)
|
|
try:
|
|
conn.execute("ALTER TABLE pages ADD COLUMN collection_id INTEGER REFERENCES collections(id)")
|
|
except sqlite3.OperationalError:
|
|
pass
|
|
|
|
# v4.2.0: Collection Templates (enhanced) + Dashboards
|
|
# Add description, is_recurring, recurrence_rule to page_templates
|
|
try:
|
|
conn.execute("ALTER TABLE page_templates ADD COLUMN description TEXT DEFAULT ''")
|
|
except sqlite3.OperationalError:
|
|
pass
|
|
try:
|
|
conn.execute("ALTER TABLE page_templates ADD COLUMN is_recurring BOOLEAN NOT NULL DEFAULT 0")
|
|
except sqlite3.OperationalError:
|
|
pass
|
|
try:
|
|
conn.execute("ALTER TABLE page_templates ADD COLUMN recurrence_rule TEXT DEFAULT ''")
|
|
except sqlite3.OperationalError:
|
|
pass
|
|
|
|
# Dashboard: combinaison de vues/widgets sur une page
|
|
conn.execute("""
|
|
CREATE TABLE IF NOT EXISTS collection_dashboards (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
collection_id INTEGER NOT NULL REFERENCES collections(id) ON DELETE CASCADE,
|
|
name TEXT NOT NULL DEFAULT 'Dashboard',
|
|
layout_json TEXT NOT NULL DEFAULT '{"columns":1,"widgets":[]}',
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
)
|
|
""")
|
|
conn.commit()
|
|
|
|
# v4.4.0: Tasks & Dependencies
|
|
# Add is_task flag to collections
|
|
try:
|
|
conn.execute("ALTER TABLE collections ADD COLUMN is_task BOOLEAN NOT NULL DEFAULT 0")
|
|
except sqlite3.OperationalError:
|
|
pass
|
|
|
|
# Dependencies table: bloque/bloqué par with auto-shift config
|
|
conn.execute("""
|
|
CREATE TABLE IF NOT EXISTS page_dependencies (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
page_id INTEGER NOT NULL REFERENCES collection_pages(id) ON DELETE CASCADE,
|
|
dependency_id INTEGER NOT NULL REFERENCES collection_pages(id) ON DELETE CASCADE,
|
|
dependency_type TEXT NOT NULL DEFAULT 'blocks',
|
|
auto_shift TEXT DEFAULT 'overlap',
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
UNIQUE(page_id, dependency_id, dependency_type)
|
|
)
|
|
""")
|
|
conn.commit()
|
|
|
|
# v4.5.0: Sprints
|
|
conn.execute("""
|
|
CREATE TABLE IF NOT EXISTS sprints (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
collection_id INTEGER NOT NULL REFERENCES collections(id) ON DELETE CASCADE,
|
|
name TEXT NOT NULL,
|
|
start_date TEXT NOT NULL,
|
|
end_date TEXT NOT NULL,
|
|
goal TEXT DEFAULT '',
|
|
status TEXT NOT NULL DEFAULT 'planning',
|
|
auto_complete BOOLEAN NOT NULL DEFAULT 1,
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
)
|
|
""")
|
|
conn.execute("""
|
|
CREATE TABLE IF NOT EXISTS sprint_pages (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
sprint_id INTEGER NOT NULL REFERENCES sprints(id) ON DELETE CASCADE,
|
|
page_id INTEGER NOT NULL REFERENCES collection_pages(id) ON DELETE CASCADE,
|
|
status_at_start TEXT DEFAULT '',
|
|
velocity_points INTEGER DEFAULT 1,
|
|
UNIQUE(sprint_id, page_id)
|
|
)
|
|
""")
|
|
conn.commit()
|
|
|
|
# v4.6.0: Sidebar customization config per user
|
|
try:
|
|
conn.execute("ALTER TABLE users ADD COLUMN sidebar_config TEXT DEFAULT '{}'")
|
|
except sqlite3.OperationalError:
|
|
pass
|
|
conn.commit()
|
|
|
|
# ═══════════ v4.9.0: Collaboration — notifications, inline comments, prefs ═══════════
|
|
# Notifications table (mentions, comments, page changes)
|
|
conn.execute("""
|
|
CREATE TABLE IF NOT EXISTS notifications (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
actor_id INTEGER REFERENCES users(id),
|
|
ntype TEXT NOT NULL DEFAULT 'mention', -- 'mention' | 'comment' | 'page'
|
|
title TEXT NOT NULL DEFAULT '',
|
|
message TEXT NOT NULL DEFAULT '',
|
|
resource_type TEXT NOT NULL DEFAULT 'page',
|
|
resource_id INTEGER NOT NULL DEFAULT 0,
|
|
url TEXT NOT NULL DEFAULT '',
|
|
is_read INTEGER NOT NULL DEFAULT 0,
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
)
|
|
""")
|
|
conn.execute(
|
|
"CREATE INDEX IF NOT EXISTS idx_notif_user_read ON notifications(user_id, is_read)"
|
|
)
|
|
|
|
# Notification email preferences (JSON: {"comments": true, "mentions": true})
|
|
try:
|
|
conn.execute("ALTER TABLE users ADD COLUMN notification_prefs TEXT DEFAULT '{}'")
|
|
except sqlite3.OperationalError:
|
|
pass
|
|
|
|
# Inline comments on pages: the v2.0.0 `comments` table had a NOT NULL FK to
|
|
# collection_pages, which prevents using page-editor (pages) ids. Rebuild it so
|
|
# it can hold page comments with optional inline anchors, while preserving data.
|
|
# target_type='collection_page' (legacy) or 'page' (editor); target_id = resource id.
|
|
# anchor_block_id = block id; anchor_start/anchor_end = text selection offsets.
|
|
_cols = [r[1] for r in conn.execute("PRAGMA table_info(comments)").fetchall()]
|
|
if "target_type" not in _cols:
|
|
try:
|
|
conn.execute("""
|
|
CREATE TABLE comments_new (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
page_id INTEGER,
|
|
user_id INTEGER NOT NULL REFERENCES users(id),
|
|
body TEXT NOT NULL DEFAULT '',
|
|
parent_id INTEGER,
|
|
resolved BOOLEAN NOT NULL DEFAULT 0,
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
target_type TEXT NOT NULL DEFAULT 'page',
|
|
target_id INTEGER NOT NULL DEFAULT 0,
|
|
anchor_block_id TEXT,
|
|
anchor_start INTEGER,
|
|
anchor_end INTEGER
|
|
)
|
|
""")
|
|
conn.execute(
|
|
"""INSERT INTO comments_new
|
|
(id, page_id, user_id, body, parent_id, resolved, created_at, updated_at, target_type, target_id)
|
|
SELECT id, page_id, user_id, body, parent_id, resolved, created_at, updated_at,
|
|
'collection_page', COALESCE(page_id, 0)
|
|
FROM comments"""
|
|
)
|
|
conn.execute("DROP TABLE comments")
|
|
conn.execute("ALTER TABLE comments_new RENAME TO comments")
|
|
except sqlite3.OperationalError:
|
|
pass
|
|
conn.commit()
|
|
|
|
# ═══════════ v4.10.0: FlowDeck Agent — agents, conversations, audit ═══════════
|
|
conn.execute("""
|
|
CREATE TABLE IF NOT EXISTS agents (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
workspace_id INTEGER REFERENCES workspaces(id),
|
|
name TEXT NOT NULL DEFAULT 'FlowDeck Agent',
|
|
icon TEXT DEFAULT '🤖',
|
|
agent_type TEXT NOT NULL DEFAULT 'personal',
|
|
description TEXT DEFAULT '',
|
|
system_instructions TEXT DEFAULT '',
|
|
model TEXT DEFAULT 'gpt-4o',
|
|
scope_json TEXT NOT NULL DEFAULT '{}',
|
|
trigger_json TEXT NOT NULL DEFAULT '{}',
|
|
approval_mode TEXT NOT NULL DEFAULT 'auto',
|
|
is_active BOOLEAN NOT NULL DEFAULT 1,
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
created_by INTEGER REFERENCES users(id),
|
|
UNIQUE(workspace_id, name)
|
|
)
|
|
""")
|
|
conn.execute("""
|
|
CREATE TABLE IF NOT EXISTS agent_conversations (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
agent_id INTEGER NOT NULL REFERENCES agents(id) ON DELETE CASCADE,
|
|
user_id INTEGER NOT NULL REFERENCES users(id),
|
|
title TEXT DEFAULT 'New conversation',
|
|
status TEXT NOT NULL DEFAULT 'idle',
|
|
context_json TEXT NOT NULL DEFAULT '{}',
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
)
|
|
""")
|
|
conn.execute("""
|
|
CREATE TABLE IF NOT EXISTS agent_messages (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
conversation_id INTEGER NOT NULL REFERENCES agent_conversations(id) ON DELETE CASCADE,
|
|
role TEXT NOT NULL,
|
|
content TEXT NOT NULL DEFAULT '',
|
|
tool_calls_json TEXT DEFAULT '[]',
|
|
model TEXT,
|
|
tokens_used INTEGER DEFAULT 0,
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
)
|
|
""")
|
|
conn.execute("""
|
|
CREATE TABLE IF NOT EXISTS agent_actions (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
conversation_id INTEGER NOT NULL REFERENCES agent_conversations(id) ON DELETE CASCADE,
|
|
tool_name TEXT NOT NULL,
|
|
target_type TEXT,
|
|
target_id TEXT,
|
|
payload_json TEXT NOT NULL DEFAULT '{}',
|
|
result_json TEXT NOT NULL DEFAULT '{}',
|
|
status TEXT NOT NULL DEFAULT 'success',
|
|
undo_snapshot_json TEXT DEFAULT '{}',
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
executed_by INTEGER REFERENCES users(id)
|
|
)
|
|
""")
|
|
conn.execute("""
|
|
CREATE TABLE IF NOT EXISTS agent_skills (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
workspace_id INTEGER REFERENCES workspaces(id),
|
|
name TEXT NOT NULL,
|
|
description TEXT DEFAULT '',
|
|
prompt_template TEXT NOT NULL,
|
|
allowed_tools_json TEXT NOT NULL DEFAULT '[]',
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
created_by INTEGER REFERENCES users(id),
|
|
UNIQUE(workspace_id, name)
|
|
)
|
|
""")
|
|
conn.execute("""
|
|
CREATE TABLE IF NOT EXISTS agent_triggers (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
agent_id INTEGER NOT NULL REFERENCES agents(id) ON DELETE CASCADE,
|
|
trigger_type TEXT NOT NULL DEFAULT 'manual',
|
|
config_json TEXT NOT NULL DEFAULT '{}',
|
|
is_active BOOLEAN NOT NULL DEFAULT 1,
|
|
last_fired_at TIMESTAMP,
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
)
|
|
""")
|
|
# ─── v4.15.0: feedback des réponses de l'agent (👍 / 👎) ───────────────
|
|
conn.execute("""
|
|
CREATE TABLE IF NOT EXISTS agent_feedback (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
conversation_id INTEGER REFERENCES agent_conversations(id) ON DELETE SET NULL,
|
|
message_id INTEGER REFERENCES agent_messages(id) ON DELETE SET NULL,
|
|
user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
|
rating TEXT NOT NULL CHECK (rating IN ('up', 'down')),
|
|
snippet TEXT DEFAULT '',
|
|
comment TEXT DEFAULT '',
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
)
|
|
""")
|
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_agent_feedback_conv ON agent_feedback(conversation_id)")
|
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_agent_feedback_user ON agent_feedback(user_id)")
|
|
|
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_agent_conv_user ON agent_conversations(user_id, updated_at)")
|
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_agent_msg_conv ON agent_messages(conversation_id, created_at)")
|
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_agent_action_conv ON agent_actions(conversation_id)")
|
|
|
|
# ─── v4.10.1: LLM runtime config (single row id=1) ─────────────────────
|
|
# Created lazily (no seed) so .env stays the default until an admin saves
|
|
# the LLM settings from the UI. Precedence: DB row > settings.llm_*.
|
|
conn.execute("""
|
|
CREATE TABLE IF NOT EXISTS llm_config (
|
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
|
provider TEXT NOT NULL DEFAULT 'offline',
|
|
model TEXT DEFAULT '',
|
|
api_key TEXT DEFAULT '',
|
|
api_base TEXT DEFAULT '',
|
|
verified INTEGER NOT NULL DEFAULT 0,
|
|
verified_model TEXT DEFAULT '',
|
|
verified_at TIMESTAMP,
|
|
last_error TEXT DEFAULT '',
|
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
)
|
|
""")
|
|
# ─── v4.10.2: per-user provider API keys ──────────────────────────────
|
|
# Each user can save several providers with their own key/base + the
|
|
# live model list fetched from the provider (models_json cache).
|
|
conn.execute("""
|
|
CREATE TABLE IF NOT EXISTS user_llm_keys (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
provider TEXT NOT NULL,
|
|
api_key TEXT NOT NULL DEFAULT '',
|
|
api_base TEXT NOT NULL DEFAULT '',
|
|
default_model TEXT DEFAULT '',
|
|
models_json TEXT NOT NULL DEFAULT '[]',
|
|
verified INTEGER NOT NULL DEFAULT 0,
|
|
verified_model TEXT DEFAULT '',
|
|
verified_at TIMESTAMP,
|
|
last_error TEXT DEFAULT '',
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
UNIQUE(user_id, provider)
|
|
)
|
|
""")
|
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_user_llm_keys_user ON user_llm_keys(user_id)")
|
|
# v4.12: provider activation/verification — a provider is only offered in
|
|
# the Agent UI once it is configured AND its connection test succeeded.
|
|
for col, ddl in (
|
|
("verified", "INTEGER NOT NULL DEFAULT 0"),
|
|
("verified_model", "TEXT DEFAULT ''"),
|
|
("verified_at", "TIMESTAMP"),
|
|
("last_error", "TEXT DEFAULT ''"),
|
|
):
|
|
try:
|
|
conn.execute(f"ALTER TABLE user_llm_keys ADD COLUMN {col} {ddl}")
|
|
except sqlite3.OperationalError:
|
|
pass # column already exists
|
|
for col, ddl in (
|
|
("verified", "INTEGER NOT NULL DEFAULT 0"),
|
|
("verified_model", "TEXT DEFAULT ''"),
|
|
("verified_at", "TIMESTAMP"),
|
|
("last_error", "TEXT DEFAULT ''"),
|
|
):
|
|
try:
|
|
conn.execute(f"ALTER TABLE llm_config ADD COLUMN {col} {ddl}")
|
|
except sqlite3.OperationalError:
|
|
pass # column already exists
|
|
# Migration: per-conversation provider/model override columns
|
|
for col in ("provider", "model"):
|
|
try:
|
|
conn.execute(f"ALTER TABLE agent_conversations ADD COLUMN {col} TEXT DEFAULT ''")
|
|
except sqlite3.OperationalError:
|
|
pass # column already exists
|
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_agent_conv_llm ON agent_conversations(provider, model)")
|
|
conn.commit()
|
|
|
|
# ── v5.2.0: apply any pending VERSIONED migrations (schema_version) ──
|
|
from app.migrations import apply_migrations
|
|
apply_migrations(conn)
|
|
|
|
# Webhook subscriptions (v2.1.0) — created here (idempotent) so the full
|
|
# schema exists without depending on the FastAPI lifespan startup.
|
|
from app.services.webhook_outbound import init_webhook_tables
|
|
init_webhook_tables()
|
|
|
|
|
|
@contextmanager
|
|
def get_conn():
|
|
"""Yield a SQLite connection (WAL mode, foreign keys on)."""
|
|
db_path = get_db_path()
|
|
conn = sqlite3.connect(str(db_path))
|
|
conn.row_factory = sqlite3.Row
|
|
conn.execute("PRAGMA journal_mode=WAL")
|
|
conn.execute("PRAGMA foreign_keys=ON")
|
|
# A21 (partiel) : un seul writer par DB — sans timeout la requête concurrente
|
|
# échoue immédiatement avec « database is locked » (tests xdist, schedulers).
|
|
# ponytail: le reste d'A21 (wrapper async + 510 call sites synchrones sur
|
|
# l'event loop) reste à migrer module par module.
|
|
conn.execute("PRAGMA busy_timeout=5000")
|
|
try:
|
|
yield conn
|
|
finally:
|
|
conn.close()
|