···11-CREATE TABLE IF NOT EXISTS posts (
22- id INTEGER UNIQUE PRIMARY KEY AUTOINCREMENT,
33- user TEXT NOT NULL,
44- service TEXT NOT NULL,
55- identifier TEXT NOT NULL,
66- parent INTEGER NULL REFERENCES posts(id),
77- root INTEGER NULL REFERENCES posts(id),
88- reposted INTEGER NULL REFERENCES posts(id),
99- extra_data TEXT NULL
1010-);
1111-1212-CREATE TABLE IF NOT EXISTS mappings (
1313- original INTEGER NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
1414- mapped INTEGER NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
1515- UNIQUE(original, mapped)
1616-);
+21
migrations/001_initdb_v1.py
···11+import sqlite3
22+33+44+def migrate(conn: sqlite3.Connection):
55+ _ = conn.execute("""
66+ CREATE TABLE IF NOT EXISTS posts (
77+ id INTEGER PRIMARY KEY AUTOINCREMENT,
88+ user_id TEXT NOT NULL,
99+ service TEXT NOT NULL,
1010+ identifier TEXT NOT NULL,
1111+ parent_id INTEGER NULL REFERENCES posts(id) ON DELETE SET NULL,
1212+ root_id INTEGER NULL REFERENCES posts(id) ON DELETE SET NULL
1313+ );
1414+ """)
1515+ _ = conn.execute("""
1616+ CREATE TABLE IF NOT EXISTS mappings (
1717+ original_post_id INTEGER NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
1818+ mapped_post_id INTEGER NOT NULL
1919+ );
2020+ """)
2121+ pass
-5
migrations/002_add_indexes.sql
···11-CREATE INDEX IF NOT EXISTS idx_posts_service_user_identifier
22-ON posts (service, user, identifier);
33-44-CREATE UNIQUE INDEX IF NOT EXISTS ux_mappings_original_mapped
55-ON mappings (original, mapped);
+11
migrations/002_add_reposted_column_v1.py
···11+import sqlite3
22+33+44+def migrate(conn: sqlite3.Connection):
55+ columns = conn.execute("PRAGMA table_info(posts)")
66+ column_names = [col[1] for col in columns]
77+ if "reposted_id" not in column_names:
88+ _ = conn.execute("""
99+ ALTER TABLE posts
1010+ ADD COLUMN reposted_id INTEGER NULL REFERENCES posts(id) ON DELETE SET NULL
1111+ """)
+22
migrations/003_add_extra_data_column_v1.py
···11+import json
22+import sqlite3
33+44+55+def migrate(conn: sqlite3.Connection):
66+ columns = conn.execute("PRAGMA table_info(posts)")
77+ column_names = [col[1] for col in columns]
88+ if "extra_data" not in column_names:
99+ _ = conn.execute("""
1010+ ALTER TABLE posts
1111+ ADD COLUMN extra_data TEXT NULL
1212+ """)
1313+1414+ # migrate old bsky identifiers from json to uri as id and cid in extra_data
1515+ data = conn.execute("SELECT id, identifier FROM posts WHERE service = 'https://bsky.app';").fetchall()
1616+ rewrites: list[tuple[str, str, int]] = []
1717+ for row in data:
1818+ if row[1][0] == '{' and row[1][-1] == '}':
1919+ data = json.loads(row[1])
2020+ rewrites.append((data['uri'], json.dumps({'cid': data['cid']}), row[0]))
2121+ if rewrites:
2222+ _ = conn.executemany("UPDATE posts SET identifier = ?, extra_data = ? WHERE id = ?;", rewrites)
+52
migrations/004_initdb_next.py
···11+import sqlite3
22+33+44+def migrate(conn: sqlite3.Connection):
55+ cursor = conn.cursor()
66+77+ old_posts = cursor.execute("SELECT * FROM posts;").fetchall()
88+ old_mappings = cursor.execute("SELECT * FROM mappings;").fetchall()
99+1010+ _ = cursor.execute("DROP TABLE posts;")
1111+ _ = cursor.execute("DROP TABLE mappings;")
1212+1313+ _ = cursor.execute("""
1414+ CREATE TABLE posts (
1515+ id INTEGER UNIQUE PRIMARY KEY AUTOINCREMENT,
1616+ user TEXT NOT NULL,
1717+ service TEXT NOT NULL,
1818+ identifier TEXT NOT NULL,
1919+ parent INTEGER NULL REFERENCES posts(id),
2020+ root INTEGER NULL REFERENCES posts(id),
2121+ reposted INTEGER NULL REFERENCES posts(id),
2222+ extra_data TEXT NULL
2323+ );
2424+ """)
2525+2626+ _ = cursor.execute("""
2727+ CREATE TABLE mappings (
2828+ original INTEGER NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
2929+ mapped INTEGER NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
3030+ UNIQUE(original, mapped)
3131+ );
3232+ """)
3333+3434+ for old_post in old_posts:
3535+ _ = cursor.execute(
3636+ """
3737+ INSERT INTO posts (id, user, service, identifier, parent, root, reposted, extra_data)
3838+ VALUES (:id, :user_id, :service, :identifier, :parent_id, :root_id, :reposted_id, :extra_data)
3939+ """,
4040+ dict(old_post),
4141+ )
4242+4343+ for mapping in old_mappings:
4444+ original, mapped = mapping["original_post_id"], mapping["mapped_post_id"]
4545+ _ = cursor.execute(
4646+ "INSERT OR IGNORE INTO mappings (original, mapped) VALUES (?, ?)",
4747+ (original, mapped),
4848+ )
4949+ _ = cursor.execute(
5050+ "INSERT OR IGNORE INTO mappings (original, mapped) VALUES (?, ?)",
5151+ (mapped, original),
5252+ )
+12
migrations/005_add_indexes.py
···11+import sqlite3
22+33+44+def migrate(conn: sqlite3.Connection):
55+ _ = conn.execute("""
66+ CREATE INDEX IF NOT EXISTS idx_posts_service_user_identifier
77+ ON posts (service, user, identifier);
88+ """)
99+ _ = conn.execute("""
1010+ CREATE UNIQUE INDEX IF NOT EXISTS ux_mappings_original_mapped
1111+ ON mappings (original, mapped);
1212+ """)
+35
migrations/_registry.py
···11+import importlib.util
22+from pathlib import Path
33+import sqlite3
44+from typing import Callable
55+66+77+def load_migrations(path: Path) -> list[tuple[int, str, Callable[[sqlite3.Connection], None]]]:
88+ migrations: list[tuple[int, str, Callable[[sqlite3.Connection], None]]] = []
99+ migration_files = sorted(
1010+ [f for f in path.glob("*.py") if not f.stem.startswith("_")]
1111+ )
1212+1313+ for filepath in migration_files:
1414+ filename = filepath.stem
1515+ version_str = filename.split("_")[0]
1616+1717+ try:
1818+ version = int(version_str)
1919+ except ValueError:
2020+ raise ValueError('migrations must start with a number!!')
2121+2222+ spec = importlib.util.spec_from_file_location(filepath.stem, filepath)
2323+ if not spec or not spec.loader:
2424+ raise Exception(f"Failed to load spec from file: {filepath}")
2525+2626+ module = importlib.util.module_from_spec(spec)
2727+ spec.loader.exec_module(module)
2828+2929+ if hasattr(module, "migrate"):
3030+ migrations.append((version, filename, module.migrate))
3131+ else:
3232+ raise ValueError(f"Migration {filepath.name} missing 'migrate' function")
3333+3434+ migrations.sort(key=lambda x: x[0])
3535+ return migrations