Files
thoughtsync/core/src/local/schema.rs
T
bvandeusen bc22f8e249
CI & Build / Build now, or wait for Android? (push) Successful in 2s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Failing after 6s
CI & Build / Build & push image (push) Skipped
CI & Build / Python tests (push) Successful in 8s
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 31s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Failing after 37s
Desktop (Tauri) / Update manifest (push) Skipped
Android / Kotlin + Rust (APK) (push) Failing after 1m56s
Remove [[wiki-links]], backlinks and the graph
Operator, 2026-08-22 (note 2897): ThoughtSync is an intermediary surface. You
write here because it's easy — a notebook in your pocket — and later you recall
the thing and go finish it somewhere else. Recall is the product; organization
is secondary. A linking system is organization, and it isn't what this is for.

So: `[[wiki-links]]`, backlinks, the `[[` autocomplete, the note_links table,
`/api/notes/link-search`, `/api/notes/<id>/backlinks`, the whole graph blueprint
and GraphView. Rust core loses `extract_links`, `backlinks`, `link_search` and
`create_titled`; the desktop loses the three Tauri commands that exposed them.

This subsumes 982d24c rather than reverting it. That commit bound links to a
note id so a rename would stop rewriting other notes' bodies — real infra, but
infra for a feature that is now gone, and nothing it added survives. Alembic
0023 stays in the chain anyway: it shipped in an image and may already be
applied, and deleting an applied revision strands a database's version pointer.
0024 drops the table and takes the column with it. The history stays honest
about the fact that it existed for a day.

Two things deliberately kept, because they were serving recall and only
incidentally serving links:

- `/api/notes/titles` and the titles store. The command palette lists them so
  you can jump to a note by name. `resolve()` — the name→note lookup that only
  linking needed — is gone.
- `display_title`. Every note still has a name for search results and export
  filenames. What that name is FOR changed; that it exists did not.

`notes/links.py` is now `notes/tags.py`, holding the #tag→label reconciliation
it always also owned. A file called links.py with no links in it would have been
exactly the drift this removal is meant to end.

Also swept out on the way: `_escape_like`, whose only caller was link-search,
and the `graph` icon. Nothing lost that a person typed — note_links was always
derived, and the `[[text]]` is still sitting in every body it was written in.
2026-08-22 12:00:57 -04:00

189 lines
6.7 KiB
Rust

//! Local SQLite schema + migrations. The schema mirrors the note/label model so an
//! offline note can later sync 1:1 with the server. Each syncable row carries local
//! `sync_revision` + `dirty` bookkeeping (consumed by the sync engine in M10.7);
//! `#tags` are NOT stored as such (derived at query time into labels), matching
//! docs/sync.md.
//!
//! Migrations are gated on `PRAGMA user_version`; bump it and add a block per change.
use rusqlite::Connection;
const SCHEMA_V1: &str = r#"
CREATE TABLE notes (
id TEXT PRIMARY KEY,
title TEXT,
body TEXT NOT NULL DEFAULT '',
color TEXT NOT NULL DEFAULT 'default',
kind TEXT NOT NULL DEFAULT 'text', -- 'text' | 'list'
position INTEGER NOT NULL DEFAULT 0,
pinned INTEGER NOT NULL DEFAULT 0,
archived INTEGER NOT NULL DEFAULT 0,
trashed INTEGER NOT NULL DEFAULT 0,
remind_at TEXT,
recurrence TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
sync_revision INTEGER NOT NULL DEFAULT 0,
dirty INTEGER NOT NULL DEFAULT 1
);
CREATE TABLE labels (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
color TEXT NOT NULL DEFAULT 'default',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
sync_revision INTEGER NOT NULL DEFAULT 0,
dirty INTEGER NOT NULL DEFAULT 1
);
CREATE UNIQUE INDEX idx_labels_name ON labels (lower(name));
CREATE TABLE note_labels (
note_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
label_id TEXT NOT NULL REFERENCES labels(id) ON DELETE CASCADE,
via_tag INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (note_id, label_id)
);
CREATE INDEX idx_note_labels_label ON note_labels (label_id);
CREATE TABLE checklist_items (
id TEXT PRIMARY KEY,
note_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
text TEXT NOT NULL DEFAULT '',
checked INTEGER NOT NULL DEFAULT 0,
position INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX idx_items_note ON checklist_items (note_id);
CREATE TABLE attachments (
id TEXT PRIMARY KEY,
note_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
url TEXT NOT NULL,
filename TEXT,
mime TEXT NOT NULL DEFAULT 'application/octet-stream',
size INTEGER,
sha256 TEXT,
position INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX idx_attachments_note ON attachments (note_id);
CREATE TABLE link_previews (
id TEXT PRIMARY KEY,
note_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
url TEXT NOT NULL,
title TEXT,
description TEXT,
image_url TEXT,
site_name TEXT,
position INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX idx_previews_note ON link_previews (note_id);
CREATE TABLE note_revisions (
id TEXT PRIMARY KEY,
note_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
title TEXT,
body TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE INDEX idx_revisions_note ON note_revisions (note_id, created_at);
CREATE TABLE saved_filters (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
params TEXT NOT NULL DEFAULT '{}', -- NoteFacets JSON
position INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL
);
-- Single-row sync bookkeeping (server URL / device token / last-consumed cursor).
CREATE TABLE sync_state (
id INTEGER PRIMARY KEY CHECK (id = 1),
server_url TEXT,
device_token TEXT,
last_cursor TEXT
);
INSERT INTO sync_state (id) VALUES (1);
"#;
// v2 (M10.7c): local tombstones.
//
// A permanent delete previously just dropped the row, which left NO record that it
// ever existed. Offline, that means the delete can never be pushed — and the next
// pull would faithfully resurrect the note from the server. A deletion that undoes
// itself is about the worst outcome sync can produce, so deletes are now recorded
// here until they've been acknowledged by the server and cleared.
const SCHEMA_V2: &str = r#"
CREATE TABLE pending_deletes (
entity TEXT NOT NULL, -- 'note' | 'label'
id TEXT NOT NULL,
deleted_at TEXT NOT NULL,
PRIMARY KEY (entity, id)
);
"#;
// v3 (M10.7e): when the last successful sync finished.
//
// The cursor alone can't answer "is this up to date?" — it's a revision watermark,
// not a time, and it doesn't move at all when a sync legitimately finds nothing new.
// The UI needs a timestamp to say anything honest.
const SCHEMA_V3: &str = r#"
ALTER TABLE sync_state ADD COLUMN last_sync_at TEXT;
"#;
// v4 (M11.3): WHEN a note was trashed.
//
// The table only ever recorded THAT a note was trashed, which is enough to draw a
// Trash view and nothing else. Retention needs an age: without a timestamp there is
// no way to tell a note trashed this morning from one trashed last spring, so an
// offline device could never expire its own trash — and the UI couldn't warn anyone
// before it did.
// It also records the LINKED server's retention window, captured from /api/config.
// Once linked, the server's policy is the one that actually applies, so showing this
// device's offline default would put a countdown on screen that doesn't match what
// happens — a wrong deadline is worse than none.
const SCHEMA_V4: &str = r#"
ALTER TABLE notes ADD COLUMN trashed_at TEXT;
UPDATE notes SET trashed_at = updated_at WHERE trashed = 1;
ALTER TABLE sync_state ADD COLUMN server_retention_days INTEGER;
"#;
// v5 (M10.9): small key/value app preferences.
//
// The first entry is the update channel, which is neither note data nor part of the
// server link — so it belongs in neither `notes` nor `sync_state`. Generic on
// purpose: the next device-local preference shouldn't need another migration.
const SCHEMA_V5: &str = r#"
CREATE TABLE prefs (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
"#;
/// Bring the database up to the latest schema. Idempotent.
pub fn migrate(conn: &Connection) -> rusqlite::Result<()> {
conn.execute_batch("PRAGMA foreign_keys = ON;")?;
let version: i64 = conn.query_row("PRAGMA user_version", [], |r| r.get(0))?;
if version < 1 {
conn.execute_batch(SCHEMA_V1)?;
conn.execute_batch("PRAGMA user_version = 1;")?;
}
if version < 2 {
conn.execute_batch(SCHEMA_V2)?;
conn.execute_batch("PRAGMA user_version = 2;")?;
}
if version < 3 {
conn.execute_batch(SCHEMA_V3)?;
conn.execute_batch("PRAGMA user_version = 3;")?;
}
if version < 4 {
conn.execute_batch(SCHEMA_V4)?;
conn.execute_batch("PRAGMA user_version = 4;")?;
}
if version < 5 {
conn.execute_batch(SCHEMA_V5)?;
conn.execute_batch("PRAGMA user_version = 5;")?;
}
Ok(())
}