Skip to main content

Database Schema

mahpastes uses SQLite for local data storage. The database file is clips.db stored in the platform-specific data directory.

Tables

clips

Stores all clipboard content.

CREATE TABLE clips (
id INTEGER PRIMARY KEY AUTOINCREMENT,
content_type TEXT NOT NULL,
data BLOB NOT NULL,
filename TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
is_archived INTEGER DEFAULT 0,
expires_at DATETIME,
content_hash TEXT DEFAULT '',
metadata TEXT DEFAULT '{}'
);
ColumnTypeDescription
idINTEGERAuto-incrementing primary key
content_typeTEXTMIME type (e.g., "image/png", "text/plain")
dataBLOBRaw binary content
filenameTEXTOriginal filename (nullable)
created_atDATETIMETimestamp of creation
is_archivedINTEGER0 = active, 1 = archived
expires_atDATETIMEAuto-delete timestamp (nullable)
content_hashTEXTSHA-256 hash for duplicate detection (empty string default)
metadataTEXTJSON object of user-defined key-value pairs

Indexes:

  • Primary key on id
  • Index on content_hash for duplicate group queries

watched_folders

Configuration for folders being watched.

CREATE TABLE watched_folders (
id INTEGER PRIMARY KEY AUTOINCREMENT,
path TEXT NOT NULL UNIQUE,
filter_mode TEXT NOT NULL DEFAULT 'all',
filter_presets TEXT,
filter_regex TEXT,
process_existing INTEGER DEFAULT 0,
auto_archive INTEGER DEFAULT 0,
is_paused INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
ColumnTypeDescription
idINTEGERAuto-incrementing primary key
pathTEXTAbsolute path to folder (unique)
filter_modeTEXT"all", "presets", or "custom"
filter_presetsTEXTJSON array of preset names
filter_regexTEXTRegex pattern for custom filter
process_existingINTEGERImport existing files on add
auto_archiveINTEGERArchive imports automatically
is_pausedINTEGERPer-folder pause state
created_atDATETIMEWhen folder was added

Constraints:

  • path must be unique

Migrations:

ALTER TABLE watched_folders ADD COLUMN auto_tag_id INTEGER
ColumnTypeDescription
auto_tag_idINTEGERTag ID to auto-apply on import (nullable)

settings

Application settings as key-value pairs.

CREATE TABLE settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
ColumnTypeDescription
keyTEXTSetting name (primary key)
valueTEXTSetting value

Current settings:

KeyValuesDescription
global_watch_paused"true" / "false"Global watching pause state
hidden_tagsJSON array of int64Tag IDs to hide from gallery by default

tags

Stores tag definitions for organizing clips.

CREATE TABLE tags (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
color TEXT NOT NULL
);

Note: Colors are auto-assigned from a palette when creating tags, not via a SQL default.

ColumnTypeDescription
idINTEGERAuto-incrementing primary key
nameTEXTTag name (unique)
colorTEXTHex color code for display

Tag hierarchy convention: There is no parent_id column or separate hierarchy table. Tag hierarchy is derived entirely from the / separator in tag names. For example, the tag work/client1/projectABC is a child of work/client1, which is a child of work. Descendant queries use LIKE 'prefix/%' against the name column. This keeps the schema flat and avoids recursive joins.

clip_tags

Junction table linking clips to tags (many-to-many).

CREATE TABLE clip_tags (
clip_id INTEGER NOT NULL,
tag_id INTEGER NOT NULL,
PRIMARY KEY (clip_id, tag_id),
FOREIGN KEY (clip_id) REFERENCES clips(id) ON DELETE CASCADE,
FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE
);
ColumnTypeDescription
clip_idINTEGERForeign key to clips table
tag_idINTEGERForeign key to tags table

Constraints:

  • Composite primary key on (clip_id, tag_id)
  • Cascading deletes when clip or tag is removed

plugins

Stores installed plugin metadata and state.

CREATE TABLE plugins (
id INTEGER PRIMARY KEY AUTOINCREMENT,
filename TEXT UNIQUE NOT NULL,
name TEXT NOT NULL,
version TEXT,
enabled INTEGER DEFAULT 1,
status TEXT DEFAULT 'enabled',
error_count INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
ColumnTypeDescription
idINTEGERAuto-incrementing primary key
filenameTEXTPlugin filename (unique)
nameTEXTHuman-readable plugin name
versionTEXTPlugin version string (nullable)
enabledINTEGER0 = disabled, 1 = enabled
statusTEXTCurrent status (enabled, error, disabled)
error_countINTEGERNumber of runtime errors
created_atDATETIMEWhen plugin was installed

Migration:

ALTER TABLE plugins ADD COLUMN source_url TEXT DEFAULT ''
ColumnTypeDescription
source_urlTEXTURL the plugin was installed from (empty for local imports)

plugin_permissions

Stores granted permissions for plugins.

CREATE TABLE plugin_permissions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
plugin_id INTEGER NOT NULL,
permission_type TEXT NOT NULL,
path TEXT NOT NULL,
granted_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (plugin_id) REFERENCES plugins(id) ON DELETE CASCADE
);

Migration adds pending_reconfirm column:

ALTER TABLE plugin_permissions ADD COLUMN pending_reconfirm INTEGER DEFAULT 0
ColumnTypeDescription
idINTEGERAuto-incrementing primary key
plugin_idINTEGERForeign key to plugins table
permission_typeTEXTPermission type (http, fs, etc.)
pathTEXTSpecific path/domain granted
granted_atDATETIMEWhen permission was granted
pending_reconfirmINTEGER1 if permission needs re-confirmation after restore

plugin_storage

Key-value storage scoped to individual plugins.

CREATE TABLE plugin_storage (
plugin_id INTEGER NOT NULL,
key TEXT NOT NULL,
value BLOB,
PRIMARY KEY (plugin_id, key),
FOREIGN KEY (plugin_id) REFERENCES plugins(id) ON DELETE CASCADE
);
ColumnTypeDescription
plugin_idINTEGERForeign key to plugins table
keyTEXTStorage key
valueBLOBStored value (JSON-encoded)

Constraints:

  • Composite primary key on (plugin_id, key)
  • Cascading delete when plugin is removed

app_settings

Application settings used by internal services (e.g., plugin update interval).

CREATE TABLE IF NOT EXISTS app_settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
ColumnTypeDescription
keyTEXTSetting name (primary key)
valueTEXTSetting value

Current settings:

KeyValuesDescription
plugin_update_interval"startup", "6h", "24h", "disabled"How often to check for plugin updates

api_keys

Stores hashed API keys for REST API authentication.

CREATE TABLE IF NOT EXISTS api_keys (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
key_hash TEXT NOT NULL UNIQUE,
key_prefix TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'viewer',
scoped_tag_id INTEGER,
is_revoked INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
last_used_at DATETIME,
FOREIGN KEY (scoped_tag_id) REFERENCES tags(id) ON DELETE CASCADE
);
ColumnTypeDescription
idINTEGERAuto-incrementing primary key
nameTEXTHuman-readable key name
key_hashTEXTSHA-256 hash of the raw key (unique)
key_prefixTEXTFirst 8 characters of the raw key, for identification in listings
roleTEXT"viewer" (read-only, default), "editor" (create/modify clips and tags), or "admin" (full access)
scoped_tag_idINTEGERTag ID to restrict access to (nullable — NULL means global access)
is_revokedINTEGER0 = active, 1 = revoked
created_atDATETIMEWhen the key was created
last_used_atDATETIMELast time the key was used for authentication (nullable)

Constraints:

  • key_hash must be unique
  • Foreign key on scoped_tag_id with cascading delete when tag is removed
  • Raw keys are never stored — only the SHA-256 hash is persisted. The raw key is returned once at creation time and cannot be retrieved afterward.

Schema Migrations

Migrations are handled inline in initDB():

// Initial table creation
db.Exec(createTableSQL)

// Migrations (idempotent - ALTER TABLE silently fails if column exists)
db.Exec("ALTER TABLE clips ADD COLUMN is_archived INTEGER DEFAULT 0")
db.Exec("ALTER TABLE clips ADD COLUMN expires_at DATETIME")
db.Exec("ALTER TABLE clips ADD COLUMN content_hash TEXT DEFAULT ''")
db.Exec("CREATE INDEX IF NOT EXISTS idx_clips_content_hash ON clips(content_hash)")
db.Exec("ALTER TABLE clips ADD COLUMN metadata TEXT DEFAULT '{}'")
db.Exec("ALTER TABLE watched_folders ADD COLUMN auto_tag_id INTEGER")
db.Exec("ALTER TABLE plugin_permissions ADD COLUMN pending_reconfirm INTEGER DEFAULT 0")
db.Exec("ALTER TABLE plugins ADD COLUMN source_url TEXT DEFAULT ''")

Migrations use ALTER TABLE which silently fails if column exists.

Database Configuration

DSN-Based Pragmas

All pragmas are set in the DSN string so they apply to every pooled connection:

dsn := dbPath +
"?_pragma=busy_timeout%3D5000" +
"&_pragma=journal_mode%3Dwal" +
"&_pragma=foreign_keys%3Don"
db, err := sql.Open("sqlite", dsn)
PragmaValuePurpose
busy_timeout5000Wait up to 5 seconds when database is locked
journal_modeWALWrite-Ahead Logging for better read/write concurrency
foreign_keysONRequired for CASCADE deletes on clip_tags, plugin_permissions, plugin_storage, api_keys

Setting pragmas via DSN (not db.Exec) is important because sql.Open may use a connection pool. A db.Exec("PRAGMA ...") call only applies to one connection, while DSN-based pragmas apply to all connections the pool creates.

Closed on application shutdown:

func (a *App) shutdown(ctx context.Context) {
if a.db != nil {
a.db.Close()
}
}

Common Queries

Basic query (no tag filters):

SELECT c.id, c.content_type, c.filename, c.created_at, c.expires_at,
SUBSTR(c.data, 1, 500), c.is_archived, LENGTH(c.data),
(SELECT COUNT(*) FROM clips c2
WHERE c2.content_hash = c.content_hash
AND c2.content_hash != '' AND c2.id != c.id)
FROM clips c
WHERE c.is_archived = ?
AND (c.expires_at IS NULL OR c.expires_at > CURRENT_TIMESTAMP)
ORDER BY c.created_at DESC
LIMIT 50

Note: Only first 500 bytes of data fetched for preview. LENGTH(c.data) provides the file size and the subquery counts duplicates sharing the same content_hash. When tag filters or hidden tags are active, the query uses EXISTS/NOT EXISTS with clip_tags for filtering.

Get full clip data

SELECT content_type, data, filename
FROM clips
WHERE id = ?

Insert new clip

-- UploadFileAndGetID (single file, no expiration)
INSERT INTO clips (content_type, data, filename, content_hash)
VALUES (?, ?, ?, ?)

-- UploadFiles (batch upload with optional expiration)
INSERT INTO clips (content_type, data, filename, expires_at, content_hash)
VALUES (?, ?, ?, ?, ?)

Toggle archive status

UPDATE clips
SET is_archived = NOT is_archived
WHERE id = ?

Delete expired clips

DELETE FROM clips
WHERE expires_at IS NOT NULL
AND expires_at <= CURRENT_TIMESTAMP

Runs every 60 seconds via cleanup job.

Bulk operations

-- Bulk delete
DELETE FROM clips WHERE id IN (?, ?, ?)

-- Bulk archive (always archives — does not toggle)
UPDATE clips SET is_archived = 1 WHERE id IN (?, ?, ?)

Data Storage Paths

PlatformPath
macOS~/Library/Application Support/mahpastes/clips.db
Windows%APPDATA%\mahpastes\clips.db
Linux~/.config/mahpastes/clips.db

Backup

To backup your data:

# macOS
cp ~/Library/Application\ Support/mahpastes/clips.db backup.db

# Windows
copy %APPDATA%\mahpastes\clips.db backup.db

# Linux
cp ~/.config/mahpastes/clips.db backup.db

Data Size Considerations

  • Each clip stores full binary data
  • Large images/files can grow database quickly
  • No automatic cleanup except expiration
  • Consider archiving + periodically clearing old clips

Viewing the Database

Use any SQLite viewer:

# Command line
sqlite3 ~/Library/Application\ Support/mahpastes/clips.db

# Queries
.schema -- Show all tables
SELECT * FROM clips LIMIT 5;
SELECT * FROM watched_folders;
SELECT * FROM settings;

Popular GUI tools:

  • DB Browser for SQLite
  • TablePlus
  • DBeaver