-- ATCR AppView Database Schema -- This file contains the complete base schema for fresh database installations. -- Migrations (in migrations/*.yaml) handle changes to existing databases. CREATE TABLE IF NOT EXISTS schema_migrations ( version INTEGER PRIMARY KEY, applied_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS users ( did TEXT PRIMARY KEY, handle TEXT NOT NULL, pds_endpoint TEXT NOT NULL, avatar TEXT, default_hold_did TEXT, oci_client TEXT DEFAULT '', registry_domain TEXT DEFAULT '', last_seen TIMESTAMP NOT NULL, UNIQUE(handle) ); CREATE INDEX IF NOT EXISTS idx_users_handle ON users(handle); -- Keyed by manifest_key, a node-independent identity derived from -- (did, repository, digest); see db.ManifestKey. There is no surrogate rowid: one -- is allocated by whichever node performs the insert, so it is only a stable -- identity while every write funnels through a single writer. CREATE TABLE IF NOT EXISTS manifests ( manifest_key TEXT PRIMARY KEY NOT NULL, did TEXT NOT NULL, repository TEXT NOT NULL, digest TEXT NOT NULL, hold_endpoint TEXT NOT NULL, -- Stored as DID (e.g., did:web:hold.example.com) schema_version INTEGER NOT NULL, media_type TEXT NOT NULL, config_digest TEXT, config_size INTEGER, artifact_type TEXT NOT NULL DEFAULT 'container-image', -- container-image, helm-chart, unknown subject_digest TEXT, -- digest of the parent manifest (for attestations/referrers) created_at TIMESTAMP NOT NULL, UNIQUE(did, repository, digest), FOREIGN KEY(did) REFERENCES users(did) ON DELETE CASCADE ); CREATE INDEX IF NOT EXISTS idx_manifests_did_repo ON manifests(did, repository); CREATE INDEX IF NOT EXISTS idx_manifests_created_at ON manifests(created_at DESC); CREATE INDEX IF NOT EXISTS idx_manifests_digest ON manifests(digest); CREATE INDEX IF NOT EXISTS idx_manifests_artifact_type ON manifests(artifact_type); CREATE INDEX IF NOT EXISTS idx_manifests_subject_digest ON manifests(subject_digest); CREATE TABLE IF NOT EXISTS repository_annotations ( did TEXT NOT NULL, repository TEXT NOT NULL, key TEXT NOT NULL, value TEXT NOT NULL, updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY(did, repository, key), FOREIGN KEY(did) REFERENCES users(did) ON DELETE CASCADE ); CREATE INDEX IF NOT EXISTS idx_repository_annotations_did_repo ON repository_annotations(did, repository); CREATE INDEX IF NOT EXISTS idx_repository_annotations_key ON repository_annotations(key); CREATE TABLE IF NOT EXISTS layers ( manifest_key TEXT NOT NULL, digest TEXT NOT NULL, size INTEGER NOT NULL, media_type TEXT NOT NULL, layer_index INTEGER NOT NULL, annotations TEXT, PRIMARY KEY(manifest_key, layer_index), FOREIGN KEY(manifest_key) REFERENCES manifests(manifest_key) ON DELETE CASCADE ); CREATE INDEX IF NOT EXISTS idx_layers_digest ON layers(digest); CREATE TABLE IF NOT EXISTS manifest_references ( manifest_key TEXT NOT NULL, digest TEXT NOT NULL, media_type TEXT NOT NULL, size INTEGER NOT NULL, platform_architecture TEXT, platform_os TEXT, platform_variant TEXT, platform_os_version TEXT, is_attestation BOOLEAN DEFAULT FALSE, reference_index INTEGER NOT NULL, PRIMARY KEY(manifest_key, reference_index), FOREIGN KEY(manifest_key) REFERENCES manifests(manifest_key) ON DELETE CASCADE ); CREATE INDEX IF NOT EXISTS idx_manifest_references_digest ON manifest_references(digest); -- Keyed by its natural key. There is no surrogate id: nothing joined on it, and -- an AUTOINCREMENT rowid is allocated by whichever node does the insert, which -- stops being a stable identity as soon as writes are not funnelled through a -- single writer. CREATE TABLE IF NOT EXISTS tags ( did TEXT NOT NULL, repository TEXT NOT NULL, tag TEXT NOT NULL, digest TEXT NOT NULL, created_at TIMESTAMP NOT NULL, PRIMARY KEY(did, repository, tag), FOREIGN KEY(did) REFERENCES users(did) ON DELETE CASCADE ); -- No separate (did, repository) index: the primary key already indexes that as a -- prefix. It was needed only while the primary key was the surrogate id. -- rev increments on every successful write, so a writer that read revision N can -- tell whether anyone has written since. Refresh tokens rotate on use, and the -- per-DID mutex that serializes refreshes is in-process only, so without this a -- second instance's concurrent refresh looks identical to a dead session and -- gets it deleted out from under the user. See OAuthStore.SaveSession. CREATE TABLE IF NOT EXISTS oauth_sessions ( session_key TEXT PRIMARY KEY, account_did TEXT NOT NULL, session_id TEXT NOT NULL, session_data TEXT NOT NULL, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, rev INTEGER NOT NULL DEFAULT 0, UNIQUE(account_did, session_id) ); CREATE INDEX IF NOT EXISTS idx_oauth_sessions_did ON oauth_sessions(account_did); CREATE INDEX IF NOT EXISTS idx_oauth_sessions_updated ON oauth_sessions(updated_at DESC); CREATE TABLE IF NOT EXISTS oauth_auth_requests ( state TEXT PRIMARY KEY, request_data TEXT NOT NULL, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ); CREATE INDEX IF NOT EXISTS idx_oauth_auth_requests_created ON oauth_auth_requests(created_at); CREATE TABLE IF NOT EXISTS ui_sessions ( id TEXT PRIMARY KEY, did TEXT NOT NULL, handle TEXT NOT NULL, pds_endpoint TEXT NOT NULL, oauth_session_id TEXT, expires_at TIMESTAMP NOT NULL, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY(did) REFERENCES users(did) ON DELETE CASCADE ); CREATE INDEX IF NOT EXISTS idx_ui_sessions_did ON ui_sessions(did); CREATE INDEX IF NOT EXISTS idx_ui_sessions_expires ON ui_sessions(expires_at); CREATE TABLE IF NOT EXISTS devices ( id TEXT PRIMARY KEY, did TEXT NOT NULL, handle TEXT NOT NULL, name TEXT NOT NULL, secret_hash TEXT NOT NULL UNIQUE, -- hex(sha256(secret)), for O(1) lookup during authentication. Nullable -- because rows predating migration 0028 are backfilled lazily on their -- next successful auth (the plaintext is not recoverable from the bcrypt -- hash). See ValidateDeviceSecret. secret_lookup TEXT, ip_address TEXT, location TEXT, user_agent TEXT, created_at TIMESTAMP NOT NULL, last_used TIMESTAMP, FOREIGN KEY(did) REFERENCES users(did) ON DELETE CASCADE ); CREATE INDEX IF NOT EXISTS idx_devices_secret_lookup ON devices(secret_lookup); CREATE INDEX IF NOT EXISTS idx_devices_did ON devices(did); CREATE INDEX IF NOT EXISTS idx_devices_hash ON devices(secret_hash); CREATE TABLE IF NOT EXISTS pending_device_auth ( device_code TEXT PRIMARY KEY, user_code TEXT NOT NULL UNIQUE, device_name TEXT NOT NULL, ip_address TEXT, user_agent TEXT, expires_at TIMESTAMP NOT NULL, approved_did TEXT, approved_at TIMESTAMP, device_secret TEXT, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ); CREATE INDEX IF NOT EXISTS idx_pending_device_auth_user_code ON pending_device_auth(user_code); CREATE INDEX IF NOT EXISTS idx_pending_device_auth_expires ON pending_device_auth(expires_at); CREATE TABLE IF NOT EXISTS repository_stats ( did TEXT NOT NULL, repository TEXT NOT NULL, pull_count INTEGER NOT NULL DEFAULT 0, last_pull TIMESTAMP, push_count INTEGER NOT NULL DEFAULT 0, last_push TIMESTAMP, PRIMARY KEY(did, repository), FOREIGN KEY(did) REFERENCES users(did) ON DELETE CASCADE ); CREATE INDEX IF NOT EXISTS idx_repository_stats_did ON repository_stats(did); CREATE INDEX IF NOT EXISTS idx_repository_stats_pull_count ON repository_stats(pull_count DESC); CREATE TABLE IF NOT EXISTS repository_stats_daily ( did TEXT NOT NULL, repository TEXT NOT NULL, date TEXT NOT NULL, pull_count INTEGER NOT NULL DEFAULT 0, push_count INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(did, repository, date), FOREIGN KEY(did) REFERENCES users(did) ON DELETE CASCADE ); CREATE INDEX IF NOT EXISTS idx_repo_stats_daily_date ON repository_stats_daily(date DESC); CREATE TABLE IF NOT EXISTS jetstream_cursor ( id INTEGER PRIMARY KEY CHECK (id = 1), cursor INTEGER NOT NULL, updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ); -- Stripe webhook idempotency + ordering. One row per successfully processed -- Stripe event. Dedups redelivered events (event_id PRIMARY KEY) and lets us -- drop stale out-of-order deliveries (compare event_created per customer). CREATE TABLE IF NOT EXISTS stripe_processed_events ( event_id TEXT PRIMARY KEY, customer_id TEXT, event_created INTEGER NOT NULL, processed_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ); CREATE INDEX IF NOT EXISTS idx_stripe_events_customer ON stripe_processed_events(customer_id); CREATE TABLE IF NOT EXISTS stars ( starrer_did TEXT NOT NULL, owner_did TEXT NOT NULL, repository TEXT NOT NULL, created_at TIMESTAMP NOT NULL, PRIMARY KEY(starrer_did, owner_did, repository), FOREIGN KEY(starrer_did) REFERENCES users(did) ON DELETE CASCADE, FOREIGN KEY(owner_did) REFERENCES users(did) ON DELETE CASCADE ); CREATE INDEX IF NOT EXISTS idx_stars_owner_repo ON stars(owner_did, repository); CREATE INDEX IF NOT EXISTS idx_stars_starrer ON stars(starrer_did); CREATE TABLE IF NOT EXISTS hold_captain_records ( hold_did TEXT PRIMARY KEY, owner_did TEXT NOT NULL, public BOOLEAN NOT NULL, allow_all_crew BOOLEAN NOT NULL, deployed_at TEXT, region TEXT, successor TEXT, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); CREATE INDEX IF NOT EXISTS idx_hold_captain_updated ON hold_captain_records(updated_at); CREATE TABLE IF NOT EXISTS hold_crew_approvals ( hold_did TEXT NOT NULL, user_did TEXT NOT NULL, approved_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, expires_at TIMESTAMP NOT NULL, PRIMARY KEY(hold_did, user_did) ); CREATE INDEX IF NOT EXISTS idx_crew_approvals_expires ON hold_crew_approvals(expires_at); CREATE TABLE IF NOT EXISTS hold_crew_denials ( hold_did TEXT NOT NULL, user_did TEXT NOT NULL, denial_count INTEGER NOT NULL DEFAULT 1, next_retry_at TIMESTAMP NOT NULL, last_denied_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY(hold_did, user_did) ); CREATE INDEX IF NOT EXISTS idx_crew_denials_retry ON hold_crew_denials(next_retry_at); -- Cached hold crew memberships from Jetstream -- Enables reverse lookup: "which holds is user X a member of?" CREATE TABLE IF NOT EXISTS hold_crew_members ( hold_did TEXT NOT NULL, member_did TEXT NOT NULL, rkey TEXT NOT NULL, role TEXT, permissions TEXT, -- JSON array tier TEXT, added_at TEXT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (hold_did, member_did) ); CREATE INDEX IF NOT EXISTS idx_hold_crew_member ON hold_crew_members(member_did); CREATE INDEX IF NOT EXISTS idx_hold_crew_hold ON hold_crew_members(hold_did); CREATE INDEX IF NOT EXISTS idx_hold_crew_rkey ON hold_crew_members(hold_did, rkey); CREATE TABLE IF NOT EXISTS repo_pages ( did TEXT NOT NULL, repository TEXT NOT NULL, description TEXT, avatar_cid TEXT, user_edited BOOLEAN NOT NULL DEFAULT 0, created_at TIMESTAMP NOT NULL, updated_at TIMESTAMP NOT NULL, PRIMARY KEY(did, repository), FOREIGN KEY(did) REFERENCES users(did) ON DELETE CASCADE ); CREATE INDEX IF NOT EXISTS idx_repo_pages_did ON repo_pages(did); CREATE TABLE IF NOT EXISTS crypto_keys ( name TEXT PRIMARY KEY, key_data BLOB NOT NULL, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS webhooks ( id TEXT PRIMARY KEY, user_did TEXT NOT NULL, url TEXT NOT NULL, secret TEXT, triggers INTEGER NOT NULL DEFAULT 1, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, last_fired_at TIMESTAMP NULL, FOREIGN KEY(user_did) REFERENCES users(did) ON DELETE CASCADE ); CREATE INDEX IF NOT EXISTS idx_webhooks_user ON webhooks(user_did); CREATE TABLE IF NOT EXISTS scans ( hold_did TEXT NOT NULL, manifest_digest TEXT NOT NULL, user_did TEXT NOT NULL, repository TEXT NOT NULL, critical INTEGER NOT NULL DEFAULT 0, high INTEGER NOT NULL DEFAULT 0, medium INTEGER NOT NULL DEFAULT 0, low INTEGER NOT NULL DEFAULT 0, total INTEGER NOT NULL DEFAULT 0, scanner_version TEXT, scanned_at TIMESTAMP NOT NULL, PRIMARY KEY(hold_did, manifest_digest) ); CREATE INDEX IF NOT EXISTS idx_scans_user ON scans(user_did); CREATE TABLE IF NOT EXISTS advisor_suggestions ( manifest_digest TEXT PRIMARY KEY, suggestions_json TEXT NOT NULL, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS taken_down_subjects ( src TEXT NOT NULL, did TEXT NOT NULL, repo TEXT NOT NULL DEFAULT '', cts TIMESTAMP NOT NULL, PRIMARY KEY (src, did, repo) ); CREATE INDEX IF NOT EXISTS idx_taken_down_subjects_did ON taken_down_subjects(did); CREATE INDEX IF NOT EXISTS idx_taken_down_subjects_did_repo ON taken_down_subjects(did, repo); CREATE TABLE IF NOT EXISTS labeler_cursor ( src TEXT PRIMARY KEY, cursor INTEGER NOT NULL ); -- Leader election for background workers. Exactly one AppView instance may run -- the Jetstream consumer, backfill, labeler subscriber and cleanup loops; see -- pkg/appview/db/leases.go for why, and pkg/appview/leases for the renewal loop. -- -- acquired_at and expires_at are Unix milliseconds, not TIMESTAMP text: libSQL -- normalizes date-like TEXT on the way in, and Go's driver and CURRENT_TIMESTAMP -- disagree on format, so a string comparison in the acquire statement would be -- comparing incompatible shapes. Integer comparison is the whole safety property -- here, so it does not get to be subtle. CREATE TABLE IF NOT EXISTS instance_leases ( lease_name TEXT PRIMARY KEY, holder_id TEXT NOT NULL, fence INTEGER NOT NULL DEFAULT 0, acquired_at INTEGER NOT NULL, expires_at INTEGER NOT NULL ); CREATE INDEX IF NOT EXISTS idx_instance_leases_expires ON instance_leases(expires_at);