Skip to content

feat(db): native Postgres foundation (database package, schema, tests) — #251 - #252

Merged
ryanbarlow97 merged 7 commits into
postgresfrom
pg-foundation
Oct 16, 2026
Merged

ryanbarlow97 merged 7 commits into
postgresfrom
pg-foundation

Conversation

@ryanbarlow97

@ryanbarlow97 ryanbarlow97 commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

The foundation for moving the site data to native Postgres (#251): a src/database package with a pooled psycopg 3 connection, a migration runner with a typed baseline for every province.db table, pytest fixtures against a real Postgres, and a Postgres service in CI. Nothing reads or writes site data through it yet; the SQLite src.skins.db.connect() is untouched, so the unconverted areas and their tests keep running. Area PRs into postgres move each area over.

What it adds

backend/src/database/ (psycopg[binary]==3.3.6, psycopg-pool==3.3.3)

  • connect() borrows a connection from a process-wide pool opened from DATABASE_URL (sizes DATABASE_POOL_MIN_SIZE/DATABASE_POOL_MAX_SIZE, default 1 and 10; 10 s checkout timeout; each borrow checks the connection). The block is one transaction: it commits when it ends normally and rolls back on an exception. Rows are dicts (dict_row). Every connection starts with search_path=public (the database is shared with Precedent and patch notes), TimeZone=UTC, lock_timeout=5s, statement_timeout=30s and idle_in_transaction_session_timeout=60s. A returned connection is reset before the next borrower gets it: the pool rolls back, then _reset runs DISCARD ALL's parts (settings back to their start-up values; cursors, temp tables, LISTENs and session advisory locks gone) and puts autocommit, isolation, read-only and the row factory back to their defaults. It leaves out DEALLOCATE ALL: psycopg 3.3 notices a DISCARD ALL only the first time a connection runs it, and after the second reset it named prepared statements the server had dropped. psycopg's own prepared statements are safe to keep.
  • transaction(isolation=…, read_only=…, lock=…) is connect() with SET TRANSACTION applied first and, optionally, an advisory lock taken before anything else.
  • advisory_lock(conn, name, wait=True) takes pg_advisory_xact_lock(hashtextextended(name, 0)); commit or rollback releases it. wait=False returns False instead of waiting. The wait is bounded by its own ADVISORY_LOCK_TIMEOUT of 20 s (set with set_config(…, true) for the acquisition only, then the transaction's previous lock_timeout comes back). That is longer than the 5 s row-lock bound, because an advisory holder may run a whole Patreon recompute or LuckPerms snapshot, and shorter than the 30 s statement_timeout, so a waiter fails with LockNotAvailable rather than a cancelled statement. Callers need no retry.
  • serializable(work, attempts=5) runs work(conn) in a SERIALIZABLE transaction and retries it on serialization failure or deadlock, with a short random backoff.
  • Error classes (UniqueViolation, ForeignKeyViolation, SerializationFailure, LockNotAvailable, …) and Jsonb are re-exported.

Migrations. src/database/migrations/NNNN_name.sql, applied in order by src/database/migrate.py, each file in its own transaction with its row in schema_migrations (version, name, SHA-256, applied time). A session advisory lock lets one runner work at a time, so two backends starting together never apply a file twice. The run stops, changing nothing, when an applied file has changed, an applied version has no file, or a pending file is numbered below the newest applied one. Rows the site needs live in src/database/seeds.sql (idempotent ON CONFLICT DO NOTHING inserts; today only patreon_sync_state id 1) and are applied after the migrations with seed=True. The backend migrates and seeds at start-up when DATABASE_URL is set (previews have none, so they skip it). The copy from SQLite migrates without seeding, so every table starts empty and SQLite's own patreon_sync_state row is copied in. By hand: python -m src.database.migrate [--seed] [--status] [--database-url URL].

0001_baseline.sql creates all 61 tables in schema.sql at their final shape, which includes what skins/db.py's _upgrade() adds (the role, placeholder, acting-user, nickname, link method, minted-via, tier sets, held-until and roster death columns; rpc_player_meta.donator_tier dropped) and its two indexes (idx_users_role, idx_user_sessions_acting). The tables that only old database files hold are left out: armourshop_player_meta and drink_player_meta (dropped from the schema in #85) and player_keys (removed in 99359c83). No code reads them; the copy tool should list any table it skips.

  • Every INTEGER PRIMARY KEY AUTOINCREMENT is bigint GENERATED BY DEFAULT AS IDENTITY, so the copy tool can insert the existing ids (and must setval each sequence afterwards). Singletons stay integer PRIMARY KEY CHECK (id = 1), other integers are bigint, REAL is double precision.
  • Times are timestamptz, 0/1 flags boolean, JSON jsonb (table below). Three kinds of column stay text: day strings (day, *_day), which are compared as strings and used in file paths; ledger founded_at, which is hashed into faction_key and must stay byte-exact; and character_roster.created_at, which holds RPCharacters' epoch-seconds string.
  • Collation. Every text column is COLLATE "C". The sites' existing provincesystem databases were created with the image's en_US.utf8 default, which sorts _,a,B,é,Z; "C" sorts by code point (B,Z,_,a,é), as SQLite's BINARY did, and its lower() folds ASCII only, as SQLite's does. Putting it on the columns makes the schema independent of how the database was created. Future migrations must do the same; test_schema.py fails on any text column without it.
  • Constraints, unique indexes, partial indexes, checks and foreign keys are as in SQLite (Postgres always enforces the foreign keys). The active = 1 predicates on patreon_links become WHERE active.
  • New constraints for read-check-write paths: microsoft_link_states UNIQUE (user_id) (it replaces the plain index), a partial unique index on patreon_alerts (kind) WHERE acked_at IS NULL, and two backstops for invariants SQLite's lock already kept: will_requests (realm_id, character_id) WHERE status = 'pending' and lp_changes (target_type, target) WHERE status IN ('pending', 'sent'). patreon_alerts is the one that live data might break, because alert() is also called outside a lock (patreon/client.py:172); the copy tool should report duplicate open alerts.
  • Seven indexes that duplicate a unique constraint or a primary-key prefix are left out (idx_codes_hash, idx_sessions_token, idx_discord_links_discord, idx_discord_link_codes_hash, idx_war_declare_codes_hash, idx_character_roster_player, idx_character_roster_player_realm).
  • admin_audit is append-only: one plpgsql function behind a BEFORE UPDATE OR DELETE … FOR EACH ROW trigger and a BEFORE TRUNCATE … FOR EACH STATEMENT trigger. An upsert onto an existing id fires the UPDATE trigger, so SQLite's replace trigger has no counterpart.

Tests. src/database/testing.py provides the fixtures, registered in backend/conftest.py. The session creates its own database on the server in TEST_DATABASE_URL (default en_US.utf8 locale, like the sites), applies the migrations and seeds once and points the pool at it (and reopens it if a test replaced it). A test opts in by taking the postgres fixture, or a whole module with pytestmark = pytest.mark.usefixtures("postgres"). Before each such test one query finds the tables with rows or a used identity sequence (a rolled-back insert still advances it), and one TRUNCATE empties them with identities restarted (under session_replication_role = replica, which skips the audit TRUNCATE trigger). The seed rows are copied back. It runs on one kept connection, taking about 6 ms instead of about 50 ms for truncating every table. scratch_database gives an empty database for runner tests. Without TEST_DATABASE_URL the tests skip, except under CI, where they fail. SQLite tests are unchanged.

New tests:

  • test_connection.py:
    • commit and rollback, dict rows, savepoints, session settings including search_path
    • isolation levels applying to one transaction only, read-only
    • the advisory lock serialising holders and its no-wait form; it waits past a short lock_timeout, gives up after its own bound, and puts lock_timeout back
    • write skew retried by serializable
    • pool opening and sizing
    • one connection reset twice with nothing leaking: settings, temp table, LISTEN, session advisory lock, autocommit, read-only, row factory, prepared statements
    • a leaked autocommit can no longer commit a block that raised
  • test_migrate.py:
    • order, idempotence, a failing file leaving nothing behind
    • changed files, missing files and out-of-order files refused
    • file-name checks
    • three concurrent runners applying a file once
    • seeding, status and CLI, the real migrations applied twice
  • test_schema.py:
    • parity with schema.sql after migrate(): tables, column order, allowed type per SQLite affinity, nullability, primary keys, unique sets, foreign keys with their ON DELETE action, column defaults, CHECK expressions and partial-index predicates (both sides normalised: IN vs = ANY (ARRAY[…]), casts, flag = 1 vs flag)
    • collation and code-point ordering; seed row; explicit identity ids; foreign keys
    • admin_audit refusing UPDATE, DELETE, TRUNCATE and upsert
    • the per-test reset, including a rolled-back identity tests/test_server_lifespan.py covers the start-up migration with and without DATABASE_URL.

CI. build.yml gets a pgvector/pgvector:pg17 service (tmpfs data) and sets TEST_DATABASE_URL for the backend step. Build also runs for pushes and PRs on postgres; Deploy still follows only main.

Type changes callers must handle

Each changed column returns a different Python value. Writes are more forgiving than reads: psycopg sends str parameters untyped, so an ISO string still lands in a timestamptz column and a json.dumps() string in a jsonb column, and an ISO string compared with a timestamptz column in WHERE is cast. Integers are not cast to boolean.

Postgres type Read value Write SQL
timestamptz aware datetime in UTC (tzinfo=timezone.utc) aware datetime (preferred), or an ISO string compare with a parameter or now()
jsonb parsed dict/list/scalar Jsonb(value); a bare dict cannot be adapted col ->> 'key' replaces json_extract
boolean bool bool; 0/1 ints are refused WHERE col / NOT col; = 1, = 0 and SUM(col) fail
bigint, integer, double precision int, float as before unchanged a text column compared with an int parameter fails
Table timestamptz (now datetime) jsonb (now dict/list) boolean (now bool)
codes created_at, expires_at, redeemed_at revoked
submissions created_at, reviewed_at, applied_at tiers, tier_aliases, helmet_3d_tiers, name_colours, name_styles, tier_scrolls, tier_sets add_name, staff
sessions expires_at, created_at
discord_links linked_at, left_guild_at, grace_until
discord_link_codes created_at, expires_at, used_at
war_declare_codes created_at, expires_at, redeemed_at revoked
skin_notifications created_at, delivered_at payload
plugin_notices created_at, delivered_at payload
player_warnings created_at
moderation_notifications created_at, delivered_at payload
armourshop_catalog updated_at payload
lore_item_customisations ready_at, applied_at, updated_at name_colours, name_styles, lore_json
character_wardrobe_slots updated_at apply_pending
character_create_wardrobe updated_at
drink_submissions created_at, reviewed_at, applied_at recipe_json new_texture
drink_textures created_at
drink_catalog updated_at payload
rpc_player_meta updated_at skin_kinds_json, permission_flags_json allow_drink_texture, allow_drink_message, allow_armor_3d_helmet
drink_notifications created_at, delivered_at payload
cosmetic_mint_resets reset_at
map_chronicle_snapshots manifest
map_chronicle_snapshots_archive manifest
map_ledger_days captured_at globals complete
map_ledger_factions first_seen_at, last_seen_at, deleted_at
map_ledger_faction_days wealth_breakdown, prestige_breakdown, subjects, wars
patreon_members declined_since, updated_at data_json
patreon_links linked_at, person_changed_at active
patreon_tokens expires_at
patreon_leases expires_at
patreon_sync_state last_sync_at last_sync_ok, brake_held
patreon_applied tiers_json ever_granted
patreon_desired grace_until
patreon_changes grace_until, created_at, dispatched_at, acked_at, cancelled_at remove_json dm_suppressed
patreon_alerts created_at, acked_at
patreon_oauth_states expires_at, used_at
patreon_pending_links expires_at, used_at
patreon_webhook_members recorded_at
creation_catalog updated_at payload
character_creates created_at, applied_at, held_until payload
character_roster updated_at, died_at kit_statuses_json, sheet_json
character_estates source_updated_at, received_at payload
will_requests created_at, acked_at
character_player_meta updated_at kit_cooldowns_json eighteen, real_age_set
users created_at, updated_at, last_login_at placeholder
user_sessions guild_checked_at, created_at, expires_at guild_member
discord_oauth_states expires_at
preview_sign_in_tickets guild_checked_at, expires_at guild_member
microsoft_link_states expires_at, used_at
admin_audit created_at detail_json
lp_state received_at, checked_at, polled_at
lp_group_nodes contexts value
lp_tracks groups_json
lp_user_nodes contexts value
lp_commands received_at payload
lp_changes created_at, sent_at, finished_at ops_json
diagnostic_reports created_at report_json

Places the type sweep found that break or change behaviour silently, for the area PRs:

  • Strict parsing of stored times. Every strptime(value, "%Y-%m-%dT%H:%M:%SZ"), _parse_iso, parse() or fromisoformat on a read value raises or misbehaves on a datetime; so does str(value) (it gives 2026-10-16 10:00:00+00:00). Examples: skins/codes.py:220/865 (cooldown), skins/discord_link.py:80/495, wars/declare_codes.py:131, auth/users.py:105/202/235, luckperms/mirror.py:40, luckperms/changes.py:56, patreon/resolver.py:47.
  • API output format. FastAPI serialises a datetime as 2026-10-16T10:00:00+00:00, where the stored strings were mostly …Z. Pin the format where a client parses it strictly: TFMCWeb (last_mint_at), the preview sign-in hand-off (preview_sign_in.py:118-143 expects the Z form), and tests that assert exact strings.
  • Silent JSON fallbacks. Reads that json.loads inside except (…, TypeError) would quietly return {}/[] on an already-parsed value: the four notification payloads, drink_catalog.payload, recipe_json, chronicle manifest (scripts/chronicle/store.py:339-343 reads the TypeError as "referencing"), ledger globals/breakdowns/subjects/wars, roster kit_statuses_json/sheet_json, estates payload. Reads without the fallback fail loudly instead (patreon/service.py, lp_*, creation_catalog, armourshop_catalog, lore_json, report_json).
  • Equality and identity on times. patreon/service.py:158/162 compare stored grace_until with a fresh ISO string (on datetime they would never match, bumping the generation and re-sending DMs every sync); service.py:224 builds link_event as id + ":" + linked_at, which must keep the same string (iso(linked_at) reproduces isoformat()); characters/estates.py:230 compares a pushed string with the stored source_updated_at.
  • Flags in SQL. = 1/= 0 on placeholder, active, revoked, complete, ever_granted, staff (for example auth/users.py:41/135, auth/admin.py:46/142, patreon/service.py:81…595, scripts/ledger/store.py:510, characters/lore_items.py:769/771); int(value) on a bool still works.
  • Unchecked input. skins/submissions.py:1302 compares reviewed_at with the plugin's raw since query string; a malformed value becomes a Postgres cast error, so validate it first.
  • character_player_meta.kit_cooldowns_json is written back as the read value (characters/roster.py:661); wrap it in Jsonb().

Design note for the area PRs

Connections and transactions. Use with database.connect() as conn: once per unit of work and pass conn to helpers. Placeholders are %s (or %(name)s); a literal % in SQL with parameters is written %%. Rows are dicts: row[0] no longer works, and when a join returns two columns with the same name dict_row keeps the last one, where sqlite3.Row returned the first. Alias every duplicate column name (SELECT s.id AS session_id, u.id AS user_id …), and avoid SELECT * across joins. INSERT … RETURNING id replaces lastrowid. Do not call conn.commit() inside the block unless the work really is two transactions; never keep a connection outside its block. An error aborts the whole transaction, so code that catches a database error and carries on must wrap the failing statement in with conn.transaction(): (a savepoint), as skins/submissions.py:967 will need for slug conflicts. Async routes should call database code through run_in_threadpool.

Locking. Postgres runs READ COMMITTED, so a read followed by a write no longer sees the world frozen as SQLite's BEGIN IMMEDIATE did. Pick, in order of preference:

  • B, compare-and-set: fold the check into the write, UPDATE … SET used_at = %s WHERE key = %s AND used_at IS NULL AND expires_at > %s RETURNING …, or DELETE … RETURNING. No row back means someone else won; re-read only to choose the error.
  • C, a constraint: INSERT … ON CONFLICT … DO NOTHING RETURNING … (or DO UPDATE) where the race is two inserts of one key.
  • A, row locks: SELECT … FOR UPDATE (or FOR SHARE when you only need the row to stay as read) on the rows the check reads, when several statements follow. Lock in a consistent order (ORDER BY id).
  • D, an advisory lock: database.transaction(lock="area:what:key") or advisory_lock(conn, …) as the first statement, when the check is on absence or an aggregate with no row to lock. Every path that changes what the check reads must take the same key. The wait is bounded at 20 s by the helper, with no retry needed.
  • E, database.serializable(work): for the rare transaction too tangled for the above; work may run several times, so it must not call out.

Shared recipes used below:

  • X1: staff re-checks call session_user_in(conn, token, lock=True) (auth/users.py:186). The opt-in lock=True adds FOR SHARE OF s, u, so a demotion or revocation waits for the action, or the action sees it. Ordinary auth reads keep lock=False and take no lock.
  • X2: _end_where (auth/view_as.py:164) selects the view sessions FOR UPDATE OF s, so two stops end and audit a view once.
  • X3: every transaction that calls drop_view_leftovers (auth/users.py:28), and view_as.start, opens with database.transaction(lock="auth:placeholders"). The lock is taken as the transaction's first statement, before any row lock, never inside drop_view_leftovers. Its DELETE … WHERE placeholder AND id NOT IN (…) could otherwise delete a placeholder whose view is being committed.
  • X4: D on patreon around anything that runs _compute/_plan (patreon/service.py:214), which reads and rewrites every Patreon table.
  • X5: seed lp_state (ON CONFLICT (id) DO NOTHING), then SELECT … FROM lp_state WHERE id = 1 FOR UPDATE as the transaction's first lock.

LuckPerms lock order: lp_state first, then users and sessions (X1), then lp_changes rows, in every LuckPerms transaction.

The 42 BEGIN IMMEDIATE paths:

Path Protects Pattern
Accounts
auth/users.py:98 consume_state single-use Discord OAuth state B: DELETE … WHERE state_hash = %s RETURNING return_to, expires_at
auth/users.py:126 sign_in upsert user, open session C on users.discord_user_id with RETURNING id; X3
auth/admin_cli.py:28 _set_root never remove the last root A: lock all roots ORDER BY id FOR UPDATE and count, or the target row for a grant; X3
auth/admin.py:56 _staff_write rank checks, role change, revocation A: actor and target users rows ORDER BY id FOR UPDATE, then X1; X3
auth/view_as.py:35 start open a view, make a placeholder X1 + X3; C for the placeholder (ON CONFLICT (discord_user_id) DO NOTHING, re-select); X2
auth/view_as.py:134 stop, :198 end end a view X2 + X3
auth/view_as.py:215 end_lapsed end an admin's expired views X1 + X2 + X3
auth/preview_sign_in.py:97 redeem single-use preview ticket B: DELETE … RETURNING joined to users
api/admin_routes.py:267 _server_write role re-check with the audit row X1
Linking
auth/microsoft_link.py:38 start one live attempt per user C on the new UNIQUE (user_id): ON CONFLICT (user_id) DO UPDATE
auth/microsoft_link.py:61 consume single-use Microsoft state B: UPDATE … SET used_at WHERE … AND used_at IS NULL AND expires_at > %s RETURNING
skins/discord_link.py:321 complete_link code redeemed once; a link never replaces another B on the code; C in _bind (ON CONFLICT DO NOTHING RETURNING, re-select for the error)
skins/discord_link.py:353 link_verified_profile link only while the attempt and session live A: FOR SHARE on the state and session rows in microsoft_link.still_valid; C in _bind
Skins
skins/codes.py:716 start_linked_profile_session Profile session only while the view exists A: FOR SHARE in view_as.view_still_open; nothing without still_allowed
skins/codes.py:930 start_site_session shared skin and drink cooldown, one mint D: cosmetic_mint:<lower(uuid)> (across realms). The TFMCWeb mint insert (skins/codes.py:286, in issue_code) takes the same lock, so both mint paths and the cooldown read are serialised.
skins/codes.py:503 redeem_code, :750 redeem_drink_code (no BEGIN IMMEDIATE today) a code is consumed by one submission A: SELECT … FROM codes WHERE id = %s FOR UPDATE and re-validate (not revoked, not expired, no submission yet) inside the submission-insert transaction: create_submission (skins/submissions.py:874-882) for skins, create_drink_submission (skins/drinks.py:797-800) for drinks. The submission insert is what consumes the code; the redeem step only opens a session, so it can stay a plain read.
Characters
characters/wills.py:134 create_will_request one pending request per character A: the character's roster row FOR UPDATE; the new partial unique index backs it
characters/wills.py:212 ack_will_results pending to acked once B: UPDATE … WHERE id = %s AND status = 'pending' RETURNING
LuckPerms
luckperms/mirror.py:125 replace_snapshot only a newer revision replaces the mirror X5
luckperms/changes.py:325 queue_change one open change per target, checked against one mirror seed lp_state and take it FOR SHARE first, so build_*_ops and check_ops_allowed read one consistent mirror while replace_snapshot waits; then X1, then C on the new partial unique index (or D on lp_change:<type>:<target>)
luckperms/changes.py:496 fetch_for_bridge each change handed out once record_poll's upsert first (row-locks lp_state); _current_actor (changes.py:444) reads the actor row FOR SHARE; then B per row (… WHERE status = 'pending')
luckperms/changes.py:553 record_results settle a change once X5 first (lp_state FOR UPDATE before any lp_changes row, matching fetch_for_bridge's order; today apply_state locks it after the updates, which can deadlock); then B: … WHERE id = %s AND status IN ('sent', 'unknown') RETURNING
Patreon
patreon/client.py:164 refresh_tokens only the lease holder rotates tokens A: the token_refresh lease row FOR UPDATE; the token UPDATE is already B
patreon/service.py:30 acquire_lease take a free or expired lease C: ON CONFLICT (name) DO UPDATE … WHERE expires_at <= %s RETURNING owner
patreon/service.py:141 create_or_update_link subject uniqueness, relink cooldown X4
patreon/service.py:259 recompute_link, :267 unlink, :495 release_brake, :566 staff_link, :583 import_links recompute desired state and outbox X4
patreon/service.py:309 _sync_failure failure counter, one alert per kind its first UPDATE patreon_sync_state locks the row; C on the new patreon_alerts index in alert()
patreon/service.py:333 _run_fetch store members only while holding the lease X4 + A on the sync lease row
patreon/service.py:430 list_changes dispatch without racing _plan's cancel X4
patreon/service.py:454 ack_changes apply an ack once X4
patreon/linking.py:57 start_link, :159 _finish_callback insert a random-hash state none needed
patreon/linking.py:88 consume_state single-use Patreon OAuth state B (already compare-and-set; add RETURNING)
patreon/linking.py:174 _pending spend a confirmation token B with RETURNING when consume; today line 180 ignores the row count, so two confirms would both link
patreon/linking.py:214 _confirm_link link then recompute X4
Schema and studio
skins/db.py:101 _upgrade ALTER TABLE upgrades goes: migrations replace it
dbstudio/studio.py:246 _site one ad-hoc write goes: the studio's site source moves to its Postgres path

Read-then-write races without BEGIN IMMEDIATE that the same review found: luckperms/changes.py:460-469 settle_timeouts (B: add the status to the WHERE), skins/codes.py:592-635 redeem_profile_code (B), the skin and drink submission inserts (in the table above), skins/codes.py:125-153 ensure_lore_upload_code (C on code_hash), and patreon/service.py:50 alert (C). None of the 42 paths catches a database error and then keeps using its connection.

Tests. Convert an area's tests to take postgres (module-level usefixtures), drop their DB_PATH patching and migrate() calls, and seed through the area's own functions or plain SQL. Tests that only check SQLite behaviour (iterdump, PRAGMA, legacy upgrades) go with the area's SQLite code.

Checks

  • src/database and tests/test_server_lifespan.py: 46 passed against pgvector/pgvector:pg17 locally.
  • Full backend suite with TEST_DATABASE_URL set: 2,432 passed, 4 skipped, 175 subtests passed (13 min 8 s), the new database tests included.

Not deployed anywhere: postgres is not deployed until the copy tool and host plumbing land (plan on #251).

🤖 Generated with Claude Code

ryanbarlow97 and others added 3 commits October 16, 2026 18:05
src/database gives connect() (one transaction per block, dict rows, UTC,
lock and statement timeouts), transaction() with isolation, read-only and
an advisory lock, advisory_lock() and serializable() with retry. Nothing
uses it yet; the SQLite connect() stays until each area moves over (#251).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…able

Numbered SQL files run in order under an advisory lock, each in its own
transaction, at start-up when DATABASE_URL is set and from
python -m src.database.migrate. 0001_baseline.sql covers the 61 tables of
schema.sql plus _upgrade(): identity ids, timestamptz, boolean, jsonb,
COLLATE "C" on every text column, and admin_audit refusing UPDATE, DELETE
and TRUNCATE.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Tests that take the postgres fixture run against TEST_DATABASE_URL in a
database the session creates and migrates once; every table is emptied
before each test. Build gets a pgvector/pgvector:pg17 service and runs for
the postgres branch too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Oct 16, 2026 •

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration
  • Configuration used: Organization UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 0f07dbcd-7da5-4494-8bf9-9ff963e2c4c2

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

ryanbarlow97 and others added 3 commits October 16, 2026 18:42
…ately

A returned connection kept session settings, temp tables, LISTENs,
session advisory locks and autocommit for the next borrower; the pool now
resets each one (DISCARD ALL's parts, minus DEALLOCATE ALL, which psycopg
3.3 stops noticing after a connection's first reset). Connections pin
search_path to public. advisory_lock() waits up to 20 s, not the 5 s row
lock bound.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The runner stops when an applied version has no file or a pending file is
numbered below the newest applied one. The patreon_sync_state row moves
from the baseline to seeds.sql, inserted at start-up with ON CONFLICT DO
NOTHING, so the copy from SQLite starts from empty tables.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… tables

The parity test also compares column defaults, CHECK expressions, foreign
key delete actions and partial-index predicates with schema.sql. The
per-test reset truncates only tables with rows or a used identity, on one
kept connection (about 6 ms instead of 50 ms).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@ryanbarlow97

Copy link
Copy Markdown
Contributor Author

Reviewed by Muse (muse-wonder-full) over two rounds; round 2 returned VERDICT: OK (46 database tests, and a 158/158 column-type check against a freshly migrated database). The dead import nit is fixed. Merging into the postgres integration branch.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant