Repository navigation
Description
Tracking issue for moving province.db from SQLite to the site's Postgres. Precedent and patch notes already moved there (#250). Both sites run their own db service, and Supabase is no longer used. The plan below was written by reading the code; PR numbers here are steps, not GitHub numbers. Each step leaves both sites on SQLite until its cutover.
Summary
province.db is reached almost entirely through one seam, src.skins.db.connect()
(backend/src/skins/db.py:14-19), which about 50 modules in backend/src import (59 counting server.py and the scripts). Only the Database studio
(backend/src/dbstudio/studio.py:240) and the two host scripts (scripts/site-deploy.sh:67-74,
scripts/ps-preview.sh:97-107) open the file directly. That makes a compatibility connection layer
the cheapest safe route: keep the roughly 470 SQL literals (about 1,225 ? placeholders) as they are,
fix the few dozen genuinely SQLite-only constructs at source while still on SQLite, and switch engines
with DATABASE_URL.
The real risks are not syntax. They are:
- Locking semantics. province.db runs in rollback-journal mode, where a writer cannot commit while
another connection reads, and 42 code paths takeBEGIN IMMEDIATEto serialise read-check-write
sequences (single-use codes, leases, LuckPerms snapshots, Patreon brake). Postgres's default
READ COMMITTED is weaker. The plan keeps today's single-writer behaviour with a transaction-scoped
advisory lock in the connection layer. - Connection cost. The code opens a connection per call (266
with connect() as conn:blocks,
28conn = connect()sites), which is free on SQLite and expensive on Postgres. A pool is required. - Tests. 58 test files patch
DB_PATHto a temporary file. They need one shared helper that
works on both engines, and CI needs a Postgres service. - Host tooling. Deploy backups and preview copies both assume a SQLite file.
6. Staged PRs
Each PR leaves both sites working on SQLite until the cutover steps. Docs PRs go to the Docs repo and are
linked from the code PR.
| # | PR | Main files | Risk | Verify |
|---|---|---|---|---|
| 1 | Portable SQL on SQLite. RETURNING id for the 10 lastrowid sites; ON CONFLICT for INSERT OR IGNORE/REPLACE (including schema.sql:477); qualify ledger/store.py:379-395 and replace scalar MIN/MAX; lower() for COLLATE NOCASE; Python-side json_extract; drop runtime PRAGMA table_info checks and the audit.py:157 fallback; positional params in codes.py:197-217; NULLS FIRST where needed. |
modules listed in section 3 | Low. Behaviour unchanged; RETURNING needs SQLite 3.35+. |
Full pytest on SQLite; check sqlite3.sqlite_version in the image and CI. |
| 2 | Database package, SQLite only. src/database/ with connect, migrate, Row, error tuples; skins/db.py re-exports; sqlite3.Row hints and sqlite3 exception catches switched to database.*. |
backend/src/database/*, skins/db.py, submissions.py, wills.py, estates.py, discord_names.py |
Low. | Full suite unchanged. |
| 3 | Postgres backend in the package. Pool, translator, row factory, bool dumper, advisory writer lock, timeouts; psycopg-pool pinned. Off unless DATABASE_URL is set. |
database/postgres.py, translate.py, requirements.txt |
Low (inactive). | Translator unit tests; wrapper tests against a CI Postgres service (introduced here, one test file). |
| 4 | Postgres migrations. Runner, 0001_baseline.sql, triggers, schema parity test. |
database/migrations/*, database/__init__.py |
Low (inactive). | Migrate twice is idempotent; parity with schema.sql; admin_audit refuses UPDATE, DELETE and TRUNCATE. |
| 5 | Test helper and CI matrix. database/testing.py; convert the 8 conftests and ~30 unittest classes; mark SQLite-only tests; build.yml gets the postgres service and db matrix, with the Postgres leg continue-on-error. |
backend/conftest.py, src/*/conftest.py, ~50 test files, .github/workflows/build.yml |
Medium (test churn). | SQLite leg green with the same test count; the Postgres leg's failure list drives PRs 6-10. |
| 6 | Accounts and linking on Postgres. | auth/*, skins/discord_link.py, api/admin_routes.py, api/database_routes.py |
Medium. Sign-in, view-as and sessions. | Postgres leg green for auth/, api/test_*auth*. |
| 7 | Skins, drinks, codes, moderation, wars. | skins/*, wars/declare_codes.py |
Medium. | Postgres leg green for these packages; scripts/skins_e2e_smoke.py against a Postgres stack. |
| 8 | Characters and wardrobe. | characters/* |
Medium. Plugin ingest paths. | Postgres leg green; character_ingest_smoke.py, lore_items_smoke.py. |
| 9 | Patreon, LuckPerms, diagnostics. Audit read-before-write transactions; check the LuckPerms snapshot time with a large payload (thousands of per-user statements; use pipeline mode if slow). | patreon/*, luckperms/*, diagnostic/* |
Medium-high (money and permissions). | Postgres leg green; time a 2,000-user snapshot apply. |
| 10 | Chronicle and ledger. Engine branch for the temp table; check that ingest promotion stays inside its time budget. | scripts/chronicle/*, scripts/ledger/*, scripts/maps/archive_copy.py, tools/generate_spoof_ledger.py, api/ledger_routes.py |
Medium. | Postgres leg green; time /ledger/series and a day promotion with a 2,000-faction payload. Then make the Postgres leg required. |
| 11 | Database studio on Postgres. site source follows DATABASE_URL; studio role; tests for both engines. |
dbstudio/studio.py, api/database_routes.py, tests; Docs database-studio.md |
Medium (root write access). | Attempts to change admin_audit, setval, DROP, TRUNCATE and ALTER all refused under the studio role. |
| 12 | Copy tool. Both directions, verification, CI round trip. | scripts/tools/copy_province_db.py + test |
Medium. | Round trip equal; run --verify-only against a copy of Dev's file made on the host. |
| 13 | Host plumbing. db services in both compose files (pg17, builtin C.UTF-8 locale, healthcheck, volume); site-deploy.sh pg_dump branch; ps-preview.sh per-preview database and role, scrub, drop on reap; admin_cli --database-url; staging import tool updated or retired. |
docker-compose*.yml, scripts/site-deploy.sh, scripts/ps-preview.sh, scripts/staging-import-prod-data.sh, scripts/retag-realm.py, auth/admin_cli.py; Docs DEPLOY.md |
High (root-installed scripts). | Both branches tested on Dev; reinstall /usr/local/sbin copies; preview up/down twice; deploy once with a backup. |
| — | Cutover Dev (ops, section 5.5). | host override | High. | Smoke list; previews come up from province_dev. |
| — | Cutover www (ops, on request). | host override | High. | Smoke list; first site-deploy produces a .dump. |
| 14 | Remove SQLite for province.db. Drop schema.sql, _upgrade, the SQLite backend, SQLite-only tests and the matrix. Optional codemod ? → %s, then delete the translator. CoreProtect stays on SQLite. |
skins/db.py, database/*, tests, build.yml; Docs architecture, overview, ledger, skins, tfmcweb, auth-security, patreon-backend, workspace AGENTS.md |
Low-medium. | Full suite on Postgres. |
| 15 | psycopg 3 for Precedent and patch notes. | precedent/db.py, patchnotes/db.py, api/precedent_routes.py, api/patchnotes_routes.py, scripts/*precedent*.py, requirements.txt |
Medium (with conn closes in psycopg 3). |
Their mocked tests updated, plus one real Postgres test each; patch note jobs updated on the host. |
Follow-ups, outside this plan
-
About 20 API modules have
async defhandlers that call synchronous database code on the event loop:characters_routes.py:218-287skins_routes.py:573luckperms_routes.py,patreon_routes.py,admin_routes.py,diagnostic_routes.py,drinks_routes.py,data_routes.py,chronicle_routes.py.
Network round trips make those stalls longer than on SQLite. Move the database parts into
run_in_threadpool, aschronicle_staff_routes.pyandview_as_guard.pyalready do. -
Typed columns:
timestamptzfor the*_attext columns once the formats are normalised,booleanfor 0/1 flags,jsonbfor*_json/payload/manifest. -
Replace the global writer lock with row locks area by area, if write throughput ever matters.
The full plan (inventory, dialect issues with file:line, host tooling, copy tool and cutover steps) is in the first comment.
🤖 Generated with Claude Code
Activity
ryanbarlow97 commentedon Oct 16, 2026
Moving province.db from SQLite to PostgreSQL 17
Research plan, written from the code in
ProvinceSystematdd1e1674(main, 2026-10-16). Paths arerelative to
ProvinceSystem/unless they start withDocs/. Counts come from an AST scan of stringliterals plus
rg; they are close but not exact (dynamic SQL built in f-strings is counted once per literal).Summary
province.db is reached almost entirely through one seam,
src.skins.db.connect()(
backend/src/skins/db.py:14-19), which about 50 modules inbackend/srcimport (59 countingserver.pyand the scripts). Only the Database studio(
backend/src/dbstudio/studio.py:240) and the two host scripts (scripts/site-deploy.sh:67-74,scripts/ps-preview.sh:97-107) open the file directly. That makes a compatibility connection layerthe cheapest safe route: keep the roughly 470 SQL literals (about 1,225
?placeholders) as they are,fix the few dozen genuinely SQLite-only constructs at source while still on SQLite, and switch engines
with
DATABASE_URL.The real risks are not syntax. They are:
another connection reads, and 42 code paths take
BEGIN IMMEDIATEto serialise read-check-writesequences (single-use codes, leases, LuckPerms snapshots, Patreon brake). Postgres's default
READ COMMITTED is weaker. The plan keeps today's single-writer behaviour with a transaction-scoped
advisory lock in the connection layer.
with connect() as conn:blocks,28
conn = connect()sites), which is free on SQLite and expensive on Postgres. A pool is required.DB_PATHto a temporary file. They need one shared helper thatworks on both engines, and CI needs a Postgres service.
1. Inventory
1.1 How province.db is opened
backend/src/skins/db.py:14connect()sqlite3.connect(DB_PATH),row_factory = sqlite3.Row,PRAGMA foreign_keys = ONBEGINbefore DML). Never closes onwithexit.backend/src/skins/db.py:22migrate()executescript(schema.sql)then_upgrade()backend/server.py:52and by scripts and tests.backend/src/scripts/ledger/store.py:124open_connection()connect()ingest.index_snapshot).backend/src/dbstudio/studio.py:240scripts/site-deploy.sh:67-74sqlite3backup API inside the backend containerdeploy-backups/province.db.before-deploy-*, keeps 10.scripts/ps-preview.sh:97-107sqlite3 .backupof Dev's file, thenDELETE+VACUUMscripts/retag-realm.py:21connect()executecalls.Not in scope:
backend/src/coreprotect/reader.py:118and the studio'scoreprotectsource open theMinecraft server's CoreProtect SQLite file.
backend/src/auth/players.pyreads both CoreProtect(lines 123-124, 343-369, which stay on SQLite) and province.db (lines 61-81, 411); the new layer must only
apply to province connections.
1.2 Modules that query province.db
"SQL" is the number of SQL string literals; "connect" is the number of
connect()call sites.backend/src/skins/db.py_upgrade, 9PRAGMA table_infobackend/src/skins/codes.pylastrowid730/955,BEGIN IMMEDIATE716/930backend/src/skins/submissions.pysqlite3.IntegrityError967,PRAGMA table_info419/877, 8sqlite3.Rowhintsbackend/src/skins/drinks.pylastrowid244backend/src/skins/discord_link.pyBEGIN IMMEDIATE321/353backend/src/skins/moderation.pylastrowid58/109backend/src/skins/notifications.pylastrowid46backend/src/skins/plugin_notices.pylastrowid47backend/src/skins/catalog.pybackend/src/skins/review_sheet.py,drink_review_sheet.pybackend/src/skins/seed_dev_code.pybackend/src/characters/wardrobe.pybackend/src/characters/lore_items.pyPRAGMA table_info1416/1705/1801,COLLATE NOCASE775backend/src/characters/creates.pybackend/src/characters/wills.pyBEGIN IMMEDIATE134/212,LIKE … ESCAPE318-320backend/src/characters/realm_wipe.pyIN (?,…)136backend/src/characters/roster.pyexecutemany573/593backend/src/characters/estates.pysqlite3.Rowhint 263backend/src/characters/rpc_player_meta.py,creation_catalog.py,pending_create.pybackend/src/auth/users.pyLIKE 'staff_view:%'35,BEGIN IMMEDIATE98/126backend/src/auth/view_as.pyBEGIN IMMEDIATEbackend/src/auth/microsoft_link.pybackend/src/auth/players.pybackend/src/auth/admin.py,admin_cli.py,audit.py,preview_sign_in.py,discord_names.py,account_overview.pyadmin_cli.py:68-85takes--db <file>backend/src/luckperms/mirror.pyINSERT OR REPLACE88/98/134backend/src/luckperms/changes.pylastrowid370backend/src/luckperms/commands.py,views.pybackend/src/patreon/service.pyBEGIN IMMEDIATE,COLLATE NOCASE547backend/src/patreon/linking.pybackend/src/patreon/client.pyINSERT OR IGNORE … VALUES (1, ?, ?, ?)without a column list, 148backend/src/diagnostic/reports.pyINSERT OR IGNORE45backend/src/wars/declare_codes.pylastrowid185backend/src/api/admin_routes.py,database_routes.py,ledger_routes.pyjson_extractindatabase_routes.py:60backend/src/scripts/chronicle/store.pybackend/src/scripts/chronicle/wipe.pyINSERT OR REPLACE71backend/src/scripts/chronicle/restore.pyINSERT OR IGNORE158backend/src/scripts/chronicle/audit.pylastrowid106,sqlite3.OperationalError157backend/src/scripts/ledger/store.pyMIN/MAX386/390backend/src/scripts/ledger/wipe.py,reindex.py,remap_wealth_keys.py,ingest.pystorebackend/src/scripts/maps/archive_copy.pybackend/src/scripts/tools/generate_spoof_ledger.pyos.path.exists(DB_PATH)723backend/src/dbstudio/studio.pybackend/scripts/*_smoke.py(6 files)rpc_player_meta_smoke.pyalready fails to parse (IndentationError at line 85)Total: about 600 SQL literals in
backend/src, 42BEGIN IMMEDIATE, 127commit(), 18rollback().1.3 Tables
backend/src/skins/schema.sqlcreates 61 tables, 54 indexes and 3 triggers. Withsqlite_sequencethat is 62. The live files report 64, so they probably still hold the tables dropped from the schema in
#85 (
d98754de, 2026-10-04:armourshop_player_meta,drink_player_meta) orplayer_keys(removed99359c83, 2026-08-08). No code reads them. Before the copy, confirm withsudo sqlite3 -readonly …/province.db .tableson each site and decide whether to archive or drop them.W = writes, R = reads only (from the scan, with dynamic table names added by hand).
Skins and cosmetic codes (6)
codes: Wskins/codes.py,skins/submissions.py,skins/seed_dev_code.py; Rauth/users.py,auth/view_as.py.submissions: Wskins/submissions.py,skins/db.py(_set_legacy_armor_sets); Rcharacters/lore_items.py,skins/codes.py,skins/review_sheet.py.sessions(code sessions): Wskins/codes.py,auth/users.py; Rauth/view_as.py.skin_notifications: W/Rskins/notifications.py.cosmetic_mint_resets: W/Rskins/codes.py.armourshop_catalog(singletonid = 1): W/Rskins/catalog.py.Drinks (4):
drink_submissions,drink_textures,drink_catalog(singleton),drink_notifications. All W/R inskins/drinks.py;drink_submissionsalso R inskins/codes.pyandskins/drink_review_sheet.py.Moderation (2):
player_warnings,moderation_notifications. W/Rskins/moderation.py.Discord and Microsoft linking (4)
discord_links: Wskins/discord_link.py,skins/db.py(_move_nicknames_out_of_usernames); Rauth/admin.py,auth/discord_names.py,auth/players.py,auth/view_as.py,luckperms/changes.py,luckperms/views.py,patreon/linking.py,patreon/service.py,skins/codes.py.discord_link_codes: Wskins/discord_link.py; Rpatreon/linking.py.microsoft_link_states: Wauth/microsoft_link.py,skins/discord_link.py.plugin_notices: Wskins/plugin_notices.py,skins/discord_link.py.Website accounts and sessions (5)
users: Wauth/users.py,auth/view_as.py,auth/admin.py,auth/admin_cli.py; Rauth/players.py,auth/preview_sign_in.py,luckperms/changes.py,luckperms/views.py,skins/discord_link.py.user_sessions: W the same four auth modules; Rauth/microsoft_link.py.discord_oauth_states: W/Rauth/users.py.preview_sign_in_tickets: W/Rauth/preview_sign_in.py.admin_audit(append-only by trigger): Wauth/audit.py(called from many routes); Rapi/database_routes.py; protected indbstudio/studio.py:47.Characters (8)
character_creates: Wcharacters/creates.py,characters/wardrobe.py,characters/realm_wipe.py; Rcharacters/lore_items.py,characters/pending_create.py.character_roster: Wcharacters/roster.py,characters/estates.py,characters/wardrobe.py,characters/realm_wipe.py; Rauth/players.py,characters/creates.py,characters/lore_items.py,characters/pending_create.py,characters/wills.py.character_estates: W/Rcharacters/estates.py.will_requests: W/Rcharacters/wills.py.character_player_meta: W/Rcharacters/roster.py.rpc_player_meta: W/Rcharacters/rpc_player_meta.py.creation_catalog(singleton): W/Rcharacters/creation_catalog.py.lore_item_customisations: Wcharacters/lore_items.py,characters/creates.py,characters/realm_wipe.py; Rskins/submissions.py.Wardrobe (2):
character_wardrobe_slots,character_create_wardrobe. W/Rcharacters/wardrobe.py,characters/realm_wipe.py.Wars (1):
war_declare_codes. W/Rwars/declare_codes.py.Map chronicle (3)
map_chronicle_snapshots: Wscripts/chronicle/store.py,chronicle/wipe.py,chronicle/restore.py,scripts/maps/archive_copy.py; R throughstorefromapi/chronicle_routes.pyandchronicle/verify.py.map_chronicle_snapshots_archive: Wchronicle/wipe.py; Rchronicle/restore.py.map_chronicle_wipes: W/Rchronicle/audit.py, used byapi/chronicle_staff_routes.py.Economy ledger (4):
map_ledger_days,map_ledger_factions,map_ledger_faction_days,map_ledger_guild_days. Wscripts/ledger/store.py(throughingest.py,reindex.py,remap_wealth_keys.py),ledger/wipe.py,maps/archive_copy.py,tools/generate_spoof_ledger.py; R throughstorefromapi/ledger_routes.pyandapi/data_routes.py.Patreon (13):
patreon_members,patreon_links,patreon_tokens(singleton, secrets),patreon_leases,patreon_sync_state(singleton, seeded atschema.sql:477),patreon_applied,patreon_desired,patreon_changes,patreon_alerts,patreon_brake_approvals,patreon_oauth_states,patreon_pending_links,patreon_webhook_members. W/Rpatreon/service.py,patreon/linking.py,patreon/client.py.LuckPerms mirror (8):
lp_state,lp_groups,lp_group_nodes,lp_tracks,lp_users,lp_user_nodes(Wluckperms/mirror.py,lp_statealsoluckperms/changes.py; Rluckperms/views.py,auth/account_overview.py),lp_commands(luckperms/commands.py),lp_changes(luckperms/changes.py).Diagnostics (1):
diagnostic_reports. W/Rdiagnostic/reports.py.2. Schema and migrations today, and on Postgres
Today
migrate()(db.py:22-35) runs all ofschema.sqlwithexecutescripton every start. Everystatement is
CREATE … IF NOT EXISTS, plus oneINSERT OR IGNOREseed row (schema.sql:477)._upgrade()(db.py:99-141) runs in oneBEGIN IMMEDIATEtransaction. It readsPRAGMA table_infofor eight tables and adds missing columns one by one(
users.role,users.placeholder,user_sessions.acting_user_id,discord_links.discord_nicknameandlink_method,codes.minted_via,submissions.tier_sets,character_creates.held_until, fivecharacter_rosterdeath columns). It dropsrpc_player_meta.donator_tier. It runs two data fix-ups:_move_nicknames_out_of_usernames(once, when the column is added) and_set_legacy_armor_sets(every start, only touches rows where
tier_sets IS NULL).lore_items.py:1416, 1705, 1801andsubmissions.py:419, 877callPRAGMA table_infoand branch on whether a column exists.chronicle/audit.py:157treatssqlite3.OperationalErroras "table not created yet".schema.sqlplus_upgradeproduce".On Postgres
_upgrade's column additions apply.The copied data already carries the results of the two fix-ups.
schema_migrations(version text primary key, applied_at timestamptz default now())backend/src/database/migrations/NNNN_name.sql, each applied in its own transactionpg_advisory_lock(<migrate key>)around the run, so two backends starting together (or a previewracing Dev) never apply the same file twice.
0001_baseline.sqlisschema.sqltranslated:INTEGER PRIMARY KEY AUTOINCREMENTbecomesbigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY.BY DEFAULT lets the copy insert existing ids.
INTEGERbecomesbigint. SQLite integers are 64-bit, and several columns holdepoch values the plugins send (
lp_state.generated_at,character_player_meta.account_created_at_epoch,lp_*_nodes.expiry,map_ledger_days.captured_at_ts).Docs/…/map/ledger.md:144alreadydocuments signed 64-bit.
REALbecomesdouble precision.TEXTstaystext.integer PRIMARY KEY CHECK (id = 1).INSERT … ON CONFLICT DO NOTHING.admin_audittriggers (schema.sql:779-795) become one plpgsql functionwith a
BEFORE UPDATE OR DELETE … FOR EACH ROWtrigger and aBEFORE TRUNCATE … FOR EACH STATEMENTtrigger. The replace trigger is unnecessary because Postgres has no
INSERT OR REPLACE, and theprimary key already rejects a reused id. The TRUNCATE trigger is new: row triggers do not fire on
TRUNCATE.
schema.sql:455-458, 660-662, 841) and theCHECK ((a IS NULL) <> (b IS NULL))constraints (535, 548) are valid Postgres as written.submissions,sessionsanddrink_submissionstocodes;user_sessionsandpreview_sign_in_ticketstousers, with cascade) are always enforced in Postgres. Today theydepend on
PRAGMA foreign_keys = ONper connection.text, 0/1 flags asbigint, JSON astext. Changingtypes is a separate project, because the stored formats are mixed:
%Y-%m-%dT%H:%M:%SZ(for exampleskins/codes.py:30)characters/estates.py:64writes millisecondspatreon/resolver.py:14anddiagnostic/reports.py:40-81writeisoformat()with+00:00andmicroseconds.
Converting would change every value the API returns.
--locale-provider=builtin --builtin-locale=C.UTF-8(Postgres 17).Text comparison and
ORDER BYthen follow code-point order, like SQLite's BINARY collation; thedefault
en_US.UTF-8would reorder names and break lexical timestamp ranges with mixedZand+00:00suffixes.keys that
schema.sqland the Postgres migrations produce. Any schema change in that window must touchboth; keep the window short.
3. Dialect issues
Counts are production code (tests in brackets). "Portable" means the fix also works on SQLite 3.35+, so
it can land before the switch. Python 3.12 images ship SQLite 3.40 or later; the host's system Python 3.10
has 3.37.
?placeholderscharacters/wardrobe.py:1239,realm_wipe.py:136,lore_items.py:267, 1720,roster.py:567,wills.py:264,skins/drinks.py:1268,ledger/store.py:284, 320:nameskins/codes.py:197-217(+_COUNTS_TOWARD_COOLDOWN175-183)%(name)swhen params is a mapping. Or make positional (portable).%in SQLauth/users.py:35LIKE 'staff_view:%',luckperms/mirror.py:367LIKE 'group.%'%when params are passed. psycopg parses%even inside string literals.cursor.lastrowidwars/declare_codes.py:185,skins/drinks.py:244,plugin_notices.py:47,notifications.py:46,moderation.py:58, 109,codes.py:730, 955,chronicle/audit.py:106,luckperms/changes.py:370INSERT … RETURNING id(portable). psycopg has nolastrowid.last_insert_rowid()rowiddbstudio/studio.py:398-407(max(rowid)row counts);auth/players.py:447, 455is CoreProtectINSERT OR IGNOREdiagnostic/reports.py:45,patreon/client.py:148,chronicle/restore.py:158,ledger/store.py:444(temp table),schema.sql:477ON CONFLICT DO NOTHING(portable).INSERT OR REPLACEluckperms/mirror.py:88, 98, 134,chronicle/wipe.py:71ON CONFLICT (pk) DO UPDATE SET …(portable).ON CONFLICT … DO UPDATEledger/store.py:379-395uses barefirst_seen_day/last_seen_dayon the right-hand sideMIN(a, b)/MAX(a, b)ledger/store.py:386, 390CASE WHEN … END(portable; SQLite has noLEAST).COLLATE NOCASEcharacters/lore_items.py:775(ORDER BY),patreon/service.py:547(minecraft_name = ? COLLATE NOCASE)lower(x)on both sides (portable).json_extractapi/database_routes.py:60detail_jsonin Python (portable), ordetail_json::jsonb ->> 'database'.dict(cursor)of pairsapi/database_routes.py:59strftime/datetime('now')/julianday/unixepochin SQLWITHOUT ROWIDstudio.py:407sqlite3.Rowbehaviourdb.py:17; hintssubmissions.py:138-1391(8),wills.py:72, 84, 93,estates.py:263;row.keys()insubmissions.py:139-272, 1275; positionalfetchone()[0]/r[0]inpatreon/service.py:184, 311, 431, 481, 511, 533,patreon/linking.py:305,luckperms/views.py:61,luckperms/mirror.py:349,auth/admin_cli.py:45,submissions.py:419, 877;dict(row)about 46 siteskeys()and name lookup. That covers all of these unchanged. Replace hints withdatabase.Row.executescriptdb.py:33executemanyroster.py:573, 593,ledger/store.py:308, 341, 378, 443,luckperms/mirror.py:90, 99, 134isolation_levelstudio.py:241BEGIN IMMEDIATEpatreon/service.py(12),patreon/linking.py(5),auth/view_as.py(4),luckperms/changes.py(3),auth/users.py,auth/microsoft_link.py,characters/wills.py,skins/codes.py,skins/discord_link.py(2 each),auth/admin.py:56,auth/admin_cli.py:28,auth/preview_sign_in.py:97,luckperms/mirror.py:125,patreon/client.py:164,api/admin_routes.py:267,skins/db.py:101,studio.py:246BEGINplus the writer advisory lock (section 5).PRAGMAdb.py:18, 103-134; runtime column checkslore_items.py:1416, 1705, 1801,submissions.py:419, 877;studio.py:245, 248foreign_keysis implicit on Postgres.sqlite_master/sqlite_sequencestudio.py:218, 367, 451studio.py:375-386, 446-450).ATTACHstudio.py:51)ledger/store.py:438-453(temp.prefix,INSERT OR IGNORE)faction_key <> ALL(%s)with a list parameter; no 999-variable limit. Branch on engine, or drop the temp table once SQLite support ends.sqlite3exceptionssubmissions.py:967(IntegrityErrorfor slug conflicts),auth/discord_names.py:170, 202(sqlite3.Error),chronicle/audit.py:157(OperationalError), studiodatabase.IntegrityError/DatabaseError(tuples of both drivers' classes) and catch those.submissions.py:967re-raises, so fine. Any code that catches a database error and keeps using the same connection breaks on Postgres ("current transaction is aborted").exceptaroundexecute; the Postgres CI run finds the rest.1 if x else 0(auth/users.py:162, 225,preview_sign_in.py:83); anyTruethat reaches an integer column fails on Postgrestext_col = 123loosely; Postgres raisesoperator does not existbiginteverywhere.LIKEcaseLIKEignores ASCII case, Postgres does not; all province uses lower the input first (wills.py:318,mirror.py:342) or match lowercase keys (users.py:35,mirror.py:367)ORDER BYsubmissions.py:1304(reviewed_at),drinks.py:1257;creates.py:641already usesCOALESCENULLS FIRSTwhere it matters.GROUP BYbare columnsGROUP BYqueries (mirror.py:367,database_routes.py:62) are validwith conn:semanticswith connect()+ 13with conn:withexit__exit__commits or rolls back, then returns the connection to the pool. An AST scan found no production code usingconnafter itswith connect()block (the hits were re-bindings).total_changes/rowcountstudio.py:253;rowcountworks in psycopg4. Everything outside request code that assumes a SQLite file
4.1
scripts/site-deploy.sh64-78back up with SQLite's backup API inside the container todeploy-backups/province.db.before-deploy-<sha>-<time>and keep 10.docker compose exec -T db pg_dump -Fc -d province_<site>todeploy-backups/province.before-deploy-<sha>-<time>.dump, keeping 10, pluspg_restore --listas a cheap integrity check.pg_dumpin the database container: the backend image (python:3.12-slim) has no client tools.DATABASE_URL./usr/local/sbin/site-deploy, so it must be reinstalled after merging..github/workflows/deploy.yml:75) still holds.4.2
scripts/ps-preview.sh88-107rsync Dev's data without*.db, copyprovince.dbwithsqlite3 .backup, then delete fromsessions,user_sessions,discord_oauth_statesanddiscord_link_codes, andVACUUM.createdb pv_<slug>(hyphens to underscores) with its own role and password, saved in$dir/keysnext toSTAFF_KEY.pg_dump -Fc province_dev | pg_restore --no-owner --role=pv_<slug> -d pv_<slug>. About 7 MB, so seconds.DELETEs throughpsql.DATABASE_URLand a small pool size inwrite_compose(115-167).do_down/cmd_reap(169-238) addDROP DATABASE … WITH (FORCE)andDROP ROLE.CREATE DATABASE … TEMPLATE province_devwill not work, because Dev's backend is always connected to it.ps-previewsnetwork. Each database should revokeCONNECTfromPUBLIC, so a preview role reaches only its own database.4.3
backend/src/scripts/**and other toolsstore.py,wipe.py,restore.py,audit.py,verify.py) and ledger (store.py,wipe.py,reindex.py,remap_wealth_keys.py,ingest.py): SQL fixes are in section 3. They also take file locks (scripts/util/maplock.py), which are unaffected.chronicle/audit.py:157's missing-table fallback goes.scripts/maps/archive_copy.py: plain deletes; nothing beyond placeholders.scripts/tools/generate_spoof_ledger.py:723: treats "noDB_PATHfile" as "no data". On Postgres this becomes "the ledger table is empty" or "the database is unreachable".auth/admin_cli.py:68-85(--db <file>): change to--database-url.scripts/retag-realm.pyandscripts/staging-import-prod-data.sh: the import rsyncsbackend/src/data(which then holds no database) and retags in place. Change it topg_dump/pg_restorethen retag, or retire it if previews have replaced it.backend/scripts/*_smoke.py: they callmigrate()andconnect(), so they follow the layer. Fixrpc_player_meta_smoke.py:85while there.backend/benchmarks/coreprotect_reader.py: CoreProtect only; unaffected.4.4 Database studio (
backend/src/dbstudio/studio.py)sitesource is hard-wired to SQLite:sources()(86-88) and_site()(234-266).206-230) refusesATTACH, transactions, triggers and non-schema pragmas. It also refuses writes tosqlite_sequenceand any change toadmin_audit.schema()(397-408) estimates rows withmax(rowid);_measure()(451-455) sums file and-walsizes._postgres,POSTGRES_SCHEMA, the_checkdollar-quote handling) is already there, keyed onSUPABASE_DB_URL(304).sitesource runs_postgreswithDATABASE_URLunder a dedicatedprovince_studiorole. The role should:DROP/ALTER/TRUNCATEor disable triggersSELECTonly onadmin_auditUSAGEbut notUPDATEon sequences, so nosetval, which matches thesqlite_sequencerule_check()stays as the second layer.frontend/lib/admin/database.ts:6,DatabaseList.tsx:22).api/database_routes.py:59-62usesjson_extract(section 3).4.5 Tests
conftest.pyfixtures do the same thing:auth,dbstudio,diagnostic,luckperms,patreon,rail,servers,scripts/ledger. Each patchesdb.DB_PATH,DATA_DIRand the three directories, then callsmigrate().db_mod.DB_PATH = root / "province.db"insetUpand restore it intearDown. Examples:skins/test_drinks.py:33-55,characters/test_realm_wipe.py:39-66,api/test_skins_routes.py:30-82.backend/conftest.py:16-17aliasessys.modules["skins.db"], andapi/data_routes.py:27-33documents how that aliasing once pointed the ledger at the wrong database. Keep one module object as the seam.tests/test_database_schema.py(iterdump,PRAGMA foreign_keys)scripts/chronicle/test_schema_indexes.py:52, 95(sqlite_master)auth/test_admin.py:99-123,auth/test_view_as.py:316-331,auth/test_discord_nicknames.py:32andcharacters/test_create_hold.py:97-111(rawsqlite3.connect+executescript+PRAGMA)scripts/chronicle/test_capture.py:38andtest_verify.py:36(executescript(schema.sql))patreon/test_linking.py:184, 242, which assert a token is absent from the file bytesdbstudio/test_studio.py:16-310(authorizer,PRAGMA writable_schema,sqlite_master).patchnotes/test_db.py,precedent/test_db.py) mock psycopg2 completely. No test runs a real Postgres.api/test_ledger_routes.py:721andapi/test_chronicle_routes.py:760countconnect()calls. They stay valid with a pool.4.6 CI (
.github/workflows/build.yml)python -m pytest -q(line 47), no database service.postgresservice (pgvector/pgvector:pg17, health checkpg_isready, tmpfs data,-c fsync=off -c synchronous_commit=off -c full_page_writes=off) andTEST_DATABASE_URL.db: [sqlite, postgres]matrix while both engines are supported.preview.ymlbuilds images only and is unaffected.4.7 Compose and host
docker-compose.yml(www) has no database service.docker-compose.staging.yml(Dev) already runspatchnotes-db: pgvector/pgvector:pg16for patch notes (lines 17-38).backend/src/data, which keeps the skin, wardrobe and drink files after the move.docker-compose.override.yml/.env. Back these up before editing and never print them.provincesystem-data/data/province.db(nightly copies,maintenance-backups/, whichps-preview.sh:90excludes) needs apg_dumpequivalent. Check the host's timers before cutover.4.8 Documentation
Docs/projects/ProvinceSystem/docs/architecture.md:12, 53, 66, 149-167overview.md:43, 67docs/README.md:53DEPLOY.md:31, 67, 74, 79, 89(backups and preview data)docs/ops/database-studio.md:16-66docs/map/ledger.md:144, 175, 185, 206docs/cosmetics/skins.md:108docs/identity/tfmcweb.md:78docs/identity/auth-security.md:198docs/integrations/patreon-backend.md:9docs/integrations/precedent.md:188AGENTS.md:90("backs upprovince.db")backend/src/luckperms/README.md.5. Recommended approach
5.1 One database package, psycopg 3, a compatibility layer first
Add
backend/src/database/(__init__.py,postgres.py,sqlite.py,translate.py,migrations/,testing.py).src/skins/db.pykeepsDATA_DIR,SKINS_DIR,WARDROBE_DIR,DRINKS_DIRandre-exports
connectandmigrate, so none of the importing modules change in the first PRs.The engine is chosen by
DATABASE_URL. If it is unset, the layer uses SQLite atDB_PATH(today'sbehaviour);
postgresql://…selects Postgres. The Postgres connection wrapper should provide:psycopg_pool.ConnectionPool(new pinned dependencypsycopg-pool). Sizes come from theenvironment: about 15 on www, 5 on previews. The checkout timeout is 10 s.
connect()returns a wrapperaround a pooled connection;
close()and__exit__return it to the pool.BEGINitself before the first
INSERT/UPDATE/DELETE. That copies Python sqlite3's default mode, whereSELECTs outside a transaction see the latest committed data.
commit()androllback()act only whena transaction is open, and
in_transactionis exposed.BEGIN IMMEDIATE, and the first write of any implicit transaction, also runSELECT pg_advisory_xact_lock(<writer key>). Writers are then serialised as they are on SQLite today(rollback journal: one RESERVED lock), and the 42 read-check-write paths stay safe without rewriting
them. Set
lock_timeout = '5s'(sqlite3's default busy timeout),statement_timeout = '30s'andidle_in_transaction_session_timeout = '60s'. Taking the lock before any row lock means it cannotdeadlock. The remaining gap is a transaction that reads before its first write without
BEGIN IMMEDIATE: on Postgres it can act on a value another writer has just changed. List theseduring the per-area PRs and move them to
BEGIN IMMEDIATEorSELECT … FOR UPDATE.?into%soutside quotes, comments and dollar-quotes.:nameinto%(name)sonly when params is a mapping, and never touch::casts.%when params are present.Row(tuple)withkeys()and string indexing, matchingsqlite3.Row. Positional access, unpacking,dict(row)anddict(cursor)then all keep working.TimeZone=UTC.database.IntegrityError,OperationalErrorandDatabaseErrorare tuples of bothdrivers' classes, for
exceptclauses.Why not rewrite the queries per module first? About 470 literals and 1,225 placeholders across
about 50 modules would mean 30 to 40 large, risky PRs with no way to run both engines meanwhile. The layer lets
the existing suite run unchanged against both engines. Once SQLite is gone, an optional mechanical codemod
can switch the SQL to
%sand delete the translator, leaving pool, rows and lock. The?style could alsostay for good; the translator is about 80 lines.
One driver: after cutover, move
precedent/db.py,patchnotes/db.py,api/precedent_routes.py,api/patchnotes_routes.pyand the three precedent scripts from psycopg2 to psycopg 3:pgvector.psycopg.register_vectorpsycopg.types.json.JsonbforJsondict_rowforRealDictCursorwith conn:.Then drop
psycopg2-binary, and give the root-owned patch note job copy (tfmc-patchnotes-update)psycopg[binary]. Precedent and patch notes keep their ownSUPABASE_DB_URL.5.2 Topology
Use two Postgres 17 servers (
pgvector/pgvector:pg17):province_www)province_devand allpv_*preview databases.That keeps preview churn and Dev load away from www, and fits
max_connections(Dev 15 + 4 previews × 5).patchnotes-dbto 17 needs a dump and restore of its patch note data, or a separate pg17 service.127.0.0.1or keep them internal to the compose network.province_studiorole.5.3 Fast tests on Postgres
Transaction rollback per test will not work. The code under test commits, and it opens several
connections (and, with the pool, several sessions) per test.
ps_test_<worker>once per session, run the migrations once, and point thepool at it.
SET session_replication_role = replica; TRUNCATE <all province tables> RESTART IDENTITY CASCADE;then re-seed
patreon_sync_state(1).replicaskips theadmin_auditTRUNCATE trigger. The CI rolemust be a superuser, which is fine for a throwaway service. Expect 10 to 30 ms per test, roughly
10 to 25 s over the ~860 database tests.
database.testing.fresh_database(tmp_path)serves both engines. On SQLite itpatches
DB_PATHas now. Use it from all 8 conftests and the ~30 unittestsetUpmethods.iterdumpand authorizer tests@pytest.mark.sqlite_only. Rewrite the file-bytes secret checks as queries. Add Postgres equivalentsfor the studio role and the
admin_audittriggers.docker run --rm -d -p 55432:5432 -e POSTGRES_PASSWORD=test --tmpfs /var/lib/postgresql/data pgvector/pgvector:pg17 -c fsync=offwith
TEST_DATABASE_URL=postgresql://postgres:test@127.0.0.1:55432/postgres.5.4 Data copy and verification
Use one tool for both directions:
backend/src/scripts/tools/copy_province_db.py.mode=ro,text_factory=bytes. RunPRAGMA integrity_checkandPRAGMA foreign_key_check; abort on any problem.them unless
--archive-legacy DIRwrites them out as JSON.codesbeforesubmissions/sessions/drink_submissions;usersbeforeuser_sessions/preview_sign_in_tickets), using an explicit column list.typeof()must be integer forbigintcolumns; integer or real fordouble precision; text fortext.cursor.copy("COPY t (cols) FROM STDIN").max(max(id), sqlite_sequence.seq), so AUTOINCREMENT'snever-reuse rule holds (it matters for
admin_auditandcodes).normalised (NULL marker, int as decimal, float as
repr, text as UTF-8). Compute it the same way onboth sides in Python and print a table. Exit non-zero on any difference. Run
ANALYZEat the end.table), copy to Postgres and back, and require identical checksums.
5.5 Cutover and rollback per site
Do Dev first, soak it for at least a few days with previews running from it, then www.
Before cutover
DATABASE_URLready but not yet active.Cutover
ps-compose <target> stop backend. The plugins' pushes (roster, LuckPerms bridge, TFMCWeb) fail during the window, so check that each retries. Patreon and Discord loops stop with the backend.sqlite3 .backupofprovince.dbtoprovince.db.pre-postgres-<time>.copy_province_db.py to-postgresin a one-off container from the deployed image (ps-compose <target> run --rm backend …). It verifies as it goes.DATABASE_URLin the override, backing the file up first. Start the backend.site-deploy's health URLspsycopgerrors.province.dbread-only for 30 days; nothing reads it any more.The downtime is a few minutes; the copy itself takes seconds at 7 MB.
Rollback
DATABASE_URLand restart. The site is back on the SQLite file as of step 2.copy_province_db.py to-sqliteinto a fresh file thatmigrate()has created, check the verification, swap the file in, unsetDATABASE_URLand start.6. Staged PRs
Each PR leaves both sites working on SQLite until the cutover steps. Docs PRs go to the Docs repo and are
linked from the code PR.
RETURNING idfor the 10lastrowidsites;ON CONFLICTforINSERT OR IGNORE/REPLACE(includingschema.sql:477); qualifyledger/store.py:379-395and replace scalarMIN/MAX;lower()forCOLLATE NOCASE; Python-sidejson_extract; drop runtimePRAGMA table_infochecks and theaudit.py:157fallback; positional params incodes.py:197-217;NULLS FIRSTwhere needed.RETURNINGneeds SQLite 3.35+.sqlite3.sqlite_versionin the image and CI.src/database/withconnect,migrate,Row, error tuples;skins/db.pyre-exports;sqlite3.Rowhints andsqlite3exception catches switched todatabase.*.backend/src/database/*,skins/db.py,submissions.py,wills.py,estates.py,discord_names.pypsycopg-poolpinned. Off unlessDATABASE_URLis set.database/postgres.py,translate.py,requirements.txt0001_baseline.sql, triggers, schema parity test.database/migrations/*,database/__init__.pyschema.sql;admin_auditrefuses UPDATE, DELETE and TRUNCATE.database/testing.py; convert the 8 conftests and ~30 unittest classes; mark SQLite-only tests;build.ymlgets thepostgresservice anddbmatrix, with the Postgres legcontinue-on-error.backend/conftest.py,src/*/conftest.py, ~50 test files,.github/workflows/build.ymlauth/*,skins/discord_link.py,api/admin_routes.py,api/database_routes.pyauth/,api/test_*auth*.skins/*,wars/declare_codes.pyscripts/skins_e2e_smoke.pyagainst a Postgres stack.characters/*character_ingest_smoke.py,lore_items_smoke.py.patreon/*,luckperms/*,diagnostic/*scripts/chronicle/*,scripts/ledger/*,scripts/maps/archive_copy.py,tools/generate_spoof_ledger.py,api/ledger_routes.py/ledger/seriesand a day promotion with a 2,000-faction payload. Then make the Postgres leg required.sitesource followsDATABASE_URL; studio role; tests for both engines.dbstudio/studio.py,api/database_routes.py, tests; Docsdatabase-studio.mdadmin_audit,setval,DROP,TRUNCATEandALTERall refused under the studio role.scripts/tools/copy_province_db.py+ test--verify-onlyagainst a copy of Dev's file made on the host.dbservices in both compose files (pg17, builtin C.UTF-8 locale, healthcheck, volume);site-deploy.shpg_dumpbranch;ps-preview.shper-preview database and role, scrub, drop on reap;admin_cli --database-url; staging import tool updated or retired.docker-compose*.yml,scripts/site-deploy.sh,scripts/ps-preview.sh,scripts/staging-import-prod-data.sh,scripts/retag-realm.py,auth/admin_cli.py; DocsDEPLOY.md/usr/local/sbincopies; preview up/down twice; deploy once with a backup.province_dev.site-deployproduces a.dump.schema.sql,_upgrade, the SQLite backend, SQLite-only tests and the matrix. Optional codemod?→%s, then delete the translator. CoreProtect stays on SQLite.skins/db.py,database/*, tests,build.yml; Docs architecture, overview, ledger, skins, tfmcweb, auth-security, patreon-backend, workspaceAGENTS.mdprecedent/db.py,patchnotes/db.py,api/precedent_routes.py,api/patchnotes_routes.py,scripts/*precedent*.py,requirements.txtwith conncloses in psycopg 3).Follow-ups, outside this plan
About 20 API modules have
async defhandlers that call synchronous database code on the event loop:characters_routes.py:218-287skins_routes.py:573luckperms_routes.py,patreon_routes.py,admin_routes.py,diagnostic_routes.py,drinks_routes.py,data_routes.py,chronicle_routes.py.Network round trips make those stalls longer than on SQLite. Move the database parts into
run_in_threadpool, aschronicle_staff_routes.pyandview_as_guard.pyalready do.Typed columns:
timestamptzfor the*_attext columns once the formats are normalised,booleanfor 0/1 flags,jsonbfor*_json/payload/manifest.Replace the global writer lock with row locks area by area, if write throughput ever matters.
ryanbarlow97 commentedon Oct 16, 2026
Change of approach (2026-10-16, decided by Ryan): no compatibility layer. The site moves to native Postgres (psycopg 3,
%s, real types, row-level locking for each read-check-write path) on the integration branchpostgres. Area PRs targetpostgres. When everything is converted,postgresmerges tomain, Dev cuts over with a copy tool, then www on request.Freeze: while
postgresis open, please avoid changing province.db queries or schema onmain. Anything that must land there has to be ported intopostgrestoo. The steps table above is superseded; the foundation PR posts the revised native plan here.🤖 Generated with Claude Code
ryanbarlow97 commentedon Oct 16, 2026
Revised plan: native Postgres, no compatibility layer
The site moves to native Postgres in one go. This replaces sections 5 and 6 of the plan above: there is no
?translator, no sqlite3-style row class and no dual-engine period. Work happens on the integration branchpostgres; area PRs target it, and it is never deployed until everything is converted. When it is complete,postgresmerges tomain, which deploys Dev; www follows through Actions → Deploy. Mixed states inside the branch are fine, butpostgresmust be green before it merges tomain.1. Foundation (#252)
backend/src/database/gives a pooled psycopg 3connect()(one transaction per block, dict rows,search_path=public, UTC, lock and statement timeouts, and every connection reset before reuse),transaction(isolation=, read_only=, lock=),advisory_lock()(waits up to 20 s) andserializable()with retry. A migration runner applies numbered SQL files under an advisory lock at start-up and frompython -m src.database.migrate. It refuses histories that disagree with the files: a changed, missing or out-of-order file. Seed rows (patreon_sync_state) are idempotent inserts inseeds.sql, applied at start-up after the migrations, so a freshly migrated database has every table empty.0001_baseline.sqlcreates all 61 tables with real types:timestamptz,booleanandjsonb, identity ids that accept explicit values, andCOLLATE "C"on every text column so ordering matches SQLite whatever the database's default.admin_auditrefuses UPDATE, DELETE and TRUNCATE by trigger. Pytest fixtures run against a Postgres fromTEST_DATABASE_URL, and CI has apgvector/pgvector:pg17service. The PR body lists every column whose Python value changes, the code sites the type change breaks, and a recommended Postgres pattern for each of the 42BEGIN IMMEDIATEpaths.Province tables share each site's existing
provincesystemdatabase with Precedent and patch notes, through the sameDATABASE_URL.2. Area PRs, into
postgresEach PR moves one area to
src.database:%splaceholders,RETURNINGfor ids,ON CONFLICTforINSERT OR …, the new Python types, aliases for duplicate column names in joins (dict_rowkeeps the last,sqlite3.Rowkept the first), the locking pattern from the foundation PR for its read-check-write paths, and its tests on thepostgresfixture. SQLite-only tests (legacy upgrades,iterdump,PRAGMA) are deleted with the code they test.auth/*includingaudit.recordandsession_user_in,api/admin_routes.pyandapi/database_routes.py's audit read. This goes first, because most areas callaudit.record(conn, …)orsession_user_in(conn, …)with their own connection.session_user_in(conn, token, lock=True)takesFOR SHARE OF s, ufor staff re-checks only.drop_view_leftoversopens withtransaction(lock="auth:placeholders").skins/discord_link.py,auth/microsoft_link.py.skins/*,wars/declare_codes.py.FOR UPDATEand re-validate it in the same transaction.start_site_sessionand TFMCWeb'sissue_code) takecosmetic_mint:<uuid>.characters/*(roster, estates, wills, wardrobe, lore items, creates).luckperms/*,diagnostic/*. Time a 2,000-user snapshot apply.lp_statefirst, then users and sessions, thenlp_changes.queue_changetakeslp_state FOR SHAREfor one consistent mirror.record_resultslockslp_statebefore settling changes._current_actorreads the actor rowFOR SHARE.patreon/*, with thepatreonadvisory lock around_compute/_plan.scripts/chronicle/*,scripts/ledger/*,scripts/maps/archive_copy.py,scripts/tools/generate_spoof_ledger.py,api/ledger_routes.py. The temp table becomes<> ALL(%s). Time a day promotion with 2,000 factions.sitesource runs on Postgres under aprovince_studiorole that owns nothing, can onlySELECTfromadmin_auditand has noUPDATEon sequences. The SQLite authorizer goes.backend/scripts/*_smoke.py,auth/admin_cli.py --database-url,scripts/retag-realm.py, and the staging import, which is retired if previews have replaced it.schema.sql,skins/db.connect()/migrate()/_upgrade(), and theDB_PATHtest patching.DATA_DIRand the file directories stay; CoreProtect stays on SQLite.psycopg2-binary.Where a callee takes the caller's connection (
audit.record,session_user_in,characters/estates,scripts/ledger/store.open_connection), callers in areas not yet converted will fail until their PR lands. That is expected on the branch.3. Copy tool
backend/src/scripts/tools/copy_province_db.py:PRAGMA integrity_checkandforeign_key_check.patreon_sync_staterow is copied in; the backend's start-up seed is then a no-op.armourshop_player_meta,drink_player_meta,player_keys, and anything else outside the schema) are listed and skipped, or written out as JSON with--archive-legacy.…Z, milliseconds…Z,isoformat()+00:00with or without microseconds) and nothing elseNaN,Infinityand-Infinitythat Python'sjson.loadsaccepts and Postgres rejectsbigintcaptured_at,first_seen_at,last_seen_atanddeleted_atcarry Java's nanoseconds) round to microseconds intimestamptz. The tool reports how many rows rounded, and verification compares times at microsecond precision.patreon_alertsper kind.pendingwill_requestsrow per(realm_id, character_id).pending/sentlp_changesrow per(target_type, target).microsoft_link_statesrow per user (they expire in minutes).discord_links.player_uuidvalues that are not lower case.COPY, thensetvaleach identity tomax(max(id), sqlite_sequence.seq), so AUTOINCREMENT's never-reuse rule holds.sort_keyscompact, booleans as 0/1), the result is printed as a table, and any difference exits non-zero. RunANALYZEat the end.schema.sqlwith every format seen live, then round-trip it to Postgres and back.4. Host plumbing
Databases: each site already has its
dbservice (pg17,provincesystemdatabase). The province tables go in it, andsite-deploy's existingpg_dump(feat(db): run Precedent and patch notes on each site's own Postgres #250) already backs them up.site-deploy: a one-time copy hook. If the checked-out code has
src/database/migrations, the site's Postgres has no province rows andprovince.dbexists, then before starting the new backend:copy_province_db.py to-postgresin a one-off container from the new imageA copy or verification failure aborts the deploy with the old code still running. After cutover the SQLite backup step is dropped. The root-owned
/usr/local/sbin/site-deploymust be reinstalled.Previews get their own database on staging's
db:CREATE ROLE pv_<slug>andCREATE DATABASE pv_<slug> OWNER pv_<slug>REVOKE CONNECT … FROM PUBLICpg_dump -Fcof Dev'sprovincesystem, excluding theprecedent_*andpatchnote_*tables, piped topg_restore --no-owner --role=pv_<slug>sessions,user_sessions,discord_oauth_states,discord_link_codes; considerpatreon_tokenstoo)DATABASE_URLandDATABASE_POOL_MAX_SIZE=3in the generated compose file.do_downandreapaddDROP DATABASE … WITH (FORCE)andDROP ROLE. Staging'sdbjoins theps-previewsnetwork.CREATE DATABASE … TEMPLATEwill not work while Dev is connected. Previews have noDATABASE_URLtoday, so they skip the Postgres migration until this lands.Pool size: www 10 (default), Dev 10, previews 3; under Postgres's default
max_connectionsof 100.Backups outside the repo: any host job that copies
province.dbneeds thepg_dumpequivalent. Check the host's timers before cutover.5. Cutover
Before:
postgresgreen, including the copy tool's round trip. Runcopy_province_db.py to-postgres --verify-onlyagainst fresh host copies of Dev's and www'sprovince.db, and fix any rows it reports.Dev: merge
postgresintomain. Build passes and Deploy runssite-deploy dev, whose copy hook moves the data. Then smoke test:psycopgerrorsSoak for a few days.
www: on request, Actions → Deploy with site
www, and the same smoke test.Rollback:
province.dbis untouched from the moment of the copy.copy_province_db.py to-sqliteinto a fresh file, swap it in, then deploy the old commit.Keep
province.dbread-only on the host for 30 days.Docs (Docs repo, with the cutover PR):
DEPLOY.md(backups, previews), database studio, ledger, skins, tfmcweb, auth-security, Patreon backendAGENTS.md("backs upprovince.db").feat(db): native Postgres foundation (database package, schema, tests…
feat(db): accounts on native Postgres — #251 (#253)
feat(db): chronicle and ledger on native Postgres — #251
feat(db): chronicle and ledger on native Postgres — #251 (#254)
feat(db): Patreon, LuckPerms and diagnostics on native Postgres — #251 (
feat(db): linking, skins, drinks and codes on native Postgres — #251 (#…
feat(db): characters on native Postgres — #251 (#255)
feat(db): database studio on a restricted Postgres login — #251 (#256)
Move ProvinceSystem runtime to native PostgreSQL (#263)