Resolves the modify/delete conflict with dev (which removed
phase-a-foundation.md in a1e8970). The Implementation Status and
Actionable TODOs sections are preserved under docs/ alongside the
other project docs, matching the existing docs/*.md convention.
The original phase-a-foundation.md design brief is gone per dev's
intent; only the post-implementation status and follow-up checklist
survive.
13 KiB
Phase A Implementation Status
Branch: claude/phase-a-foundation-plan-eUys1
This document tracks what shipped during Phase A (Foundation) and what is still pending. The original Phase A design brief (phase-a-foundation.md) was removed from the repo root on dev; this status doc preserves the actionable follow-up work.
Done
- Step 1 — Service layer + permission cache.
Server/service/contains 12 service files covering message, channel, permission, moderation, user, dm, block, invite, voice. Services depend onstore.Store, not*db.DB. PermissionService maintains a per-user cache. REST and WS handlers (auth, channel, dm, profile, upload, chat, reaction, presence, voice) are migrated. Service tests live inservice/message_test.goandservice/permission_test.go. - Step 2 — sqlc adoption.
Server/sqlc.yamlconfigures both the SQLite engine (queries inServer/db/queries/sqlite/, generated output inServer/db/dbgen/) and the PostgreSQL engine (queries inServer/db/queries/postgres/, output inServer/db/pgdbgen/). sqlc v1.30.0 is pinned inServer/sqlc.version.Server/Makefileexposessqlc-install,sqlc-generate,sqlc-verifytargets covering both engines. 14 SQL query files per engine cover all DB domains; the SQLitedbgenpackage is committed, the PostgreSQLpgdbgenpackage will be generated on the nextmake sqlc-generaterun. FTS search queries remain hand-written; transactional multi-step operations are unchanged. - Step 3 (partial) — Store interface + SQLiteStore + MemStore.
Server/store/store.godefines the full Store interface (12 sub-interfaces).Server/store/sqlite.gowraps*db.DB.Server/store/memstore.goprovides an in-memory implementation used by service tests. - Step 4 — Logging consolidation. OwnCord code uses
log/slogexclusively.go.uber.org/zapandrs/zerologappear only as transitive dependencies of livekit and are not imported by any OwnCord.gofile. The plan's "pick one and find-and-replace" item is satisfied. - Step 5 — Pub/sub broadcast model.
Server/ws/pubsub.go,topic_rate_limiter.go,ringbuffer.go, and the three-tier priority queue (commit7ccc93f) implement the topic-based broadcast model with global rate limits, backpressure, and priority tiers.
Pending
Step 3 — PostgreSQL backend (final wiring)
Scaffolding has landed on this branch:
Server/migrations/postgres/001_initial_schema.sql— consolidated postgres schema withtsvector+ GIN full-text search,CITEXTusernames, native CHECK constraints replacing SQLite triggers, and seeded role/setting rows.Server/migrations/postgres/migrations.go— embed FS for the postgres migration set.Server/db/queries/postgres/*.sql— 14 query files (users, sessions, roles, invites, channels, messages, reactions, voice, attachments, admin, dm, blocks, lockouts, profile) translated to postgres dialect:$Nplaceholders,NOW()/nativeTIMESTAMPTZ,TRUE/FALSEfor BOOLEAN columns,ON CONFLICT ... DO UPDATEfor upserts,RETURNING idfor creates (postgres has noLastInsertId),:execrowsfor mutations that need rows-affected.Server/sqlc.yaml— second engine entry (engine: "postgresql",sql_package: "pgx/v5") generating into thepgdbgenpackage underServer/db/pgdbgen/.Server/Makefile—sqlc-generateandsqlc-verifycover both engines.Server/store/postgres.go—PostgresStoretype behind the//go:build postgrestag, implementing the fullstore.Storeinterface. Connection lifecycle (OpenPostgres,Close,SQLDb,WithTx) is fully implemented usingdatabase/sql+ the pgx stdlib driver. Query methods are stubs that returnErrPostgresNotImplemented, waiting for thepgdbgenquerier to land so they can be replaced with wrappers around generated code. The build tag keeps pgx out of the default build; operators who want to enable postgres rungo get github.com/jackc/pgx/v5 && go build -tags postgres ./....Server/config/config.go—DatabaseConfigextended withType,Host,Port,User,Password,Name,SSLMode,MaxConns. Defaults set totype: "sqlite"so existing operators are unaffected.Server/main.go— explicit dispatch ondatabase.type. Selectingpostgresproduces a clear startup error pointing at the remaining work, instead of silently falling through to sqlite.
What still needs to land for postgres to be runnable:
- Add
github.com/jackc/pgx/v5togo.modand rungo mod tidy. This happens naturally the first time an operator runsgo get github.com/jackc/pgx/v5— the package only needs to be in the module graph when building with-tags postgres. - Run
make sqlc-generateto produceServer/db/pgdbgen/from the committed query files. This requires either network access to download sqlc or a pre-installedsqlcbinary at v1.30.0. - Replace the stub query methods in
Server/store/postgres.gowith real implementations that wrap the generatedpgdbgenquerier. The connection lifecycle and interface assertion are already in place; each stub carries the same method signature as the sqlite version, so the migration is mechanical. Where the schema produces different Go types than sqlite (e.g.boolvsint64for boolean columns,time.Timevsstringfor timestamps), the store wrapper performs the conversion so services see a uniform API. - Refactor
main.goandServer/api/router.goto threadstore.Storethrough the boundary instead of*db.DB. Today the router constructsdbstore.NewSQLiteStore(database)inline, so services are store-aware but everything else still consumes*db.DBdirectly. The store-everywhere migration is the gating step before either backend can be swapped at runtime. - FTS dispatch in
MessageStore.SearchMessages/SearchMessagesInChannels— sqlite usesMATCHagainst the FTS5 virtual table, postgres uses@@ to_tsquery(...)against themessages.ftstsvector column. Both remain hand-written (outside the sqlc-generated set) for their respective backends. - CI matrix to run the test suite against both backends.
One-way sqlite → postgres data migration
Operators who start a community on the default sqlite backend and later outgrow it must be able to carry their history over. The migration must be forward-only (once postgres is selected, the server stays on postgres unless the operator deliberately wipes the postgres database and re-initialises), both to keep the contract simple and to avoid the support burden of "I reverted to sqlite yesterday and now my data is gone".
Proposed design:
- A one-shot CLI flag, e.g.
chatserver --migrate-to-postgres, that exits after completion. No continuous sync. - Pre-flight checks:
database.typein config is alreadypostgres; postgres connection succeeds; the target postgres database contains no rows inusers(or equivalent sentinel) — if it does, refuse to run, printing the exact row count so the operator can confirm they meant to target this database. - Open sqlite read-only alongside postgres. Begin one postgres transaction for the entire migration so partial failures roll back cleanly.
- Copy rows in foreign-key order (
roles→users→channels→channel_overrides→messages→reactions→attachments→invites→sessions→voice_states→dm_participants→dm_open_state→read_states→audit_log→user_blocks→rate_lockouts→settings→emoji→sounds). Disable thetrg_messages_fts_updatetrigger for the bulk copy and re-populatemessages.ftsin a singleUPDATE messages SET fts = to_tsvector('simple', content)at the end, so FTS doesn't fire per row. - Convert types at the boundary: sqlite RFC3339 strings → postgres
TIMESTAMPTZviatime.Parse, sqliteINTEGERboolean (0/1) → postgresBOOLEAN. Drop the preservedidvalues straight through since both schemas areBIGINT-compatible. - After copying, reset every postgres sequence with
SELECT setval('<table>_id_seq', COALESCE(MAX(id), 1)) FROM <table>so subsequent inserts don't collide with migrated IDs. - Write a marker row into
settings:migrated_from_sqlite = <RFC3339 timestamp>. On subsequent startups withtype: postgres, the marker's presence (or simply the presence of a non-emptyuserstable) is the signal that the migration has already run and must not be repeated. - Uploaded files on disk (
data/uploads/) are not touched — the migration only moves database rows. Attachments reference filenames, not blobs, so the filesystem copy is a separatecp -a data/uploads old-host:/new-pathstep the operator does out-of-band. - No reverse migration. The code does not include a postgres → sqlite path. If an operator wants to return to sqlite, they stop the server, restore a sqlite backup, edit
config.yamlback totype: sqlite, and start fresh. This is deliberately inconvenient.
This feature is gated on PostgresStore's query methods being real (pending item 3). The migration implementation itself lives in a new Server/migrate/sqlite_to_postgres.go file and is invoked from main.go before the normal startup path.
Verification status
go build ./... and go test ./... were not run in the session that produced this branch — the development environment lacked network access to fetch the Go 1.25.0 toolchain required by go.mod. Manual audit of the touched Go files (the go-build-check skill) found no compile errors. The branch should be verified locally by the next operator before merge.
Actionable TODOs
Scannable checklist extracted from the prose above. Work top-to-bottom; most items unblock the ones below them.
Verification (do first, cheap)
- Run
go build ./...inServer/to confirm the branch compiles - Run
go test ./...inServer/and fix any regressions - Run
go build -tags postgres ./...after adding pgx (below) to verifystore/postgres.gocompiles under the postgres tag
Postgres enablement (Step 3 final wiring)
cd Server && go get github.com/jackc/pgx/v5 && go mod tidy— add pgx to the module graphmake sqlc-generate— produceServer/db/pgdbgen/from the committeddb/queries/postgres/*.sql- Commit the generated
Server/db/pgdbgen/output - Replace each stub in
Server/store/postgres.gowith a real implementation wrappingpgdbgen; keep the//go:build postgrestag. Expect per-method type conversion (sqlitestringtimestamps vs postgrestime.Time, sqliteint64bools vs postgresbool) - Implement FTS dispatch:
SearchMessagesandSearchMessagesInChannelscurrently use sqliteMATCH; add a postgres branch using@@ to_tsquery(...)againstmessages.fts - Remove
ErrPostgresNotImplementedonce every method is real
Store-everywhere boundary refactor (unblocks runtime backend selection)
- Audit every
*db.DBparameter inServer/api/andServer/ws/; replace withstore.Storewhere possible, usingstore.Store.SQLDb()at the leaves that truly need*sql.DB(backups, migrations) - Update
Server/api/router.go'sNewRoutersignature: takestore.Storeinstead of*db.DB - Update every
Mount*Routes(r, database, …)call site to pass the store - Update
ws.NewHubandauth.NewPersistentRateLimiterto acceptstore.Store - Update
Server/admin/handler.go(admin.NewHandler) the same way - Update
Server/main.goto construct the store via a factory,store.Open(&cfg.Database), that dispatches oncfg.Database.Type - Delete the explicit postgres error in
main.go's switch — selecting postgres should Just Work once PostgresStore is real - Fix all handler tests that construct handlers from
*db.DB— they now takestore.Store(usestore.MemStorefor unit tests)
Data migration (one-way sqlite → postgres)
- Create
Server/migrate/sqlite_to_postgres.go - Add
--migrate-to-postgresCLI flag tomain.go, parsed before normal startup - Pre-flight: verify
database.type == postgres, verify target postgresuserstable is empty, refuse with exact row count if not - Implement bulk copy in FK order:
roles → users → channels → channel_overrides → messages → reactions → attachments → invites → sessions → voice_states → dm_participants → dm_open_state → read_states → audit_log → user_blocks → rate_lockouts → settings → emoji → sounds - Disable
trg_messages_fts_updateduring bulk message copy; repopulatemessages.ftsin one statement at the end - Convert types at the boundary:
time.Parse(time.RFC3339, …)for timestamps,int != 0for booleans - Reset every
<table>_id_seqwithSELECT setval(…, COALESCE(MAX(id), 1))after copy - Write marker row:
INSERT INTO settings (key, value) VALUES ('migrated_from_sqlite', NOW()::text) - Wrap everything in one postgres transaction so partial failures roll back
- Do NOT implement a reverse migration — documented as deliberately unavailable
CI
- Add a postgres job to
.github/workflows/*.ymlthat spins up a postgres service container and runsgo test -tags postgres ./... - Keep the existing sqlite job unchanged
- Add a
sqlc-verifyjob that runsmake sqlc-verifyto catch stale generated code
Hygiene (optional, do whenever)
- Expand
Server/service/*_test.gocoverage — currently onlymessage_test.goandpermission_test.goexist; add channel, dm, voice, invite, moderation tests usingstore.MemStore - Split the postgres migration file if it grows beyond ~300 lines; for now it's consolidated intentionally