core: extract the store and sync engine into a shared crate (M12 step 1)
Android becomes a native Kotlin client over this same code (Scribe note 2730), so
the local store and sync engine stop being modules of the desktop app and become
`thoughtsync-core`, a crate with no UI framework in it at all.
This is a move, not a rewrite, and the measurement is why: every file in local/
and sync/ already carried ZERO Tauri references — 4,980 of 6,372 lines. The
coupling was 473 lines of command shim, which stays behind in the desktop crate
as src/commands/. Kept as git renames so history follows the files.
The desktop imports them under their old names (`use thoughtsync_core::{local,
sync}`) so every call site reads exactly as before. What moved is where they
live, not what they are.
Two things a workspace changes that are easy to miss, both caught before pushing:
[profile.release] now lives at the workspace ROOT. Cargo silently ignores
profiles declared by a non-root member — leaving it in the desktop crate would
have dropped lto/strip/opt-level from every release build with only a warning.
And a workspace shares ONE target dir, so the bundles moved from
desktop/src-tauri/target to target/. Thirteen references across publish-release,
debundle-graphics, verify.sh, package-prebuilt and the workflow now point there.
Pinning target-dir back would have been the smaller diff, but the Android lane
also produces Rust artifacts and they do not belong under desktop/.
Also retires the Tauri Android lane in the same push rather than leaving a path
that is being replaced: gen/android, android.yml and docs/android-dev.md are
gone, the mobile_entry_point attribute with them, and the lib drops to rlib —
staticlib/cdylib existed for Tauri mobile, and the .so Android loads will be
built from the core crate instead. Rule 22, no parallel path.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,187 @@
|
||||
//! 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);
|
||||
//! `[[links]]` are NOT stored (derived at query time), 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(())
|
||||
}
|
||||
Reference in New Issue
Block a user