feat: multi-library foundation (schema + libraries module)
Adds a `libraries` registry table and threads library_id through per-instance metadata tables (image_exif, photo_insights, entity_photo_links, video_preview_clips). File-path columns renamed to rel_path to make the relative-to-root semantics explicit. Adds content_hash + size_bytes on image_exif to support future hash-keyed thumbnail/HLS dedup. Tags and favorites stay library-agnostic so they share across libraries by rel_path. Behavior is unchanged: a single primary library (id=1) is seeded from BASE_PATH on first boot; all handlers and DAOs route through it as a transitional shim until the API gains a library query param. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
155
migrations/2026-04-17-000000_multi_library/down.sql
Normal file
155
migrations/2026-04-17-000000_multi_library/down.sql
Normal file
@@ -0,0 +1,155 @@
|
||||
-- Revert multi-library support.
|
||||
-- Drops library_id/content_hash/size_bytes, renames rel_path back to the
|
||||
-- original column names, and drops the libraries table. Rows originally
|
||||
-- from non-primary libraries (id > 1) would be orphaned, so the rollback
|
||||
-- keeps only rows from library_id=1.
|
||||
|
||||
PRAGMA foreign_keys=OFF;
|
||||
|
||||
-- tagged_photo: rel_path → photo_name.
|
||||
DROP INDEX IF EXISTS idx_tagged_photo_relpath_tag;
|
||||
DROP INDEX IF EXISTS idx_tagged_photo_rel_path;
|
||||
ALTER TABLE tagged_photo RENAME COLUMN rel_path TO photo_name;
|
||||
CREATE INDEX IF NOT EXISTS idx_tagged_photo_photo_name ON tagged_photo(photo_name);
|
||||
CREATE INDEX IF NOT EXISTS idx_tagged_photo_count ON tagged_photo(photo_name, tag_id);
|
||||
|
||||
-- favorites: rel_path → path.
|
||||
DROP INDEX IF EXISTS idx_favorites_unique;
|
||||
DROP INDEX IF EXISTS idx_favorites_rel_path;
|
||||
ALTER TABLE favorites RENAME COLUMN rel_path TO path;
|
||||
CREATE INDEX IF NOT EXISTS idx_favorites_path ON favorites(path);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_favorites_unique ON favorites(userid, path);
|
||||
|
||||
-- video_preview_clips: drop library_id, rel_path → file_path.
|
||||
CREATE TABLE video_preview_clips_old (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
file_path TEXT NOT NULL UNIQUE,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
duration_seconds REAL,
|
||||
file_size_bytes INTEGER,
|
||||
error_message TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
INSERT INTO video_preview_clips_old (
|
||||
id, file_path, status, duration_seconds, file_size_bytes,
|
||||
error_message, created_at, updated_at
|
||||
)
|
||||
SELECT
|
||||
id, rel_path, status, duration_seconds, file_size_bytes,
|
||||
error_message, created_at, updated_at
|
||||
FROM video_preview_clips
|
||||
WHERE library_id = 1;
|
||||
|
||||
DROP TABLE video_preview_clips;
|
||||
ALTER TABLE video_preview_clips_old RENAME TO video_preview_clips;
|
||||
|
||||
CREATE INDEX idx_preview_clips_file_path ON video_preview_clips(file_path);
|
||||
CREATE INDEX idx_preview_clips_status ON video_preview_clips(status);
|
||||
|
||||
-- entity_photo_links: drop library_id, rel_path → file_path.
|
||||
CREATE TABLE entity_photo_links_old (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
entity_id INTEGER NOT NULL,
|
||||
file_path TEXT NOT NULL,
|
||||
role TEXT NOT NULL,
|
||||
CONSTRAINT fk_epl_entity FOREIGN KEY (entity_id) REFERENCES entities(id) ON DELETE CASCADE,
|
||||
UNIQUE(entity_id, file_path, role)
|
||||
);
|
||||
|
||||
INSERT INTO entity_photo_links_old (id, entity_id, file_path, role)
|
||||
SELECT id, entity_id, rel_path, role
|
||||
FROM entity_photo_links
|
||||
WHERE library_id = 1;
|
||||
|
||||
DROP TABLE entity_photo_links;
|
||||
ALTER TABLE entity_photo_links_old RENAME TO entity_photo_links;
|
||||
|
||||
CREATE INDEX idx_entity_photo_links_entity ON entity_photo_links(entity_id);
|
||||
CREATE INDEX idx_entity_photo_links_photo ON entity_photo_links(file_path);
|
||||
|
||||
-- photo_insights: drop library_id, rel_path → file_path.
|
||||
CREATE TABLE photo_insights_old (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
file_path TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
summary TEXT NOT NULL,
|
||||
generated_at BIGINT NOT NULL,
|
||||
model_version TEXT NOT NULL,
|
||||
is_current BOOLEAN NOT NULL DEFAULT 0,
|
||||
training_messages TEXT,
|
||||
approved BOOLEAN
|
||||
);
|
||||
|
||||
INSERT INTO photo_insights_old (
|
||||
id, file_path, title, summary, generated_at, model_version, is_current,
|
||||
training_messages, approved
|
||||
)
|
||||
SELECT
|
||||
id, rel_path, title, summary, generated_at, model_version, is_current,
|
||||
training_messages, approved
|
||||
FROM photo_insights
|
||||
WHERE library_id = 1;
|
||||
|
||||
DROP TABLE photo_insights;
|
||||
ALTER TABLE photo_insights_old RENAME TO photo_insights;
|
||||
|
||||
CREATE INDEX idx_photo_insights_file_path ON photo_insights(file_path);
|
||||
CREATE INDEX idx_photo_insights_current ON photo_insights(file_path, is_current);
|
||||
|
||||
-- image_exif: drop library_id/content_hash/size_bytes, rel_path → file_path.
|
||||
CREATE TABLE image_exif_old (
|
||||
id INTEGER PRIMARY KEY NOT NULL,
|
||||
file_path TEXT NOT NULL UNIQUE,
|
||||
camera_make TEXT,
|
||||
camera_model TEXT,
|
||||
lens_model TEXT,
|
||||
width INTEGER,
|
||||
height INTEGER,
|
||||
orientation INTEGER,
|
||||
gps_latitude REAL,
|
||||
gps_longitude REAL,
|
||||
gps_altitude REAL,
|
||||
focal_length REAL,
|
||||
aperture REAL,
|
||||
shutter_speed TEXT,
|
||||
iso INTEGER,
|
||||
date_taken BIGINT,
|
||||
created_time BIGINT NOT NULL,
|
||||
last_modified BIGINT NOT NULL
|
||||
);
|
||||
|
||||
INSERT INTO image_exif_old (
|
||||
id, file_path,
|
||||
camera_make, camera_model, lens_model,
|
||||
width, height, orientation,
|
||||
gps_latitude, gps_longitude, gps_altitude,
|
||||
focal_length, aperture, shutter_speed, iso, date_taken,
|
||||
created_time, last_modified
|
||||
)
|
||||
SELECT
|
||||
id, rel_path,
|
||||
camera_make, camera_model, lens_model,
|
||||
width, height, orientation,
|
||||
gps_latitude, gps_longitude, gps_altitude,
|
||||
focal_length, aperture, shutter_speed, iso, date_taken,
|
||||
created_time, last_modified
|
||||
FROM image_exif
|
||||
WHERE library_id = 1;
|
||||
|
||||
DROP TABLE image_exif;
|
||||
ALTER TABLE image_exif_old RENAME TO image_exif;
|
||||
|
||||
CREATE INDEX idx_image_exif_file_path ON image_exif(file_path);
|
||||
CREATE INDEX idx_image_exif_camera ON image_exif(camera_make, camera_model);
|
||||
CREATE INDEX idx_image_exif_gps ON image_exif(gps_latitude, gps_longitude);
|
||||
CREATE INDEX idx_image_exif_date_taken ON image_exif(date_taken);
|
||||
CREATE INDEX idx_image_exif_date_path ON image_exif(date_taken DESC, file_path);
|
||||
|
||||
-- Finally, drop the libraries registry.
|
||||
DROP TABLE libraries;
|
||||
|
||||
PRAGMA foreign_keys=ON;
|
||||
|
||||
ANALYZE;
|
||||
216
migrations/2026-04-17-000000_multi_library/up.sql
Normal file
216
migrations/2026-04-17-000000_multi_library/up.sql
Normal file
@@ -0,0 +1,216 @@
|
||||
-- Multi-library support.
|
||||
-- Adds `libraries` registry table and a `library_id` column on per-instance
|
||||
-- metadata tables. Renames `file_path` / `photo_name` to `rel_path` for
|
||||
-- semantic clarity (values already stored relative to BASE_PATH).
|
||||
-- Adds `content_hash` + `size_bytes` to `image_exif` to support
|
||||
-- content-based dedup of thumbnails and HLS output across libraries.
|
||||
--
|
||||
-- SQLite cannot alter column constraints in place, so per-instance tables
|
||||
-- are recreated following the idiom established in
|
||||
-- 2026-04-02-000000_photo_insights_history/up.sql. Existing row `id`s are
|
||||
-- preserved so foreign keys (entity_facts.source_insight_id, etc.) remain
|
||||
-- valid after migration.
|
||||
|
||||
PRAGMA foreign_keys=OFF;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 1. Libraries registry.
|
||||
-- Seeded with a placeholder for the primary library; AppState patches
|
||||
-- `root_path` from the BASE_PATH env var on first boot. Subsequent
|
||||
-- prod-to-dev DB syncs update this row via a single SQL UPDATE.
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE TABLE libraries (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
root_path TEXT NOT NULL,
|
||||
created_at BIGINT NOT NULL
|
||||
);
|
||||
|
||||
INSERT INTO libraries (id, name, root_path, created_at)
|
||||
VALUES (1, 'main', 'BASE_PATH_PLACEHOLDER', strftime('%s','now'));
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 2. image_exif: + library_id, file_path → rel_path, + content_hash/size_bytes.
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE TABLE image_exif_new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
library_id INTEGER NOT NULL REFERENCES libraries(id),
|
||||
rel_path TEXT NOT NULL,
|
||||
|
||||
-- Camera information
|
||||
camera_make TEXT,
|
||||
camera_model TEXT,
|
||||
lens_model TEXT,
|
||||
|
||||
-- Image properties
|
||||
width INTEGER,
|
||||
height INTEGER,
|
||||
orientation INTEGER,
|
||||
|
||||
-- GPS
|
||||
gps_latitude REAL,
|
||||
gps_longitude REAL,
|
||||
gps_altitude REAL,
|
||||
|
||||
-- Capture settings
|
||||
focal_length REAL,
|
||||
aperture REAL,
|
||||
shutter_speed TEXT,
|
||||
iso INTEGER,
|
||||
date_taken BIGINT,
|
||||
|
||||
-- Housekeeping
|
||||
created_time BIGINT NOT NULL,
|
||||
last_modified BIGINT NOT NULL,
|
||||
|
||||
-- Content identity (backfilled by the `backfill_hashes` binary and by the watcher for new files)
|
||||
content_hash TEXT,
|
||||
size_bytes BIGINT,
|
||||
|
||||
UNIQUE(library_id, rel_path)
|
||||
);
|
||||
|
||||
INSERT INTO image_exif_new (
|
||||
id, library_id, rel_path,
|
||||
camera_make, camera_model, lens_model,
|
||||
width, height, orientation,
|
||||
gps_latitude, gps_longitude, gps_altitude,
|
||||
focal_length, aperture, shutter_speed, iso, date_taken,
|
||||
created_time, last_modified
|
||||
)
|
||||
SELECT
|
||||
id, 1, file_path,
|
||||
camera_make, camera_model, lens_model,
|
||||
width, height, orientation,
|
||||
gps_latitude, gps_longitude, gps_altitude,
|
||||
focal_length, aperture, shutter_speed, iso, date_taken,
|
||||
created_time, last_modified
|
||||
FROM image_exif;
|
||||
|
||||
DROP TABLE image_exif;
|
||||
ALTER TABLE image_exif_new RENAME TO image_exif;
|
||||
|
||||
CREATE INDEX idx_image_exif_rel_path ON image_exif(rel_path);
|
||||
CREATE INDEX idx_image_exif_camera ON image_exif(camera_make, camera_model);
|
||||
CREATE INDEX idx_image_exif_gps ON image_exif(gps_latitude, gps_longitude);
|
||||
CREATE INDEX idx_image_exif_date_taken ON image_exif(date_taken);
|
||||
CREATE INDEX idx_image_exif_date_path ON image_exif(date_taken DESC, rel_path);
|
||||
CREATE INDEX idx_image_exif_lib_date ON image_exif(library_id, date_taken);
|
||||
CREATE INDEX idx_image_exif_content_hash ON image_exif(content_hash);
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 3. photo_insights: + library_id, file_path → rel_path.
|
||||
-- Preserve `id` so entity_facts.source_insight_id FKs remain valid.
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE TABLE photo_insights_new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
library_id INTEGER NOT NULL REFERENCES libraries(id),
|
||||
rel_path TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
summary TEXT NOT NULL,
|
||||
generated_at BIGINT NOT NULL,
|
||||
model_version TEXT NOT NULL,
|
||||
is_current BOOLEAN NOT NULL DEFAULT 0,
|
||||
training_messages TEXT,
|
||||
approved BOOLEAN
|
||||
);
|
||||
|
||||
INSERT INTO photo_insights_new (
|
||||
id, library_id, rel_path, title, summary, generated_at, model_version,
|
||||
is_current, training_messages, approved
|
||||
)
|
||||
SELECT
|
||||
id, 1, file_path, title, summary, generated_at, model_version,
|
||||
is_current, training_messages, approved
|
||||
FROM photo_insights;
|
||||
|
||||
DROP TABLE photo_insights;
|
||||
ALTER TABLE photo_insights_new RENAME TO photo_insights;
|
||||
|
||||
CREATE INDEX idx_photo_insights_rel_path ON photo_insights(rel_path);
|
||||
CREATE INDEX idx_photo_insights_current ON photo_insights(library_id, rel_path, is_current);
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 4. entity_photo_links: + library_id, file_path → rel_path.
|
||||
-- Preserves entity FK; UNIQUE now includes library_id to allow the same
|
||||
-- rel_path to link entities in multiple libraries independently.
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE TABLE entity_photo_links_new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
entity_id INTEGER NOT NULL,
|
||||
library_id INTEGER NOT NULL REFERENCES libraries(id),
|
||||
rel_path TEXT NOT NULL,
|
||||
role TEXT NOT NULL,
|
||||
CONSTRAINT fk_epl_entity FOREIGN KEY (entity_id) REFERENCES entities(id) ON DELETE CASCADE,
|
||||
UNIQUE(entity_id, library_id, rel_path, role)
|
||||
);
|
||||
|
||||
INSERT INTO entity_photo_links_new (id, entity_id, library_id, rel_path, role)
|
||||
SELECT id, entity_id, 1, file_path, role FROM entity_photo_links;
|
||||
|
||||
DROP TABLE entity_photo_links;
|
||||
ALTER TABLE entity_photo_links_new RENAME TO entity_photo_links;
|
||||
|
||||
CREATE INDEX idx_entity_photo_links_entity ON entity_photo_links(entity_id);
|
||||
CREATE INDEX idx_entity_photo_links_photo ON entity_photo_links(library_id, rel_path);
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 5. video_preview_clips: + library_id, file_path → rel_path.
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE TABLE video_preview_clips_new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
library_id INTEGER NOT NULL REFERENCES libraries(id),
|
||||
rel_path TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
duration_seconds REAL,
|
||||
file_size_bytes INTEGER,
|
||||
error_message TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
UNIQUE(library_id, rel_path)
|
||||
);
|
||||
|
||||
INSERT INTO video_preview_clips_new (
|
||||
id, library_id, rel_path, status, duration_seconds, file_size_bytes,
|
||||
error_message, created_at, updated_at
|
||||
)
|
||||
SELECT
|
||||
id, 1, file_path, status, duration_seconds, file_size_bytes,
|
||||
error_message, created_at, updated_at
|
||||
FROM video_preview_clips;
|
||||
|
||||
DROP TABLE video_preview_clips;
|
||||
ALTER TABLE video_preview_clips_new RENAME TO video_preview_clips;
|
||||
|
||||
CREATE INDEX idx_preview_clips_rel_path ON video_preview_clips(rel_path);
|
||||
CREATE INDEX idx_preview_clips_status ON video_preview_clips(status);
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 6. favorites: path → rel_path. Library-agnostic (cross-library sharing).
|
||||
-- ---------------------------------------------------------------------------
|
||||
ALTER TABLE favorites RENAME COLUMN path TO rel_path;
|
||||
|
||||
DROP INDEX IF EXISTS idx_favorites_path;
|
||||
DROP INDEX IF EXISTS idx_favorites_unique;
|
||||
CREATE INDEX idx_favorites_rel_path ON favorites(rel_path);
|
||||
CREATE UNIQUE INDEX idx_favorites_unique ON favorites(userid, rel_path);
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 7. tagged_photo: photo_name → rel_path. Library-agnostic.
|
||||
-- Dedup first so the (rel_path, tag_id) unique index can be created safely.
|
||||
-- ---------------------------------------------------------------------------
|
||||
ALTER TABLE tagged_photo RENAME COLUMN photo_name TO rel_path;
|
||||
|
||||
DELETE FROM tagged_photo
|
||||
WHERE id NOT IN (
|
||||
SELECT MIN(id) FROM tagged_photo GROUP BY rel_path, tag_id
|
||||
);
|
||||
|
||||
DROP INDEX IF EXISTS idx_tagged_photo_photo_name;
|
||||
DROP INDEX IF EXISTS idx_tagged_photo_count;
|
||||
CREATE INDEX idx_tagged_photo_rel_path ON tagged_photo(rel_path);
|
||||
CREATE UNIQUE INDEX idx_tagged_photo_relpath_tag ON tagged_photo(rel_path, tag_id);
|
||||
|
||||
PRAGMA foreign_keys=ON;
|
||||
|
||||
ANALYZE;
|
||||
Reference in New Issue
Block a user