Completes the swap 0033 set up. layers and manifest_references move onto manifest_key and manifests.id is gone, which removes the last node-allocated identifier in the AppView schema. Statement order in 0034 is load-bearing. With foreign keys on, DROP TABLE performs an implicit DELETE FROM, so dropping manifests while layers still holds an ON DELETE CASCADE reference deletes every layer row. Migration 0009 did exactly that; it went unnoticed because the Jetstream backfill rebuilds layers from PDS records, so the damage healed itself. PRAGMA foreign_keys is no help: it is a no-op inside a transaction and migrations run in one. So the new children are built pointing at manifests_new, the old children are dropped first, and only then is the old manifests table dropped, by which point nothing references it. Verified both behaviors before relying on them. manifest_key is declared NOT NULL as well as PRIMARY KEY, because in SQLite a PRIMARY KEY column still accepts NULL unless it is INTEGER PRIMARY KEY. That constraint immediately caught four test helpers inserting manifests without one. Five queries used MAX(id) as "the newest manifest in this repo", which I had previously reported as absent after grepping only for ORDER BY. A derived key has no ordering, so recency now comes from created_at with manifest_key as a deterministic tiebreak. This is a real behavior change, and a fix: the two disagree whenever a manifest is indexed out of order, which the backfill does routinely, and created_at is the push time these queries always wanted. Both directions are tested, including that ties resolve the same way every run. InsertManifest and BatchInsertManifests no longer read anything back. The key is derived from (did, repository, digest), so the writer knows it before the statement runs: the select-back, its per-DID IN list, and the "manifest missing id after batch insert" branch all go away, along with the UNIQUE-conflict fallback that existed only to recover a rowid. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Database Migrations
This directory contains database migrations for the ATCR AppView database.
Schema vs Migrations
schema.sql (in parent directory) contains the complete base schema for fresh database installations. It includes all tables, indexes, and constraints.
Migrations (this directory) handle changes to existing databases:
CREATE TABLEstatements (see below — new tables need a migration too)ALTER TABLEstatements (add/modify/drop columns)UPDATEstatements (data transformations)DELETEstatements (data cleanup)- Creating/modifying indexes on existing tables
New tables go in BOTH places
InitDB skips schema.sql entirely once schema_migrations has any rows (see
hasAppliedMigrations in schema.go). A table added only to schema.sql will
never be created on an existing database — it appears on fresh installs, works
in every test, and is silently absent in production.
So a new table needs two changes:
schema.sql, for fresh installs.- A migration with
CREATE TABLE IF NOT EXISTS, for existing databases.
TestSchemaMatchesMigrations enforces this. It builds the schema both ways and
fails if they disagree, so forgetting either half is caught before it ships.
Two more rules the test cannot enforce for you
Migrations must not return rows. go-libsql rejects a row-returning statement
passed to Exec with Execute returned rows. A migration that opens with a bare
SELECT fails on every database that has not already recorded it. (Migration
0001 does exactly this; it survives only because every real database recorded it
years ago.)
Rebuild migrations must name their columns. Column order in schema.sql is
illustrative, not authoritative: migrations append with ADD COLUMN while
schema.sql places the same column mid-table, so a fresh database and an
upgraded one legitimately differ in column order on manifests, users,
devices and repo_pages. INSERT INTO new_table SELECT * FROM old_table will
therefore silently write the wrong values into the wrong columns. Always write
INSERT INTO new_table (a, b, c) SELECT a, b, c FROM old_table, as migrations
0009 and 0011 do.
Migration Format
Each migration is a YAML file with the following structure:
description: Optional human-readable description of what this migration does
query: |
SQL commands to apply the migration
Version and name are parsed from the filename, so you don't need to specify them in the YAML.
Naming Convention
Migration files must be named: {version:04d}_{migration_name}.yaml
The filename determines:
- Version: Numeric prefix (e.g.,
0001→ version 1) - Name: Everything after first underscore (e.g.,
add_repository_labels→ "add repository labels")
Examples:
0001_remove_star_count_from_repository_stats.yaml→ version 1, name "remove star count from repository stats"0002_add_repository_labels.yaml→ version 2, name "add repository labels"0003_create_webhooks_table.yaml→ version 3, name "create webhooks table"
Creating a New Migration
- Choose the next version number - Look at existing migrations and increment by 1
- Create a new YAML file with format
000N_descriptive_name.yaml - Add description (optional) - Explain what the migration does
- Write your SQL in
query- Use the|block scalar for clean multi-line SQL - Use
IF EXISTS/IF NOT EXISTSwhere possible for idempotency
Examples
Adding a column to existing table:
Filename: 0007_add_readme_url_to_manifests.yaml
description: Add readme_url column to manifests table for storing io.atcr.readme annotation
query: |
ALTER TABLE manifests ADD COLUMN readme_url TEXT;
IMPORTANT: After creating this migration, also add the column to schema.sql so fresh installations include it!
Data transformation migration:
Filename: 0005_normalize_hold_endpoint_to_did.yaml
description: Normalize hold_endpoint column to store DIDs instead of URLs
query: |
-- Convert HTTPS URLs to did:web: format
UPDATE manifests
SET hold_endpoint = 'did:web:' || substr(hold_endpoint, 9)
WHERE hold_endpoint LIKE 'https://%';
-- Convert HTTP URLs to did:web: format
UPDATE manifests
SET hold_endpoint = 'did:web:' || substr(hold_endpoint, 8)
WHERE hold_endpoint LIKE 'http://%';
Adding an index to existing table:
Filename: 0008_add_repository_description_index.yaml
description: Add index on manifests description field for faster searches
query: |
CREATE INDEX IF NOT EXISTS idx_manifests_description ON manifests(description);
How Migrations Run
- Migrations are loaded from this directory on startup
- Sorted by version number (ascending)
- Each migration is checked against the
schema_migrationstable - Only unapplied migrations are executed
- After successful execution, the version is recorded in
schema_migrations
Important Notes
- Never modify existing migrations - Once applied, they're immutable
- Test migrations before committing - Ensure they work on existing databases
- Version numbers must be unique - The migration system silently skips a duplicate, so the second file never runs (see
TestMigrationVersionsAreUnique) - Migrations run automatically on
InitDB()- Schema first, then migrations - CRITICAL: Update
schema.sqlfor every structural change - Columns, tables and indexes all need both the migration AND theschema.sqlentry, or fresh and existing databases diverge.TestSchemaMatchesMigrationsfails the build if they do - Migrations must not return rows - a bare
SELECTfails under go-libsql withExecute returned rows - Rebuild migrations must name columns explicitly - never
INSERT INTO new SELECT * FROM old; column order differs between fresh and upgraded databases - Drift is reported at boot -
InitDBlogs a warning for any difference between an existing database andschema.sql. It never fails the boot; treat the warning as a request for a corrective migration