From a767340d96973dad78b26e6e41836a70b71eeaf0 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Sat, 18 Jul 2026 12:10:24 +0100 Subject: [PATCH] fix(aggregator): rename labellers table via forward migration Migration 0001 was edited in place to rename `labellers` -> `labelers` after shipping on main. Databases that already applied 0001 never see the edit, so they keep `labellers` while every query in src/ reads `labelers`, failing with `no such table`. Restore 0001 to main's byte-for-byte schema and add 0004 to rename the table forward. Both fresh installs and main upgrades now converge on `labelers`. Adds an upgrade test that provisions main's frozen 0001, applies the live set, and asserts the table is queryable. --- apps/aggregator/migrations/0001_init.sql | 12 +- .../0004_rename_labellers_to_labelers.sql | 12 + .../fixtures/main-migrations/0001_init.sql | 355 ++++++++++++++++++ .../main-migrations/0002_indexed_at.sql | 35 ++ .../aggregator/test/migration-upgrade.test.ts | 61 +++ apps/aggregator/test/smoke.test.ts | 8 + apps/aggregator/vitest.config.ts | 9 + 7 files changed, 486 insertions(+), 6 deletions(-) create mode 100644 apps/aggregator/migrations/0004_rename_labellers_to_labelers.sql create mode 100644 apps/aggregator/test/fixtures/main-migrations/0001_init.sql create mode 100644 apps/aggregator/test/fixtures/main-migrations/0002_indexed_at.sql create mode 100644 apps/aggregator/test/migration-upgrade.test.ts diff --git a/apps/aggregator/migrations/0001_init.sql b/apps/aggregator/migrations/0001_init.sql index 9cf42c7f29..ed3a6a1f41 100644 --- a/apps/aggregator/migrations/0001_init.sql +++ b/apps/aggregator/migrations/0001_init.sql @@ -178,14 +178,14 @@ CREATE TABLE mirrored_artifacts ( ); ------------------------------------------------------------------------------ --- Labels (populated when the labeler integration lands) +-- Labels (populated when the labeller integration lands) ------------------------------------------------------------------------------ -- Append-only label history. Every label received is written here, including -- negations. Current state is derived from latest cts per (src, uri, val) and -- projected into label_state below for hot-path lookups. CREATE TABLE labels ( - src TEXT NOT NULL, -- labeler DID + src TEXT NOT NULL, -- labeller DID uri TEXT NOT NULL, -- AT URI of subject cid TEXT, -- optional version-specific CID val TEXT NOT NULL, -- e.g. 'security:yanked', '!takedown' @@ -229,8 +229,8 @@ CREATE TABLE label_state ( CREATE INDEX idx_label_state_enforce ON label_state(uri, val, trusted) WHERE neg = 0 AND trusted = 1; --- Trusted/known labelers (operator config, edited via deployment). -CREATE TABLE labelers ( +-- Trusted/known labellers (operator config, edited via deployment). +CREATE TABLE labellers ( did TEXT PRIMARY KEY, endpoint TEXT NOT NULL, -- subscribeLabels URL signing_key TEXT NOT NULL, -- cached #atproto_label key @@ -278,9 +278,9 @@ END; ------------------------------------------------------------------------------ -- Cursor state for ingest sources (Jetstream microsecond timestamp, --- subscribeLabels seq cursors per labeler, etc.). +-- subscribeLabels seq cursors per labeller, etc.). CREATE TABLE ingest_state ( - source TEXT PRIMARY KEY, -- 'jetstream', 'labeler:did:web:labels.example.com', etc. + source TEXT PRIMARY KEY, -- 'jetstream', 'labeller:did:web:labels.example.com', etc. cursor TEXT NOT NULL, updated_at TEXT NOT NULL ); diff --git a/apps/aggregator/migrations/0004_rename_labellers_to_labelers.sql b/apps/aggregator/migrations/0004_rename_labellers_to_labelers.sql new file mode 100644 index 0000000000..9dfa867549 --- /dev/null +++ b/apps/aggregator/migrations/0004_rename_labellers_to_labelers.sql @@ -0,0 +1,12 @@ +-- Rename `labellers` -> `labelers` to match the spelling every query in +-- `src/` uses (`labeler-resolver.ts`, `labels-consumer.ts`, +-- `request-policy.ts`, `index.ts`). `0001_init.sql` shipped the table as +-- `labellers` and is immutable once applied, so the correction has to land as +-- a forward migration: databases provisioned by `0001` keep `labellers` and +-- only this migration brings them to `labelers`. +-- +-- No indexes, triggers, or views reference the table, so SQLite's +-- `ALTER TABLE ... RENAME TO` is sufficient — there are no explicitly-named +-- objects embedding the old spelling to recreate. + +ALTER TABLE labellers RENAME TO labelers; diff --git a/apps/aggregator/test/fixtures/main-migrations/0001_init.sql b/apps/aggregator/test/fixtures/main-migrations/0001_init.sql new file mode 100644 index 0000000000..b8ebc193ad --- /dev/null +++ b/apps/aggregator/test/fixtures/main-migrations/0001_init.sql @@ -0,0 +1,355 @@ +-- Frozen snapshot of `migrations/0001_init.sql` as it shipped on `main` +-- (with the `labellers` table spelling). The upgrade test in +-- `migration-upgrade.test.ts` applies this directory (main's shipped migration +-- set) to reproduce a database provisioned before the `labellers` -> +-- `labelers` rename, then applies the live migration set on top. Do not "fix" +-- the spelling here: this file must keep reproducing the historical schema. +-- +-- EmDash plugin registry aggregator: initial schema. +-- +-- Lands every table that the v1 read API + ingest pipeline + label hydration +-- + mirror tracking needs, at once on purpose: features that read these +-- tables don't need to add new ones, so this is the only DDL we expect to +-- ship while NSIDs remain experimental. + +------------------------------------------------------------------------------ +-- Records: package profiles + releases +------------------------------------------------------------------------------ + +CREATE TABLE packages ( + did TEXT NOT NULL, + slug TEXT NOT NULL, + type TEXT NOT NULL, -- 'emdash-plugin' + name TEXT, + description TEXT, + license TEXT NOT NULL, + authors TEXT NOT NULL, -- JSON array + security TEXT NOT NULL, -- JSON array + keywords TEXT, -- JSON array + sections TEXT, -- JSON map + last_updated TEXT, + -- Denormalised from latest release for query convenience. Updated on every + -- new release insert; readers never compute "latest" by sorting. + latest_version TEXT, + capabilities TEXT, -- JSON array + -- Raw signed record bytes for verification + envelope passthrough. Clients + -- re-verify the MST signature against the publisher's DID document at + -- install time. + record_blob BLOB NOT NULL, + signature_metadata TEXT, -- JSON: head CID, signing key id + verified_at TEXT NOT NULL, + PRIMARY KEY (did, slug) +); + +CREATE TABLE releases ( + did TEXT NOT NULL, + package TEXT NOT NULL, -- matches the parent profile's rkey/slug (record.package field) + version TEXT NOT NULL, -- canonical (un-percent-encoded) semver from record.version + rkey TEXT NOT NULL, -- exact rkey of the form `:` + -- Pre-computed semver-precedence-ordered string for ORDER BY. Application + -- code writes this; SQLite cannot compute semver order natively. Format + -- packs zero-padded major.minor.patch with prerelease tags compared per + -- semver precedence rules. + version_sort TEXT NOT NULL, + artifacts TEXT NOT NULL, -- JSON + requires TEXT, -- JSON + suggests TEXT, -- JSON + -- com.emdashcms.experimental.package.releaseExtension contents: + -- { declaredAccess }. The capabilities-shaped projection lives in + -- packages.capabilities for query convenience. + emdash_extension TEXT NOT NULL, + repo_url TEXT, + cts TEXT NOT NULL, -- creation timestamp from the record + record_blob BLOB NOT NULL, + signature_metadata TEXT, + verified_at TEXT NOT NULL, + tombstoned_at TEXT, -- soft delete (publisher deleted record) + PRIMARY KEY (did, package, version), + -- ON DELETE CASCADE because Jetstream events for a publisher can arrive + -- in arbitrary order under network reorder. A publisher who deletes their + -- profile (and all releases) emits the events in author-order, but the + -- profile-delete might land at the consumer before the release-deletes. + -- Without cascade, the consumer would have to either skip the profile + -- delete (leaving stale rows) or sequence retries, neither of which is + -- worth the complexity. Releases are version-immutable from a publishing + -- perspective, but a publisher is still entitled to remove their entire + -- package; cascade mirrors that intent. + FOREIGN KEY (did, package) REFERENCES packages(did, slug) ON DELETE CASCADE +); + +CREATE INDEX idx_releases_latest ON releases(did, package, version_sort DESC) WHERE tombstoned_at IS NULL; +CREATE INDEX idx_releases_cts ON releases(cts); + +-- Audit trail for rejected duplicate-version attempts. FAIR PR #77 makes +-- versions immutable: a second record at the same (did, package, version) is +-- rejected at the SQL layer and logged here for forensics. +-- +-- The UNIQUE constraint dedupes attempts by content (CID), not by raw bytes. +-- CAR bytes include the publisher's commit + MST proof which churns whenever +-- the publisher writes any other record in the same repo, so byte-equality +-- would misclassify benign retries as new attempts and bloat the table. +-- The CID is content-addressed and stable for an unchanged record. +-- +-- The consumer's INSERT uses `ON CONFLICT … DO UPDATE SET rejected_at, +-- attempted_record_blob = excluded.{rejected_at, attempted_record_blob}` so +-- the row tracks the latest attempt timestamp + the latest envelope bytes +-- (newer proofs supersede older ones in the forensics column). +CREATE TABLE release_duplicate_attempts ( + did TEXT NOT NULL, + package TEXT NOT NULL, + version TEXT NOT NULL, + -- CID of the verified record (stable for content; changes only when the + -- record itself changes). Used as the dedup key. + attempted_cid TEXT NOT NULL, + rejected_at TEXT NOT NULL, + reason TEXT NOT NULL, + -- Raw CAR bytes from the most recent attempt. Kept for forensics so + -- operators can inspect what was actually attempted even if the + -- publisher has since deleted the offending record. + attempted_record_blob BLOB NOT NULL, + UNIQUE (did, package, version, attempted_cid) +); + +-- The UNIQUE constraint creates an implicit index on +-- (did, package, version, attempted_record_blob); a separate index on the +-- (did, package, version) prefix is redundant for both lookups (the implicit +-- index handles prefix seeks) and inserts (one fewer index to maintain). + +------------------------------------------------------------------------------ +-- Publishers: identity-level publisher profiles + verification claims +------------------------------------------------------------------------------ + +-- One row per publisher DID (rkey is always literal `self`). Optional: a DID +-- may publish packages without ever publishing a publisher.profile, in which +-- case the row is absent and clients fall back to the handle. This table is +-- the canonical source for "who is publishing these packages?" — distinct from +-- `packages.authors`, which is per-package and remains authoritative for that +-- package. +CREATE TABLE publishers ( + did TEXT PRIMARY KEY, + display_name TEXT NOT NULL, -- bound by verification records — see publisher_verifications + description TEXT, + url TEXT, + contact TEXT, -- JSON array of { kind, url?, email? } + updated_at TEXT, + record_blob BLOB NOT NULL, + signature_metadata TEXT, -- JSON: head CID, signing key id + verified_at TEXT NOT NULL +); + +-- Verification claims: issuer DID vouches for subject DID as a trusted +-- publisher. The rkey is a TID, so an issuer can issue multiple claims (e.g. +-- delegated + official) and we store each as its own row. Validity is bound to +-- the subject's handle + publisher.profile.displayName at issuance time: +-- clients re-resolve those at read time and treat the claim as not in force if +-- either has changed. Ingest stores the facts; the validity check is a +-- query-time concern. +CREATE TABLE publisher_verifications ( + issuer_did TEXT NOT NULL, -- DID of the repo that wrote the record + rkey TEXT NOT NULL, -- TID + subject_did TEXT NOT NULL, + subject_handle TEXT NOT NULL, -- bound at issuance; query-time validity check compares against current + subject_display_name TEXT NOT NULL, -- bound at issuance; query-time validity check compares against current + created_at TEXT NOT NULL, + expires_at TEXT, + record_blob BLOB NOT NULL, + signature_metadata TEXT, + verified_at TEXT NOT NULL, + tombstoned_at TEXT, + PRIMARY KEY (issuer_did, rkey) +); + +-- Hot path: "show me all unexpired, non-tombstoned verifications for subject X". +-- Partial index keeps the index small by excluding tombstoned rows. +CREATE INDEX idx_publisher_verifications_subject ON publisher_verifications(subject_did) + WHERE tombstoned_at IS NULL; + +-- For periodic expiry sweeps. +CREATE INDEX idx_publisher_verifications_expires ON publisher_verifications(expires_at) + WHERE expires_at IS NOT NULL AND tombstoned_at IS NULL; + +------------------------------------------------------------------------------ +-- Mirror tracking (populated when the artifact mirror lands) +------------------------------------------------------------------------------ + +CREATE TABLE mirrored_artifacts ( + did TEXT NOT NULL, + slug TEXT NOT NULL, + version TEXT NOT NULL, + artifact_id TEXT NOT NULL, -- 'package', 'icon', etc. + r2_key TEXT NOT NULL, + bytes INTEGER NOT NULL, + content_type TEXT NOT NULL, + mirrored_at TEXT NOT NULL, + PRIMARY KEY (did, slug, version, artifact_id) +); + +------------------------------------------------------------------------------ +-- Labels (populated when the labeller integration lands) +------------------------------------------------------------------------------ + +-- Append-only label history. Every label received is written here, including +-- negations. Current state is derived from latest cts per (src, uri, val) and +-- projected into label_state below for hot-path lookups. +CREATE TABLE labels ( + src TEXT NOT NULL, -- labeller DID + uri TEXT NOT NULL, -- AT URI of subject + cid TEXT, -- optional version-specific CID + val TEXT NOT NULL, -- e.g. 'security:yanked', '!takedown' + neg INTEGER NOT NULL DEFAULT 0, + cts TEXT NOT NULL, + exp TEXT, -- optional expiry (RFC 3339) + sig BLOB NOT NULL, -- raw signature for client re-verification + ver INTEGER NOT NULL DEFAULT 1, + trusted INTEGER NOT NULL DEFAULT 0, + received_at TEXT NOT NULL, + PRIMARY KEY (src, uri, val, cts) +); + +CREATE INDEX idx_labels_subject ON labels(uri); +CREATE INDEX idx_labels_latest ON labels(src, uri, val, cts DESC); + +-- Latest-state projection: one row per (src, uri, val) holding the most recent +-- cts seen, including the neg flag and exp timestamp. Updated on every label +-- write within the same transaction. Query-time filters apply +-- `neg = 0 AND (exp IS NULL OR exp > now())` to determine whether a label is +-- currently in force. +-- +-- Why retain rows for negated/expired labels rather than deleting them: an +-- out-of-order delivery (a positive label arriving after its negation) could +-- otherwise reinsert a row we'd already retracted. Keeping the row with its +-- `cts` lets the upsert reject the older positive. +CREATE TABLE label_state ( + src TEXT NOT NULL, + uri TEXT NOT NULL, + val TEXT NOT NULL, + cid TEXT, + neg INTEGER NOT NULL DEFAULT 0, + cts TEXT NOT NULL, + exp TEXT, + trusted INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (src, uri, val) +); + +-- Hot path for hard filters (yanked, takedown, etc.) from trusted issuers. +-- Partial index keeps the index small by storing only currently-active rows. +CREATE INDEX idx_label_state_enforce ON label_state(uri, val, trusted) + WHERE neg = 0 AND trusted = 1; + +-- Trusted/known labellers (operator config, edited via deployment). +CREATE TABLE labellers ( + did TEXT PRIMARY KEY, + endpoint TEXT NOT NULL, -- subscribeLabels URL + signing_key TEXT NOT NULL, -- cached #atproto_label key + signing_key_id TEXT NOT NULL, + trusted INTEGER NOT NULL DEFAULT 0, + added_at TEXT NOT NULL, + last_resolved_at TEXT NOT NULL, + notes TEXT +); + +------------------------------------------------------------------------------ +-- Search: FTS5 over packages +------------------------------------------------------------------------------ + +CREATE VIRTUAL TABLE packages_fts USING fts5( + name, + description, + keywords, + authors, + sections, + content='packages', + content_rowid='rowid', + tokenize='porter unicode61 remove_diacritics 2' +); + +CREATE TRIGGER packages_ai AFTER INSERT ON packages BEGIN + INSERT INTO packages_fts(rowid, name, description, keywords, authors, sections) + VALUES (new.rowid, new.name, new.description, new.keywords, new.authors, new.sections); +END; + +CREATE TRIGGER packages_au AFTER UPDATE ON packages BEGIN + INSERT INTO packages_fts(packages_fts, rowid, name, description, keywords, authors, sections) + VALUES ('delete', old.rowid, old.name, old.description, old.keywords, old.authors, old.sections); + INSERT INTO packages_fts(rowid, name, description, keywords, authors, sections) + VALUES (new.rowid, new.name, new.description, new.keywords, new.authors, new.sections); +END; + +CREATE TRIGGER packages_ad AFTER DELETE ON packages BEGIN + INSERT INTO packages_fts(packages_fts, rowid, name, description, keywords, authors, sections) + VALUES ('delete', old.rowid, old.name, old.description, old.keywords, old.authors, old.sections); +END; + +------------------------------------------------------------------------------ +-- Ingest cursor state +------------------------------------------------------------------------------ + +-- Cursor state for ingest sources (Jetstream microsecond timestamp, +-- subscribeLabels seq cursors per labeller, etc.). +CREATE TABLE ingest_state ( + source TEXT PRIMARY KEY, -- 'jetstream', 'labeller:did:web:labels.example.com', etc. + cursor TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +-- Known publisher DIDs we've seen via Jetstream or Constellation. Reconciliation +-- iterates this table; cold-start backfill seeds it from Constellation. +-- +-- Doubles as the DID-document resolution cache: `pds`, `signing_key`, +-- `signing_key_id` are populated by the records consumer on first verification +-- and refreshed when `pds_resolved_at` is older than the consumer's TTL +-- (currently 24h, applied at query time as +-- `pds_resolved_at > datetime('now', '-1 day')`). Backfill may insert a row +-- with these fields null; the consumer's first event for that DID forces a +-- resolution and UPDATE. +CREATE TABLE known_publishers ( + did TEXT PRIMARY KEY, + pds TEXT, -- cached PDS endpoint from DID document + signing_key TEXT, -- cached #atproto signing key (multibase) + signing_key_id TEXT, -- e.g. 'did:plc:xxx#atproto' + pds_resolved_at TEXT, -- last successful DID-doc resolution + first_seen_at TEXT NOT NULL, + last_seen_at TEXT NOT NULL +); + +------------------------------------------------------------------------------ +-- Verification-failure forensics +------------------------------------------------------------------------------ + +-- Records that failed PDS-verified ingest (signature, MST proof, AT-URI, +-- lexicon, content-mismatch). Written instead of retrying, because these +-- failures indicate malicious or broken upstream — retrying would just burn +-- PDS round trips. Operators query this table to investigate suspected attacks +-- or upstream regressions; it is NOT used as a retry queue. +-- +-- Distinct from the configured Cloudflare DLQ (`emdash-aggregator-records-dlq`, +-- see wrangler.jsonc), which receives messages after `max_retries` exhausted — +-- that is for transient failures (PDS down, profile-not-yet-arrived). Two +-- distinct failure modes, two distinct destinations. +-- +-- `payload` holds the unverified record bytes from the Jetstream event so an +-- operator can inspect what was attempted without going back to the source PDS. +CREATE TABLE dead_letters ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + did TEXT NOT NULL, + collection TEXT NOT NULL, + rkey TEXT NOT NULL, + -- Reason code; matches the `DeadLetterReason` union in records-consumer.ts. + -- Current values: 'RECORD_NOT_FOUND', 'RESPONSE_TOO_LARGE', 'INVALID_PROOF', + -- 'PDS_HTTP_ERROR', 'LEXICON_VALIDATION_FAILED', 'RKEY_MISMATCH', + -- 'CONTACT_VALIDATION_FAILED', 'INVALID_VERSION', 'UNKNOWN_COLLECTION', + -- 'UNEXPECTED_ERROR'. + reason TEXT NOT NULL, + -- Free-form context (which field, expected vs got, library error message, etc.). + detail TEXT, + -- UTF-8 encoded JSON bytes of `RecordsJob.jetstreamRecord` when present, or a + -- fallback envelope `{operation, cid}` for delete events that don't carry one. + -- Stored as BLOB so future formats (CBOR, raw record bytes) can land here + -- without a schema change; today operators must `CAST(payload AS TEXT)` to + -- read. + payload BLOB NOT NULL, + received_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE INDEX idx_dead_letters_did ON dead_letters(did); +CREATE INDEX idx_dead_letters_received ON dead_letters(received_at); diff --git a/apps/aggregator/test/fixtures/main-migrations/0002_indexed_at.sql b/apps/aggregator/test/fixtures/main-migrations/0002_indexed_at.sql new file mode 100644 index 0000000000..385d7e35d9 --- /dev/null +++ b/apps/aggregator/test/fixtures/main-migrations/0002_indexed_at.sql @@ -0,0 +1,35 @@ +-- Add `indexed_at` to the four content tables that the read API exposes via +-- the `packageView` / `releaseView` lexicon shapes. +-- +-- Why a separate column from `verified_at`: `verified_at` tracks "when the +-- aggregator last verified this record", and is bumped on every upsert +-- (including no-op re-verifications). The read API needs "when the aggregator +-- first observed this record" so the lexicon's `indexedAt` field is stable +-- across re-ingest. They diverge whenever a record is re-fetched (e.g. +-- backfill catching up on a record we already had via Jetstream). +-- +-- Defaulting to `verified_at` for any rows that pre-date this migration: the +-- closest approximation we can make for already-indexed content. Going +-- forward, the consumer's INSERTs set `indexed_at` to `now()` on first write +-- and COALESCE the existing value on conflict (see records-consumer.ts). +-- +-- NOT NULL after backfill from `verified_at`. SQLite's `ALTER TABLE` doesn't +-- support DEFAULT-with-NOT-NULL in one step, so we add the column nullable, +-- backfill, then move the constraint via the new-table dance — except SQLite +-- also can't add NOT NULL via ALTER, so we keep the column nullable at the +-- schema level and have the writer (consumer) treat it as required. The +-- read-API code defends against NULL by falling back to verified_at if +-- indexed_at is somehow missing on a row, which won't happen for new writes +-- but covers the historical-data corner case without a table rebuild. + +ALTER TABLE packages ADD COLUMN indexed_at TEXT; +UPDATE packages SET indexed_at = verified_at WHERE indexed_at IS NULL; + +ALTER TABLE releases ADD COLUMN indexed_at TEXT; +UPDATE releases SET indexed_at = verified_at WHERE indexed_at IS NULL; + +ALTER TABLE publishers ADD COLUMN indexed_at TEXT; +UPDATE publishers SET indexed_at = verified_at WHERE indexed_at IS NULL; + +ALTER TABLE publisher_verifications ADD COLUMN indexed_at TEXT; +UPDATE publisher_verifications SET indexed_at = verified_at WHERE indexed_at IS NULL; diff --git a/apps/aggregator/test/migration-upgrade.test.ts b/apps/aggregator/test/migration-upgrade.test.ts new file mode 100644 index 0000000000..cc17ce2b67 --- /dev/null +++ b/apps/aggregator/test/migration-upgrade.test.ts @@ -0,0 +1,61 @@ +/** + * Upgrade-path regression for the `labellers` -> `labelers` rename. + * + * `0001_init.sql` shipped on `main` creating a `labellers` table, then a later + * revision renamed it in place. Editing an applied migration is invisible to + * databases that already recorded it, so instances deployed off `main` kept + * `labellers` while `src/` moved to querying `labelers` -> every such query + * failed with `no such table`. The rename now lands as a forward migration. + * + * This test provisions the schema the way a `main` deployment had it (via the + * `MAIN_MIGRATIONS` fixture, a frozen copy of `main`'s shipped migration set), + * then applies the live migration set on top. `applyD1Migrations` skips + * migrations already recorded by name, so neither frozen file is re-run — + * exactly what happens on a real upgrade. It lives in its own file because the workers pool isolates + * D1 storage per test file, not per test, and this scenario needs a database + * that starts from `main`'s schema rather than the live one. + */ + +import { applyD1Migrations, env } from "cloudflare:test"; +import { describe, expect, it } from "vitest"; + +interface TestEnv { + DB: D1Database; + TEST_MIGRATIONS: Parameters[1]; + MAIN_MIGRATIONS: Parameters[1]; +} + +const testEnv = env as unknown as TestEnv; + +describe("labellers -> labelers upgrade", () => { + it("converges an already-migrated main database on the labelers table", async () => { + await applyD1Migrations(testEnv.DB, testEnv.MAIN_MIGRATIONS); + expect((await testEnv.DB.prepare(`SELECT did FROM labellers`).all()).success).toBe(true); + + const did = "did:web:labels.example.test"; + await testEnv.DB.prepare( + `INSERT INTO labellers (did, endpoint, signing_key, signing_key_id, trusted, added_at, last_resolved_at) + VALUES (?, ?, ?, ?, 1, ?, ?)`, + ) + .bind( + did, + "https://labels.example.test/subscribe", + "zKey", + `${did}#atproto_label`, + "2026-01-01T00:00:00.000Z", + "2026-01-01T00:00:00.000Z", + ) + .run(); + + await applyD1Migrations(testEnv.DB, testEnv.TEST_MIGRATIONS); + + // The RENAME must carry the existing row across, not drop/recreate the table. + const result = await testEnv.DB.prepare( + `SELECT did FROM labelers WHERE trusted = 1 ORDER BY did ASC`, + ).all<{ did: string }>(); + expect(result.success).toBe(true); + expect(result.results?.map((row) => row.did)).toContain(did); + + await expect(testEnv.DB.prepare(`SELECT did FROM labellers`).all()).rejects.toThrow(); + }); +}); diff --git a/apps/aggregator/test/smoke.test.ts b/apps/aggregator/test/smoke.test.ts index c02ab33165..5ef081cea0 100644 --- a/apps/aggregator/test/smoke.test.ts +++ b/apps/aggregator/test/smoke.test.ts @@ -84,6 +84,14 @@ describe("aggregator scaffold smoke test", () => { expect(result?.slug).toBe("searchable"); }); + it("exposes a queryable labelers table under the name src queries", async () => { + // `0001_init.sql` creates the table as `labellers`; `0004` renames it to + // `labelers`, the spelling every query in `src/` uses. A fresh install + // must land on `labelers`. + const result = await testEnv.DB.prepare(`SELECT did FROM labelers`).all(); + expect(result.success).toBe(true); + }); + it("rejects a release whose did/package does not match an existing profile (FK)", async () => { // Releases reference packages via composite FK; SQLite enforces this when // FK checks are enabled. workers-pool's miniflare D1 has FK checks on by diff --git a/apps/aggregator/vitest.config.ts b/apps/aggregator/vitest.config.ts index c44bb02004..7eb2e844d3 100644 --- a/apps/aggregator/vitest.config.ts +++ b/apps/aggregator/vitest.config.ts @@ -28,6 +28,14 @@ import { defineConfig } from "vitest/config"; const migrationsPath = fileURLToPath(new URL("./migrations", import.meta.url)); const migrations = await readD1Migrations(migrationsPath); +// Frozen copy of the schema `main` shipped (pre `labellers` -> `labelers` +// rename), exposed so the upgrade test can provision a database the way an +// already-deployed instance was before applying the live migration set. +const mainMigrationsPath = fileURLToPath( + new URL("./test/fixtures/main-migrations", import.meta.url), +); +const mainMigrations = await readD1Migrations(mainMigrationsPath); + export default defineConfig({ plugins: [ cloudflareTest({ @@ -35,6 +43,7 @@ export default defineConfig({ miniflare: { bindings: { TEST_MIGRATIONS: migrations, + MAIN_MIGRATIONS: mainMigrations, // Stub admin auth token so tests can exercise the auth-gated // admin routes without needing a real secret in the test // environment. Production deploys pull from