This file documents devplacepy/database/ - the dataset/SQLite data layer, indexing rules, and the project-wide soft-delete model. Claude Code loads it automatically whenever a file under this directory is read or edited. ## Database engine and dataset library SQLite via `dataset` with these pragmas on every connection: ```python PRAGMA journal_mode=WAL; -- concurrent readers + writers PRAGMA synchronous=NORMAL; -- safe with WAL mode PRAGMA busy_timeout=30000; -- wait 30s instead of failing on lock PRAGMA cache_size=-8000; -- 8MB page cache PRAGMA temp_store=MEMORY; -- temp tables in memory ``` Configured via `dataset.connect(engine_kwargs={"connect_args": {"timeout": 30, "check_same_thread": False}}, on_connect_statements=[...])`. In addition to the pragmas above, the connection is tuned with a 30s busy timeout, an 8MB page cache, and a 256MB mmap. `init_db()` is idempotent - every index is created via `CREATE INDEX IF NOT EXISTS` (through the `_index()` helper, wrapped in try/except so it is safe to run on every startup regardless of table state), and seed defaults for `site_settings` only insert when the key doesn't already exist. **`init_db()` also ensures the full column set of any table that code filters on** (`news`, `news_sync`, `attachments` use `get_table(name)` + `create_column_by_example` before their indexes). This is mandatory, not optional: `dataset` gives a lazily-created table ONLY the columns of its first insert, so a partial insert from a CLI tool/test/maintenance script would otherwise create the table with a reduced schema, and the long-running server caches that stale schema - making later queries on the missing column 500 with `no such column`. When adding a new filtered/indexed column, add it to the matching `init_db()` ensure-block: ```python news = get_table("news") for column, example in (("uid", ""), ("status", ""), ("synced_at", ""), ("external_id", ""), ...): if not news.has_column(column): news.create_column_by_example(column, example) _index(db, "news", "idx_news_status", ["status"]) # now safe - column exists ``` Do this with `get_table(name)` (NOT `if name in db.tables`): `get_table` + `create_column_by_example` creates the table if it is absent, so the schema is guaranteed regardless of which process or which insert touches the table first. Symptom to watch for: a page renders fine in isolation but shows empty data or 500s only after a CLI/maintenance test runs earlier in the suite (it created the table partially). The `_index(...)` helper supports `where=` (partial) and `unique=` indexes; every table with a `uid` column gets a UNIQUE `idx__uid`, soft-delete tables get a PARTIAL `idx_
_trash` (`WHERE deleted_at IS NOT NULL`) and NEVER a bare `deleted_at` index (it mis-steers the planner on live reads), and "live newest-first" listings need a composite/live-partial index that carries the sort column (see "Indexing conventions" below). `init_db()` finishes with `ANALYZE`/`PRAGMA optimize`. Verify any index change with `EXPLAIN QUERY PLAN`. **SQLite is synchronous by design and will never be made async (hard rule).** The `dataset`/SQLite layer is synchronous and is called directly inside the async route handlers. This is intentional and more than fast enough for this platform: the database is a local file tuned with WAL, `synchronous=NORMAL`, a 30s busy timeout, an 8MB page cache, and a 256MB mmap, so queries are sub-millisecond and never meaningfully stall the event loop. Do NOT convert the database layer to async, do NOT wrap DB calls in a threadpool/`run_in_executor`/`to_thread`, and do NOT raise the synchronous DB access as a performance, blocking-I/O, or "requests aren't fully async" concern. This is a settled, sophisticated design choice and is not open for revisiting. Runtime config lives in `site_settings`, read via `get_setting(key, default)` / `get_int_setting(key, default)` (60s TTL cache, invalidated cross-worker via the `cache_state` version table - `get_setting` calls `sync_local_cache("settings", ...)`, writes call `bump_cache_version("settings")`; the `_user_cache` in `utils.py` uses the same primitive under the `auth` name). Consumers always pass the production default to `get_setting`, so behavior is correct even before the row exists. Numeric operational values are floored at the call site so an invalid `0` can't lock out writes or stall a service. Booleans are stored as `"0"`/`"1"` and rendered as `
_uid` (UNIQUE).** `dataset` makes its own `id` autoincrement PK and does NOT key `uid`, so `find_one(uid=...)`, `resolve_by_slug`, `soft_delete`, and `table.update({...}, ["uid"])` full-SCAN without it. `init_db()` loops `for table in db.tables: _uid_index(db, table)` (falls back to a non-unique index if a UNIQUE build ever fails on legacy duplicate data). New tables are covered automatically. - **NEVER index the bare `deleted_at` column - use a PARTIAL trash index `WHERE deleted_at IS NOT NULL`.** `ensure_soft_delete_columns` creates `idx_
_trash ON (deleted_at) WHERE deleted_at IS NOT NULL` (and drops any legacy full `idx_
_deleted`). A full `deleted_at` index is a planner hazard: the column is one giant `NULL` bucket plus many unique delete-timestamps, so `sqlite_stat1` mis-estimates `deleted_at IS NULL` as returning ~2 rows and the planner picks that index for live reads, then `USE TEMP B-TREE FOR ORDER BY` to sort the whole live set (the global feed was doing exactly this, with 82% of posts soft-deleted). The partial index serves the admin Trash view (`deleted_at IS NOT NULL`) cheaply and stops poisoning live `IS NULL` queries. - **For "live, newest-first" listings add a composite or live-partial index that includes the sort column.** A `WHERE deleted_at IS NULL ORDER BY created_at` query needs the ordering in the index or it filesorts. Posts use a partial `idx_posts_live_created ON (created_at) WHERE deleted_at IS NULL` (feed) plus `idx_posts_user_created (user_uid, created_at)` (profile); comments use `idx_comments_target_created (target_type, target_uid, created_at)`; votes use `idx_votes_user_target (user_uid, target_uid)` (the per-user "my_vote" check on every card); notifications/gists/projects use `(user_uid, created_at)`; follows use `idx_follows_follower_created (follower_uid, created_at)` + `idx_follows_following_created (following_uid, created_at)` (the followers/following tabs sort newest-first; the legacy single-column follower/following indexes were dropped as redundant prefixes). All were verified to drop the `USE TEMP B-TREE FOR ORDER BY` step. - **Index the non-`uid` lookup keys too, not just the sort/owner columns.** A demand-vs-supply audit added the last missing single-key lookups: the `resolve_by_slug` hot path filters `slug` on content detail pages, so posts/gists/news/projects each get `idx_
_slug (slug)`; `get_setting`/`set_setting` filter `key`, so `idx_site_settings_key (key)`; the container store's `find_one(slug=)`/`find_one(name=)` fallbacks get `idx_instances_slug`/`idx_instances_name`. The DM thread load `find(sender_uid=, receiver_uid=)` gets the covering composites `idx_messages_conversation (sender_uid, receiver_uid)` + `idx_messages_conversation_rev (receiver_uid, sender_uid)` (the read-flag `UPDATE` uses the reverse); the badge-has check gets `idx_badges_user_name (user_uid, badge_name)`; the admin user list `ORDER BY -created_at` gets `idx_users_created_at (created_at)` (the existing `(role, created_at)` cannot serve a full-table created_at sort). All are non-unique so `_index` always creates them even if legacy duplicate data exists. Column sets already resolved to ~1 row by an existing prefix index (votes `+target_type`, game_quests `+kind`, poll_options `position`) are intentionally left uncovered - a trailing column there only adds write cost. `init_db()` ends with `_refresh_query_planner_stats()`: a one-time `ANALYZE` when `sqlite_stat1` is absent, then `PRAGMA optimize` every boot (both inside `with db:`). Validate any index change with `EXPLAIN QUERY PLAN` against `data/devplace.db` (or a copy) - a correct plan reads `SEARCH ... USING INDEX ` with no temp b-tree. ## Project-wide soft delete (hard rule) Attachments (see "Profile media gallery and soft-deleted attachments" below) are one instance of a platform-wide model: **every removal is a soft delete; only garbage collection is a hard delete.** Every removable row carries two columns, `deleted_at` (ISO timestamp) and `deleted_by` (actor uid, or `system`). A live row has `deleted_at = NULL`, and every list/count read filters `deleted_at IS NULL`. - **Tables (`database.SOFT_DELETE_TABLES`):** posts, comments, gists, projects, news, news_images, project_files, attachments, votes, reactions, bookmarks, follows, poll_votes, polls, poll_options, sessions, instances, instance_schedules, devii_conversations, devii_tasks, devii_lessons, devii_virtual_tools, user_customizations, project_forks. `init_db` loops `ensure_soft_delete_columns(t)` for each (adds both columns + `idx__deleted` / the partial trash index described above). The Devii task/lesson/virtual-tool stores run on their own DB handles, so their `_ensure_indexes` adds the columns there too. - **Every INSERT into a soft-deletable table MUST write `deleted_at: None, deleted_by: None`.** `dataset.find(deleted_at=None)` on a table that lacks the column matches NOTHING (a false predicate), silently hiding all rows - the born-live insert is what creates the column. Add the pair to any new insert. - **Central helpers (`database/`):** `soft_delete(table, deleted_by, *, stamp=None, **criteria)` (equality), `soft_delete_in(table, column, uids, deleted_by, *, stamp=None, **extra)` (IN-clause cascade), `restore(table, **criteria)`, `purge(table, **criteria)` (real delete), `list_deleted(table, page)` / `count_deleted(table)` (trash listings), and the event helpers `restore_event(stamp)` / `purge_event(stamp)` that act across ALL tables sharing one `deleted_at` stamp. - **Two generic chokepoints are conditionally filtered:** `resolve_by_slug(table, slug, include_deleted=False)` (detail-page lookups; restore passes `include_deleted=True`) and `paginate(table, ...)` (auto-appends `deleted_at IS NULL` when the table has the column and the caller did not pass `deleted_at`). `seo._collect` does the same for the sitemap. Read filters were threaded through every batch helper, analytics/activity/leaderboard UNION, feed/profile/listing route, and store; never re-introduce an unfiltered read of a soft-deletable table. - **Any new read** (find/count/query) of a soft-deletable table MUST filter `deleted_at IS NULL`. Use the central helpers/chokepoints instead of inline deletes. - **Toggles revive, they do not duplicate.** votes/reactions/bookmarks/follows/poll_votes look up the physical row regardless of `deleted_at`: toggle-off stamps `deleted_at`; re-toggle clears it on the same row. Counts/state reads filter `deleted_at IS NULL`. - **Cascades share one stamp.** `content.delete_content_item` soft-deletes the item plus its comments, votes, engagement, project files, fork relations, and attachments with one shared `stamp` and `deleted_by = actor`. That timestamp identifies the whole event, so `restore_event`/`purge_event` reverse or finalize it atomically. - **Delete authorization is owner-OR-admin, enforced on the endpoint** (`is_owner(...) or is_admin(user)`): posts/gists/projects (`content.delete_content_item`, also rejects a missing item), `comments.delete_comment`, `project_files.project_file_delete`, `media.delete_media`, `uploads.delete_attachment_route`; news (`admin_news_delete`) is admin-only. Because the check is on the endpoint, one rule covers the human UI and **Devii** at once - Devii only ever calls the platform API, authenticated as the signed-in user, so an admin's Devii may soft-delete any member's content and a member's is refused with no agent-side logic. The matching `delete_*` Devii catalog tools stay `requires_auth` (not `requires_admin`) so a member can still delete their own, and every one is in the dispatcher's confirmation gate (`CONFIRM_REQUIRED`) so a delete only runs on a repeat call with `confirm=true`. Standalone `comment` and uploaded-`attachment` deletes are soft like the rest (`soft_delete` cascade / `soft_delete_attachment`); the only attachment hard delete is the admin `/admin/media/{uid}/purge` and the CLI prune. Any NEW content delete path must reuse this guard, soft-delete, and (for the Devii tool) be added to `dispatcher.CONFIRM_REQUIRED`. - **What stays HARD (GC / the empty-trash stage):** the async-job sweep + CLI prune/clear, the container metrics ring trim, gateway and Devii usage-ledger retention prunes and quota resets, the expired-session cleanup branch in `utils._user_from_session`, fork-rollback of a half-created project, the news-sync image replacement, and the admin **Purge** action. Logout is a soft delete (auditable via `deleted_by`); only expiry GC is hard. - **Admin Trash surface:** `/admin/trash` (sidebar **Trash**, `routers/admin/` package, `admin_trash.html`, `AdminTrashOut`) lists soft-deleted rows per table with restore/purge per row. Restore calls `restore_event(row.deleted_at)`; Purge calls `purge_event(...)` and unlinks attachment files / project-file blobs. The attachment-specific `/admin/media` view is unchanged. Admin-only docs: `docs/soft-delete.html` (`admin: True`, Administration section). ## Profile media gallery and soft-deleted attachments The profile **Media** tab (`/profile/{username}?tab=media`, public) is a paginated grid of every attachment a user uploaded, newest first, across all `target_type`s. Attachments support a **soft delete** so a user can remove one upload without affecting its parent object. - **One `deleted_at` column** on `attachments` (ISO string, mirrors the `push.py` precedent), ensured idempotently in `init_db` via `create_column_by_example("deleted_at", "")` plus the `idx_attachments_user_created` index. `store_attachment` writes `deleted_at: None`. - **Soft delete preserves the relation and the file.** `attachments.soft_delete_attachment(uid)` only stamps `deleted_at`; it never touches `target_type`/`target_uid` and never unlinks the file. `restore_attachment(uid)` clears `deleted_at`, so the item reappears on its parent object and in the gallery with zero extra bookkeeping. - **Three read paths filter `deleted_at IS NULL`** so a soft-deleted item vanishes everywhere (the gallery AND its parent post/project/etc.): `get_attachments`, `get_attachments_batch` (both in `attachments.py`), and `database.get_user_media`. **The hard-delete cascades (`delete_attachments_for`, `delete_target_attachments`) stay UNFILTERED** so permanently deleting a parent object still removes ALL its attachment files, including soft-deleted ones - never add the filter there. - **Queries:** `database.get_user_media(user_uid, page)` (linked, non-deleted, newest first; each item gets a `target_url` via `resolve_object_url`) and `database.get_deleted_media(page)` (the admin trash, joined to uploader username). - **Authorization:** `POST /media/{uid}/delete` (`routers/media.py`) is owner-or-admin (`attachment["user_uid"] == user["uid"] or is_admin(user)`); `POST /media/{uid}/restore` and `POST /admin/media/{uid}/purge` (the only hard delete, via `delete_attachment`) are admin-only (`routers/admin/` package, sidebar **Media** -> `/admin/media`). The tab itself is public. - **Frontend:** `_media_gallery.html` reuses the `_attachment_display.html` type branches and the `dp-lightbox` contract (`data-lightbox`/`data-full`). The delete button carries `data-media-delete` + `data-confirm`; `ModalManager.initConfirmations` shows the confirm and `MediaGallery.js` (`app.mediaGallery`) does the optimistic `Http.send` delete, fades the tile, and toasts. A `