Files
URLNotesGrabberCore/retire-blognames.sql
T
jimandClaude Opus 5.5 8fe2ffeb96 fix(db): make Blogs.BlogId the only blog ID and drop BlogNames
Blogs.BlogId was a one-time copy of BlogNames and nothing kept it
current: 12,238 blogs first seen after 2026-08-07 had a BlogNames ID
but a NULL Blogs.BlogId, so GetBlogs' join on BlogId silently skipped
them and their 23,148 notes.

- retire-blognames.sql: stub Blogs rows for the 17 unregistered note
  participants, backfill IDs (none renumbered), make ix_Blogs_BlogId
  UNIQUE, drop BlogNames, and add triggers that stop a Blogs row with
  a BlogId from being deleted, renamed or renumbered
- AddNote registers both blogs via RegisterBlog (Blogs row + MAX+1 ID)
  and every query resolves names through Blogs instead of BlogNames
- verify-db-schema.sql reports a DB that still has BlogNames (1e)
- Update TL.db.md, AGENTS.md and the DB Browser saved queries

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-28 11:33:04 -05:00

144 lines
7.0 KiB
PL/PgSQL

-- retire-blognames.sql
-- Makes Blogs.BlogId the only ID authority for Notes and retires the BlogNames table.
--
-- WHY: normalize-notes.sql (2026-08-07) put the IDs in BlogNames and copied them into
-- Blogs.BlogId once. Nothing kept the copy current: by 2026-09-28, 12,238 blogs first seen
-- in a note after the migration had a BlogNames ID but Blogs.BlogId = NULL, so every query
-- joining Notes to Blogs on BlogId (GetBlogs and friends) silently skipped them -- 23,148
-- notes. Two copies of one ID drift; this leaves one.
--
-- What it does:
-- 1. Gives every BlogNames name a Blogs row (17 had none), carrying its ID over.
-- 2. Copies the ID onto every Blogs row that is missing it. IDs are never renumbered --
-- they are stored in 1.18M Notes rows.
-- 3. Proves every Notes ID resolves through Blogs before anything is dropped.
-- 4. Makes ix_Blogs_BlogId UNIQUE.
-- 5. Drops BlogNames. No compatibility view: any other app that still names it gets
-- "no such table: BlogNames" and must port to Blogs.BlogId (see TL.db.md).
-- 6. Adds triggers that stop a Blogs row holding a BlogId from being deleted, renamed or
-- renumbered -- the guarantees BlogNames gave by never being touched.
--
-- DateModified is NOT moved: assigning an ID is bookkeeping, not a content change. The 17
-- new stub rows get DateAdded/DateModified/DateCreated = now, as AddBlog would give them.
--
-- Runs after normalize-notes.sql. A backup from before 2026-08-07 needs both, in order.
--
-- HOW TO RUN:
-- 1. Stop every app that uses TL.db. Pause NextCloud sync.
-- 2. Back up TL.db: sqlite3 TL.db ".backup 'TL pre-retire-blognames.db'"
-- 3. sqlite3 -bail TL.db < retire-blognames.sql
-- -bail matters: a failed check aborts before COMMIT and nothing is changed.
-- In DB Browser, Execute SQL stops at the first error; then Revert Changes.
-- 4. Run the build of URLNotesGrabberCORE that no longer uses BlogNames. An older
-- build fails every AddNote with "no such table: BlogNames".
PRAGMA foreign_keys = off;
BEGIN;
-- Every check inserts one count here; the CHECK aborts the script on anything but 0.
CREATE TEMP TABLE MustBeZero (Check_ TEXT, n INTEGER CHECK (n = 0));
--------------------------------------------------------------------------
-- STEP 0: the two copies must not disagree anywhere they are both set
--------------------------------------------------------------------------
INSERT INTO MustBeZero
SELECT 'Blogs.BlogId differs from BlogNames', COUNT(*)
FROM Blogs b JOIN BlogNames bn ON bn.BlogName = b.BlogName
WHERE b.BlogId <> bn.BlogId;
INSERT INTO MustBeZero
SELECT 'Blogs.BlogId unknown to BlogNames', COUNT(*)
FROM Blogs b
WHERE b.BlogId IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM BlogNames bn WHERE bn.BlogId = b.BlogId AND bn.BlogName = b.BlogName);
--------------------------------------------------------------------------
-- STEP 1: a Blogs row for every name Notes points at
--------------------------------------------------------------------------
-- No IsActive in the column list: it is not ours to write (defaults to live).
INSERT INTO Blogs (BlogName, DateAdded, DateModified, DateCreated, BlogId)
SELECT bn.BlogName,
strftime('%Y-%m-%d %H:%M:%S', 'now', 'localtime'),
strftime('%Y-%m-%d %H:%M:%S', 'now', 'localtime'),
strftime('%Y-%m-%d %H:%M:%S', 'now', 'localtime'),
bn.BlogId
FROM BlogNames bn
WHERE NOT EXISTS (SELECT 1 FROM Blogs b WHERE b.BlogName = bn.BlogName);
--------------------------------------------------------------------------
-- STEP 2: backfill the IDs Blogs never received
--------------------------------------------------------------------------
UPDATE Blogs
SET BlogId = (SELECT bn.BlogId FROM BlogNames bn WHERE bn.BlogName = Blogs.BlogName)
WHERE BlogId IS NULL
AND BlogName IN (SELECT BlogName FROM BlogNames);
--------------------------------------------------------------------------
-- STEP 3: prove Blogs now holds exactly what BlogNames held
--------------------------------------------------------------------------
INSERT INTO MustBeZero
SELECT 'BlogNames pair missing from Blogs', COUNT(*)
FROM BlogNames bn
WHERE NOT EXISTS (SELECT 1 FROM Blogs b WHERE b.BlogId = bn.BlogId AND b.BlogName = bn.BlogName);
INSERT INTO MustBeZero
SELECT 'Blogs IDs vs BlogNames rows', (SELECT COUNT(*) FROM Blogs WHERE BlogId IS NOT NULL) - (SELECT COUNT(*) FROM BlogNames);
INSERT INTO MustBeZero
SELECT 'Notes.RootBlogId unresolved', COUNT(*)
FROM (SELECT DISTINCT RootBlogId AS Id FROM Notes) n
WHERE NOT EXISTS (SELECT 1 FROM Blogs b WHERE b.BlogId = n.Id);
INSERT INTO MustBeZero
SELECT 'Notes.NoteBlogId unresolved', COUNT(*)
FROM (SELECT DISTINCT NoteBlogId AS Id FROM Notes) n
WHERE NOT EXISTS (SELECT 1 FROM Blogs b WHERE b.BlogId = n.Id);
--------------------------------------------------------------------------
-- STEP 4: one row per ID
--------------------------------------------------------------------------
-- UNIQUE still allows the NULLs on the ~168k blogs that have never appeared in a note.
DROP INDEX ix_Blogs_BlogId;
CREATE UNIQUE INDEX ix_Blogs_BlogId ON Blogs (BlogId);
--------------------------------------------------------------------------
-- STEP 5: BlogNames goes
--------------------------------------------------------------------------
DROP TABLE BlogNames;
--------------------------------------------------------------------------
-- STEP 6: what BlogNames guaranteed by never being written
--------------------------------------------------------------------------
-- A deleted row would orphan its notes, and MAX(BlogId) + 1 in RegisterBlog could then
-- hand the same ID to a different blog. Remove a blog with IsActive = 0 instead.
CREATE TRIGGER trg_Blogs_BlogId_NoDelete
BEFORE DELETE ON Blogs
WHEN OLD.BlogId IS NOT NULL
BEGIN
SELECT RAISE(ABORT, 'Blogs row has a BlogId that Notes points at; set IsActive = 0 instead of deleting');
END;
-- A blog renamed upstream is a new blog to Tumblr's API and gets a new row. Editing the name
-- in place would re-attribute every note to it; changing the ID would orphan them.
CREATE TRIGGER trg_Blogs_BlogId_Immutable
BEFORE UPDATE OF BlogId, BlogName ON Blogs
WHEN OLD.BlogId IS NOT NULL
AND (NEW.BlogId IS NOT OLD.BlogId OR NEW.BlogName IS NOT OLD.BlogName)
BEGIN
SELECT RAISE(ABORT, 'BlogId and BlogName are fixed once a blog has a BlogId; Notes rows point at it');
END;
DROP TABLE temp.MustBeZero;
COMMIT;
--------------------------------------------------------------------------
-- VERIFY
--------------------------------------------------------------------------
-- SELECT COUNT(*) FROM sqlite_master WHERE name = 'BlogNames'; -- expect: 0
-- SELECT COUNT(*) FROM Blogs WHERE BlogId IS NOT NULL; -- expect: the old BlogNames row count
-- SELECT sql FROM sqlite_master WHERE name = 'ix_Blogs_BlogId'; -- expect: CREATE UNIQUE INDEX
-- SELECT name FROM sqlite_master WHERE type = 'trigger'; -- expect: both triggers
-- PRAGMA integrity_check; -- expect: ok