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
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.
189 lines
6.7 KiB
Rust
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(())
|
|
}
|