Status: Draft. Locks before v0.1 ships. Audience: Engineers writing the storage layer; product reviewers deciding default policies.
This doc owns: schema, retention, blob layout, dedup, migrations, backup/export. Crypto-at-rest details live in crypto.md.
Per-OS layout. Only Windows is implemented in v0.1; macOS shown for v0.2 design.
%APPDATA%\Klipo\
├─ klipo.db # SQLite database (WAL mode)
├─ klipo.db-wal # WAL log (auto-managed)
├─ klipo.db-shm # Shared memory file
├─ blobs/ # Binary content (images, large files)
│ ├─ 0a/
│ │ └─ 0a3f...c2.png # SHA-256-named, first 2 hex chars sharded
│ ├─ 1f/
│ │ └─ 1f8e...d4.bin
│ └─ ...
├─ thumbs/ # Cached thumbnails for image clips
│ └─ <sha256>-192.webp
├─ logs/
│ └─ klipo.log # Rolling, no clipboard content
├─ backups/ # User-initiated exports
│ └─ klipo-2026-05-04.kpb # Encrypted backup bundle
└─ keychain/ # Sealed device keys (sodium-sealed)
├─ device.key.enc
└─ vault.key.enc
%APPDATA% = C:\Users\<user>\AppData\Roaming by default. We respect Windows roaming profile semantics (small files only — blobs go local-only via %LOCALAPPDATA% if user enables roaming-aware mode in v1.0).
~/Library/Application Support/Klipo/
├─ klipo.db
├─ blobs/
├─ thumbs/
└─ logs/
~/Library/Caches/Klipo/ # OCR cache, ephemeral
└─ ocr/
# Keychain entries:
# service "klipo.app", account "device-key"
# service "klipo.app", account "vault-key"
$XDG_DATA_HOME/klipo/ (defaults to ~/.local/share/klipo)
$XDG_STATE_HOME/klipo/ (logs, ephemeral)
$XDG_CACHE_HOME/klipo/ (thumbs)
Initial migration (src-tauri/src/storage/migrations/001_initial.sql). Reproducing PRD spec verbatim with one addition: HLC reserved column.
PRAGMA journal_mode = WAL;
PRAGMA foreign_keys = ON;
PRAGMA synchronous = NORMAL;
PRAGMA temp_store = MEMORY;
CREATE TABLE clips (
id TEXT PRIMARY KEY, -- UUIDv7
kind TEXT NOT NULL CHECK(kind IN ('text','image','file','rtf','html')),
content_hash TEXT NOT NULL, -- SHA-256 hex
text_content TEXT, -- nullable for binary kinds
blob_path TEXT, -- relative to APPDATA/Klipo/blobs
size_bytes INTEGER NOT NULL,
source_app TEXT, -- exe name or bundle id
source_url TEXT, -- if browser-sourced
source_window_title TEXT, -- v0.2; helpful for context
created_at INTEGER NOT NULL, -- unix ms (local clock)
pinned INTEGER NOT NULL DEFAULT 0,
deleted_at INTEGER, -- soft delete (sync tombstone)
sensitive INTEGER NOT NULL DEFAULT 0,
category TEXT, -- v0.2: AI-assigned or user-tagged
sync_version INTEGER NOT NULL DEFAULT 0, -- bumped on local mutation
hlc TEXT -- 16-hex (8-byte) HLC, NULL until v0.3 sync
);
CREATE INDEX idx_clips_created
ON clips(created_at DESC)
WHERE deleted_at IS NULL;
CREATE INDEX idx_clips_pinned
ON clips(pinned DESC, created_at DESC)
WHERE deleted_at IS NULL;
CREATE UNIQUE INDEX idx_clips_hash
ON clips(content_hash)
WHERE deleted_at IS NULL;
-- FTS5 virtual table for substring + fuzzy search
CREATE VIRTUAL TABLE clips_fts USING fts5(
text_content,
content='clips',
content_rowid='rowid',
tokenize='unicode61 remove_diacritics 2'
);
-- Triggers to keep FTS in sync
CREATE TRIGGER clips_ai AFTER INSERT ON clips
WHEN new.text_content IS NOT NULL BEGIN
INSERT INTO clips_fts(rowid, text_content) VALUES (new.rowid, new.text_content);
END;
CREATE TRIGGER clips_ad AFTER DELETE ON clips
WHEN old.text_content IS NOT NULL BEGIN
INSERT INTO clips_fts(clips_fts, rowid, text_content)
VALUES('delete', old.rowid, old.text_content);
END;
CREATE TRIGGER clips_au AFTER UPDATE ON clips
WHEN old.text_content IS NOT NULL OR new.text_content IS NOT NULL BEGIN
INSERT INTO clips_fts(clips_fts, rowid, text_content)
VALUES('delete', old.rowid, old.text_content);
INSERT INTO clips_fts(rowid, text_content)
VALUES (new.rowid, new.text_content);
END;
CREATE TABLE excluded_apps (
bundle_id TEXT PRIMARY KEY,
label TEXT,
added_at INTEGER NOT NULL
);
CREATE TABLE settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
-- Default exclusions: a seed list of common password-manager process names
-- (Windows `.exe`) and bundle ids (macOS `com.*`). See
-- `src-tauri/src/storage/migrations/001_initial.sql` for the full inserted set.
-- Default settings
INSERT INTO settings (key, value) VALUES
('schema_version', '1'),
('history_limit', '10000'),
('retention_days_pinned', 'unlimited'),
('retention_days_unpinned', '90'),
('retention_days_sensitive', '7'),
('retention_days_deleted', '30'),
('clipboard_poll_interval_ms', '500'),
('hotkey', 'Ctrl+Alt+V'),
('theme', 'system'),
('telemetry', 'off'),
('sync', 'off'),
('max_blob_mb', '50'),
('thumbnail_size_px', '192');The "Organize" feature settled over three migrations. Net result:
title(003) — an optional per-clip name, folded into the FTS index.- labels (005) — a clip carries MULTIPLE labels (
clip_labels). Auto- detection seeds the first; the user can add, rename (globally), and remove them. This replaced the singlecategoryidea (003 also created free-texttags/clip_tags, dropped by 004;categoryis left dead in the row, superseded byclip_labels). - favorite — the per-row star toggles the existing
pinnedflag.
-- 003: per-clip title (+ title-aware FTS triggers).
ALTER TABLE clips ADD COLUMN title TEXT;
-- 005: multi-label system. `auto_key` ties an auto label to a classifier key
-- so detection survives a rename; NULL for user-created labels.
CREATE TABLE clip_labels (
clip_id TEXT NOT NULL REFERENCES clips(id) ON DELETE CASCADE,
name TEXT NOT NULL,
auto_key TEXT, -- 'url','email',… for auto labels; NULL custom
added_at INTEGER NOT NULL,
PRIMARY KEY (clip_id, name)
);
CREATE INDEX idx_clip_labels_name ON clip_labels(name);
CREATE INDEX idx_clip_labels_clip ON clip_labels(clip_id);
-- 005 also migrates clips.category → seed labels, then drops idx_clips_category.Behavioural notes:
- Auto label on capture. For
kind='text'clips the pipeline runsclipboard::classify::classify(regex/heuristic, fully local — no model, no network) and, after insert, links the matching auto label viaStorage::link_auto_label. Default display names come fromclassify::auto_label_name(Bağlantı, E-posta, Kod, …); the stable key lives inauto_key. Keys:url,email,phone,iban,color,code,json,number,path. - User-managed labels.
add_label(custom; inheritsauto_keyif the name matches a known auto label so it keeps its color),remove_label, andrename_label(globalUPDATE OR REPLACE, preservingauto_keyso future auto-detections reuse the renamed label). Each bumps the clip'ssync_version.Storage::reclassify_allre-applies auto labels over history, preserving user-created ones. - Read path.
get_clip/list_clips/search_clipsattach ajson_group_array(json_object('name', …, 'autoKey', …))subquery aliasedlabels;row_to_clipparses it intoVec<Label>. - Title in FTS. The
clips_ai/ad/autriggers (003) indexfold(title + ' ' + text_content)and fire when a row has a title but no body (e.g. a named image). So a clip's title is searchable with the same Turkish-folded query path as its body. Display uses the original glyphs. - Favorite =
pinned. The per-row star toggles the existingpinnedflag: favorited clips sort first (ORDER BY pinned DESC) and are filterable via the popup's "Favoriler" chip. No separate column.
1. Receive clipboard event with raw bytes + metadata.
2. Normalize:
- text/html → both stored, kind=text (HTML in `text_content`, plain in derived view)
- RTF → strip to text, store text + raw RTF in `text_content` JSON wrapper
- image → re-encode to PNG (lossless), strip metadata
- file → store list of paths as JSON in text_content; kind='file'
3. SHA-256 hash of normalized bytes.
4. Sensitive regex scan (text-kind only).
5. Determine source app via foreground window query.
6. If source_app in excluded_apps → DROP, no DB write, no log.
7. Lookup hash in idx_clips_hash:
- If exists and deleted_at IS NULL → bump created_at; do not insert duplicate.
- Else → continue.
8. If kind ∈ {image, file}: write blob to %APPDATA%\Klipo\blobs\<hash[:2]>\<hash>.<ext>.
9. INSERT INTO clips (...) VALUES (...).
10. Triggers update FTS5.
11. Emit Tauri event "clip:new" with new id.
Steps 4-9 run on Tokio worker, NOT on the WindowProc thread (Windows) or pasteboard timer thread (macOS).
- Hash-based unique index on
content_hash WHERE deleted_at IS NULL. - "Same hash" means literal byte equality. Lossy normalization (e.g., RTF → text) is one-way; we hash post-normalization.
- Pasting the same text twice in a row → no new row; existing row gets bumped (
created_at = now,sync_version += 1). - Image dedup is exact-bit. Two screenshots taken seconds apart at the same coords are usually identical → dedup'd.
- Files with same path + same content (ino+mtime) generate identical blob → dedup. Files with same path but different content → different hash → two rows.
User-tunable in Settings. Defaults below.
| Cohort | Default Retention | Why |
|---|---|---|
| Pinned | Unlimited | User explicitly said "keep this." |
| Unpinned | 90 days | Balances "I copy URL last month" vs disk usage. |
| Sensitive | 7 days | Strong default to limit exposure. |
| Soft-deleted (tombstoned) | 30 days | Sync tombstone propagation; then physical delete. |
Runs daily at 03:00 local time (or on app start if more than 24h since last run):
-- Example: prune unpinned non-sensitive past retention
UPDATE clips
SET deleted_at = strftime('%s','now')*1000,
sync_version = sync_version + 1
WHERE deleted_at IS NULL
AND pinned = 0
AND sensitive = 0
AND created_at < (strftime('%s','now')*1000 - 90*86400000);
-- Hard-delete tombstones older than 30d
DELETE FROM clips
WHERE deleted_at IS NOT NULL
AND deleted_at < (strftime('%s','now')*1000 - 30*86400000);After hard delete: orphaned blobs swept by a separate job (compare blobs/ filesystem vs blob_path in DB; delete unreferenced; reverse check to detect missing blobs and surface as integrity warning).
Independent of date-based retention. If count(clips) > history_limit, drop oldest unpinned non-sensitive until under limit. Default history_limit = 10000.
DELETE FROM clips
WHERE id IN (
SELECT id FROM clips
WHERE deleted_at IS NULL
AND pinned = 0
AND sensitive = 0
ORDER BY created_at ASC
LIMIT (
SELECT COUNT(*) FROM clips WHERE deleted_at IS NULL
) - (SELECT CAST(value AS INTEGER) FROM settings WHERE key='history_limit')
);(In practice, batched prune so we don't lock for long.)
blobs/
├─ 0a/
│ └─ 0a3f...c2.png # SHA-256 named, real extension
├─ 1f/
│ └─ 1f8e...d4.bin # unknown binary → .bin
└─ ...
thumbs/
└─ 0a3f...c2-192.webp # WebP for size; 192 = px on long edge
Two-character hex sharding keeps any one folder under ~16k files even at 4M total (way past our 10k clip target).
Path stored in DB: Relative path from %APPDATA%\Klipo\blobs, e.g. 0a/0a3f...c2.png. Absolute paths derived at runtime via Tauri's path::AppLocalData.
Max blob size: 50MB hard cap. Larger clips drop with toast notification ("Image >50MB skipped"). Cap is a setting.
sqlx::migrate!() runs on app start. Migrations are forward-only.
src-tauri/src/storage/migrations/
├─ 001_initial.sql
├─ 002_turkish_fts.sql (FTS5 Turkish-ASCII fold; schema_version → 2)
├─ 003_organize.sql (title + tags + category index + title FTS; schema_version → 3)
├─ 004_drop_tags.sql (drop tags/clip_tags; schema_version → 4)
├─ 005_labels.sql (clip_labels multi-label + migrate category; schema_version → 5)
├─ 006_sync_columns.sql (v0.3 — populate hlc for existing rows)
└─ ...
settings.schema_version tracks the latest applied migration. On startup:
let current: u32 = get_setting("schema_version").await?.parse()?;
sqlx::migrate!("./src/storage/migrations").run(&pool).await?;
let new: u32 = latest_migration_version();
if new > current {
set_setting("schema_version", new.to_string()).await?;
log::info!("Migrated schema {} → {}", current, new);
}Before running ANY migration that's not 001:
- Copy
klipo.dbtoklipo.db.pre-mig-<old_version>.bak. - Run migration.
- On success, keep backup for 30 days then delete.
- On failure, abort startup, surface error, instruct user to restore.
This bound is small (DB is ≤200MB even at history_limit). Worth the disk for safety.
When sync first enables on a vault that has v0.1 data (no HLC):
Migration 004:
ALTER TABLE clips ADD COLUMN hlc TEXT;
UPDATE clips SET hlc = printf('%016x', (created_at << 16))
WHERE hlc IS NULL;
-- Devices catch up via sync; first push wins HLC ordering.
backup-2026-05-04.kpb (zip archive)
├─ manifest.json (version, vault_id, created_at, item_count)
├─ klipo.db (full SQLite snapshot)
├─ blobs/ (full blob mirror)
└─ signature.bin (Ed25519 signature over manifest)
The whole archive is encrypted under a separate backup key derived from a backup passphrase the user sets at export time. (Not the vault password — different threat model. User may want to share backup with a sysadmin without giving up vault.)
backup_key = Argon2id(backup_passphrase, salt=fresh_random)
ciphertext = XChaCha20Poly1305_encrypt(zip_bytes, backup_key, nonce=fresh_random)
stored = {
salt, nonce, ciphertext_size, ciphertext
}
Restore: prompt for backup passphrase; decrypt; replace current DB after confirmation.
For users who want to script processing of their data:
{
"version": 1,
"exported_at": "2026-05-04T12:34:56Z",
"items": [
{
"id": "01J9...",
"kind": "text",
"text": "...",
"created_at": 1746355200000,
"source_app": "Code.exe",
"pinned": true,
"category": null
},
...
]
}Default toggles in export dialog:
- Include sensitive items (default OFF)
- Include images and files (default OFF — they balloon size)
- Include pinned items
- Include settings
Plaintext exports come with a clear warning banner: "This file is unencrypted. Anyone who reads it can see all your clipboard history. Store it securely."
When OS reports low disk (<5% free or <500MB):
- Show non-blocking banner.
- Pause new image captures (text still allowed).
- Offer one-click "Free 200MB" that runs:
- Hard-delete tombstones immediately (instead of 30d wait).
- Re-encode large images at lower quality (or hard-prune images > X MB).
We never silently delete. Always confirm.
On app start (debounced; once per day per machine):
PRAGMA integrity_checkon the SQLite DB. Failure → block startup, surface "DB corrupt, restore from backup?"- Walk
blobs/directory: every file must correspond to aclips.blob_path. Orphans go toblobs/.orphans/(held 7d, then deleted) — gives recovery window. - For 1% random sample: verify file's actual SHA-256 matches its filename. Mismatch → quarantine + surface warning.
- Windows: NTFS handles the sharded layout fine. Defender may scan large blobs on write — we accept the latency hit (<150ms for 2MB).
- macOS: APFS clones blobs cheaply if user copies same image twice (we still hash + dedup at our layer; APFS is below us).
- Network drives: Detected at startup; if
%APPDATA%resolves to a network share, warn user (perf, durability). Allow override.
klipo-cli dump(v0.2) prints DB stats: total clips, by kind, blob size, retention buckets.klipo-cli vacuumrunsVACUUM+ orphan blob cleanup on demand.klipo-cli repairruns integrity checks + offers fixes.
These are debug/admin tools, not exposed in default UI.
- SQLCipher opt-in? v0.2 candidate. Pros: at-rest encryption even if
%APPDATA%is read by another local user (shouldn't happen on standard Windows perms but defensive). Cons: ~10% perf hit. Decision: opt-in toggle in v0.2, default off (relies on OS disk encryption). - Sync engine separate worker process or in-app thread? Worker process gives fault isolation (sync crash doesn't take down popup). In-app simpler. Decision deferred to v0.3.
- Compression of text content? ZSTD at 1KB threshold could halve text storage. Adds complexity. Decision: skip for v0.1; revisit at 100k clip user feedback.
- iCloud Drive / OneDrive auto-sync the DB? Strongly NO — corruption guaranteed. We refuse to start if storage path is on iCloud/OneDrive Documents folder; require move.