feat(metadata): SQLite backend, storage reconciliation and local defaults

- pluggable per-domain Repository interfaces; PostgreSQL and SQLite impls
- SQLite on modernc.org/sqlite (WAL, busy_timeout, foreign_keys, immediate tx)
- separate migrations/sqlite and db.MigrateSQLite; store.NewPostgres/NewSQLite
- cmd/reconcile compares object storage with the journal (report, then --delete)
- storage.Backend.List and photos.Repository.AllStorageKeys
- DB_BACKEND/SQLITE_PATH and STORAGE_BACKEND/FS_ROOT selectable; local defaults
  (sqlite ./galo.db, fs ./data)
- Makefile reconcile/reconcile-delete; docs and .env.example updated
This commit is contained in:
iu
2026-10-11 10:08:31 +03:00
parent ec8d907565
commit 176a8d8ced
69 changed files with 4174 additions and 95 deletions
@@ -0,0 +1,15 @@
DROP TABLE IF EXISTS variant_jobs;
DROP TABLE IF EXISTS photo_objects;
DROP TABLE IF EXISTS objects;
DROP TABLE IF EXISTS photo_tags;
DROP TABLE IF EXISTS tags;
DROP TABLE IF EXISTS refresh_tokens;
DROP TABLE IF EXISTS share_links;
DROP TABLE IF EXISTS notifications;
DROP TABLE IF EXISTS likes;
DROP TABLE IF EXISTS comments;
DROP TABLE IF EXISTS invites;
DROP TABLE IF EXISTS album_members;
DROP TABLE IF EXISTS photos;
DROP TABLE IF EXISTS albums;
DROP TABLE IF EXISTS users;
@@ -0,0 +1,159 @@
-- Consolidated SQLite schema equivalent to the PostgreSQL migration history.
CREATE TABLE users (
id TEXT PRIMARY KEY,
email TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
name TEXT NOT NULL,
avatar_url TEXT,
preferences TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
);
CREATE TABLE albums (
id TEXT PRIMARY KEY,
owner_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
title TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
cover_photo_id TEXT REFERENCES photos(id) ON DELETE SET NULL,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
);
CREATE TABLE photos (
id TEXT PRIMARY KEY,
album_id TEXT REFERENCES albums(id) ON DELETE SET NULL,
uploader_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
original_filename TEXT NOT NULL DEFAULT '',
taken_at TEXT,
latitude REAL,
longitude REAL,
caption TEXT NOT NULL DEFAULT '',
exif TEXT NOT NULL DEFAULT '{}',
deleted_at TEXT,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
);
CREATE INDEX photos_album_created_idx ON photos (album_id, created_at DESC, id DESC);
CREATE INDEX photos_deleted_idx ON photos (deleted_at) WHERE deleted_at IS NOT NULL;
CREATE TABLE album_members (
album_id TEXT NOT NULL REFERENCES albums(id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role TEXT NOT NULL,
invited_by TEXT REFERENCES users(id) ON DELETE SET NULL,
joined_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
PRIMARY KEY (album_id, user_id)
);
CREATE TABLE invites (
id TEXT PRIMARY KEY,
album_id TEXT NOT NULL REFERENCES albums(id) ON DELETE CASCADE,
token TEXT NOT NULL UNIQUE,
role TEXT NOT NULL,
created_by TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
expires_at TEXT NOT NULL,
max_uses INTEGER NOT NULL DEFAULT 1,
uses INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
);
CREATE TABLE comments (
id TEXT PRIMARY KEY,
photo_id TEXT NOT NULL REFERENCES photos(id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
text TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
);
CREATE INDEX comments_photo_idx ON comments (photo_id, created_at);
CREATE TABLE likes (
photo_id TEXT NOT NULL REFERENCES photos(id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
PRIMARY KEY (photo_id, user_id)
);
CREATE TABLE notifications (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
type TEXT NOT NULL,
payload_json TEXT NOT NULL DEFAULT '{}',
read_at TEXT,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
);
CREATE INDEX notifications_user_idx ON notifications (user_id, created_at DESC);
CREATE TABLE share_links (
id TEXT PRIMARY KEY,
album_id TEXT NOT NULL REFERENCES albums(id) ON DELETE CASCADE,
token TEXT NOT NULL UNIQUE,
created_by TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
expires_at TEXT NOT NULL,
revoked_at TEXT
);
CREATE TABLE refresh_tokens (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash TEXT NOT NULL UNIQUE,
expires_at TEXT NOT NULL,
revoked_at TEXT,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
);
CREATE INDEX refresh_tokens_user_idx ON refresh_tokens (user_id);
CREATE TABLE tags (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
);
CREATE UNIQUE INDEX tags_user_name_idx ON tags (user_id, lower(name));
CREATE TABLE photo_tags (
photo_id TEXT NOT NULL REFERENCES photos(id) ON DELETE CASCADE,
tag_id TEXT NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
PRIMARY KEY (photo_id, tag_id)
);
CREATE INDEX photo_tags_tag_idx ON photo_tags (tag_id);
CREATE TABLE objects (
id TEXT PRIMARY KEY,
storage_key TEXT NOT NULL UNIQUE,
mime_type TEXT NOT NULL DEFAULT '',
size INTEGER NOT NULL DEFAULT 0,
width INTEGER NOT NULL DEFAULT 0,
height INTEGER NOT NULL DEFAULT 0,
checksum TEXT NOT NULL DEFAULT '',
state TEXT NOT NULL DEFAULT 'pending'
CHECK (state IN ('pending', 'ready', 'deleting')),
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
);
CREATE INDEX objects_state_created_idx ON objects (state, created_at);
CREATE TABLE photo_objects (
photo_id TEXT NOT NULL REFERENCES photos(id) ON DELETE CASCADE,
object_id TEXT NOT NULL REFERENCES objects(id) ON DELETE RESTRICT,
role TEXT NOT NULL CHECK (role IN ('original', 'thumb', 'preview')),
PRIMARY KEY (photo_id, role)
);
CREATE INDEX photo_objects_object_idx ON photo_objects (object_id);
CREATE TABLE variant_jobs (
photo_id TEXT PRIMARY KEY REFERENCES photos(id) ON DELETE CASCADE,
attempts INTEGER NOT NULL DEFAULT 0,
next_attempt_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
last_error TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
);
CREATE INDEX variant_jobs_due_idx ON variant_jobs (next_attempt_at);