Skip to content

Migrate repos from raw SQL to Kysely query builder#3

Open
dnplkndll wants to merge 10 commits intomainfrom
feat/kysely-orm
Open

Migrate repos from raw SQL to Kysely query builder#3
dnplkndll wants to merge 10 commits intomainfrom
feat/kysely-orm

Conversation

@dnplkndll
Copy link
Owner

@dnplkndll dnplkndll commented Mar 23, 2026

Summary

Replaces the raw SQL pattern (ConfiguredRepo/GlobalConfiguredRepo/TypedDB.query()) with Kysely type-safe query builder across all repository classes. This aligns with the ChurchApps maintainer's decision to adopt Kysely (Api PR #22 comment) and matches the pattern from the merged LessonsApi PR #38.

Commit structure

Commit Scope Files
feat: add Kysely ORM infrastructure db factory, base classes, deps 5
feat: convert 99 repos to Kysely all repos + controller fixes 117
fix: review fixes missing decorators, loadOne/loadAll params 4
test: add 299 integration tests test suite adapted for Kysely 16
fix: restore convertToModel, cleanup field-stripping, unused imports 6
feat: add PostgreSQL DDL (107 tables) PG DDL for all 6 modules 107
fix: dual MySQL/PostgreSQL compatibility boolean, dialect branching, cleanup 69
test: fix tests for dual dialect cross-dialect test fixes 7

Infrastructure (src/db/index.ts)

  • Multi-database Kysely factory: getDb(moduleName) — singleton per module
  • Dual dialect via DB_DIALECT env var: MysqlDialect (default) or PostgresDialect
  • MySQL BIT(1) → boolean typeCast at driver level
  • destroyAllDbs() with error isolation for clean shutdown

Base classes (src/shared/infrastructure/KyselyRepo.ts)

  • KyselyRepo — church-scoped (id + churchId): save, delete, load, loadAll, saveAll, insert
    • Opt-in softDelete: sets removed=true on delete, auto-filters on load
    • loadOne(churchId, id, includeRemoved?) for restore/audit flows
    • loadAll(churchId, orderBy?) with optional defaultOrderBy
  • GlobalKyselyRepo — global (id only): save, delete, load, loadAll, saveAll
  • Uses this.createId() consistently (not UniqueIdHelper.shortId() directly)

PostgreSQL support

  • 107 PG DDL files in tools/dbScripts/pg/ — native BOOLEAN, TIMESTAMP, SERIAL
  • Dialect branching via getDialect() for incompatible SQL:
    • STR_TO_DATE(concat(year(),week()))DATE_TRUNC('week', ...)
    • DATE_FORMATTO_CHAR
    • IFNULLCOALESCE
    • INSERT IGNOREON CONFLICT DO NOTHING
    • INSERT ... FROM dual → bare SELECT (PG)
  • Boolean columns: all removed=0removed=false, =1=true
  • Stored functions: cleanup, deleteForChurch, updateConversationStats

Repos converted (99/101)

| Module | Count | Notable |
|--------|-------|---------|-
| attendance | 8 | AttendanceRepo uses sql template for analytics |
| content | 29 | 8 GlobalKyselyRepo for Bible/Song data |
| doing | 13 | MembershipRepo stays TypedDB (cross-module) |
| giving | 10 | DonationRepo has complex date/KPI queries |
| membership | 26 | 8 GlobalKyselyRepo for User/Church/OAuth |
| messaging | 13 | ConversationRepo/MessageRepo preserve field-stripping |
| reporting | — | ReportRepo stays TypedDB (generic SQL runner) |

Not included (follow-up)

  • Kysely Migrator tooling (initdb.ts still uses raw SQL files)
  • Deletion of old base classes — kept for 2 remaining TypedDB repos
  • PG demo/seed data (MySQL demo.sql files have no PG equivalents yet)
  • LIKE → ILIKE for case-insensitive search on PG

Test plan

  • npx tsc --noEmit — zero errors
  • 299 integration tests passing on MySQL
  • 299 integration tests passing on PostgreSQL
  • Helm deploy to b1-postgres namespace
  • Playwright E2E against PG deployment

🤖 Generated with Claude Code

dnplkndll and others added 3 commits March 23, 2026 14:06
Add core Kysely query builder infrastructure matching LessonsApi PR #38 pattern:

- src/db/index.ts: Multi-database Kysely factory (getDb(moduleName)) with
  dialect switching via DB_DIALECT env var. MySQL uses BIT(1)→boolean typeCast.
- src/shared/infrastructure/KyselyRepo.ts: Base classes for all repos:
  - KyselyRepo: church-scoped CRUD (id+churchId) with opt-in softDelete
  - GlobalKyselyRepo: global CRUD (id only, no churchId)
  - Both provide save, delete, load, loadAll, saveAll, insert
  - convertToModel/convertAllToModel delegation (DRY: repos only override
    convertToModel, base handles array mapping)

Dependencies: kysely@^0.28.14, pg@^8.13.0, @types/pg@^8.11.0

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace ConfiguredRepo/GlobalConfiguredRepo/BaseRepo + TypedDB.query()
with Kysely type-safe query builder across all repository classes.

Repos converted by module:
- attendance: 8 (CampusRepo, ServiceRepo, ServiceTimeRepo, SessionRepo,
  VisitRepo, VisitSessionRepo, GroupServiceTimeRepo, AttendanceRepo)
- content: 29 (including 8 GlobalKyselyRepo for Bible/Song data)
- doing: 13 (MembershipRepo kept on TypedDB — cross-module)
- giving: 10 (DonationRepo, FundRepo, GatewayRepo, etc.)
- membership: 26 (including 8 GlobalKyselyRepo for User/Church/OAuth)
- messaging: 13 (ConnectionRepo, MessageRepo, etc.)
- reporting: ReportRepo kept on TypedDB (generic SQL runner)

Controller/helper fixes:
- Type casts for Kysely return types in 10 controller files
- UserChurchHelper: TypedDB.query → Kysely sql template

Query patterns used:
- Simple CRUD → Kysely query builder (selectFrom, insertInto, etc.)
- Complex JOINs/subqueries → Kysely sql tagged template
- IN clauses → native Kysely array support
- removed column → integer (0/1) matching MySQL TINYINT(1) schema

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Add missing @Injectable() to FileRepo, AuditLogRepo, DeviceRepo
- Add includeRemoved param to loadOne() for restore/audit flows
- Add defaultOrderBy + orderBy param to loadAll() matching old BaseRepo
- Both are backward-compatible (optional params with defaults)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
dnplkndll and others added 5 commits March 23, 2026 18:50
Adapt integration test suite from Drizzle branch for Kysely:
- Replace getDrizzleDb → getDb, drizzle-orm sql → kysely sql
- Fix db.execute(sql`...`) → sql`...`.execute(db) (Kysely API)
- Remove assertions on DB-generated fields (NOW(), auto-computed)
  that aren't on the model returned by save()
- Fix repo bugs found by tests: AccessLogRepo/AuditLogRepo create
  path, GroupRepo labelArray cleanup, QuestionRepo sort on delete

All 299 tests pass across 6 modules:
  attendance: 36 | content: 62 | doing: 59
  giving: 58 | membership: 51 | messaging: 33

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Restore convertToModel on ConversationRepo, MessageRepo, ConnectionRepo
  to match original rowToModel field whitelisting (prevents leaking extra
  DB columns in API responses)
- Remove unused `sql` import from UserRepo, SettingRepo
- Use this.createId() consistently in base class save/insert instead of
  direct UniqueIdHelper.shortId() calls

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Port MySQL DDL to PostgreSQL for: membership (30), attendance (8),
content (30), giving (11), messaging (16), doing (12).

Key differences from MySQL DDL:
- BOOLEAN columns instead of BIT(1)/TINYINT for flags
- TIMESTAMP instead of DATETIME
- SERIAL for auto-increment columns
- PostgreSQL-native stored functions (cleanup, deleteForChurch,
  updateConversationStats)
- Double-quoted identifiers throughout
- DROP TABLE IF EXISTS CASCADE for idempotent re-runs

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Make all 99 Kysely repos work on both MySQL and PostgreSQL:

Boolean columns (PG DDL uses BOOLEAN, not BIT/SMALLINT):
- KyselyRepo base: soft-delete uses true/false not 1/0
- All .where("removed", "=", 0) → false, "=", 1 → true
- Raw SQL: removed=0 → removed=false across all modules
- Insert defaults: removed: 0 → removed: false

Dialect-specific SQL with getDialect() branching:
- AttendanceRepo.loadTrend: STR_TO_DATE → DATE_TRUNC
- DonationRepo.loadSummary: STR_TO_DATE → DATE_TRUNC
- SessionRepo.loadByGroupIdWithNames: DATE_FORMAT → TO_CHAR
- ServiceRepo.searchByCampus: rewrite to Kysely query builder
- ServiceTimeRepo.loadByChurchCampusService: rewrite to builder
- ConversationRepo: dialect-specific cleanup/save
- RegistrationRepo: INSERT...FROM dual → bare SELECT (PG)
- StreamingServiceRepo: recurring boolean filter
- TaskRepo: IFNULL → COALESCE

Cross-dialect fixes:
- SongDetailRepo: title + ' ' + artist → concat()
- RegistrationRepo.countActiveForEvent: Number() cast for PG bigint COUNT
- Remove debug console.log from Environment.ts

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- setup.ts: add DB_DIALECT=postgres support with PG connection defaults
- Fix backtick identifiers → double-quoted (groups, end)
- Fix INSERT IGNORE → dialect-conditional ON CONFLICT DO NOTHING
- Fix 10-char test IDs → 11-char for PG char(11) padding
- All 299 tests pass on both MySQL and PostgreSQL

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
dnplkndll and others added 2 commits March 24, 2026 22:08
The seed data file must load after roles.sql and rolePermissions.sql
are created. Alphabetical ordering caused it to run too early on PG.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
RolePermissionRepo: add all SELECT columns to GROUP BY (PG requires
non-aggregated columns to appear in GROUP BY, MySQL doesn't enforce).
PersonRepo: quote "optedOut" in raw SQL filter clause, add "churchId"
to GROUP BY in loadByName.

Add PostgreSQL demo data for all 6 modules (membership, attendance,
content, giving, messaging, doing) with:
- Double-quoted identifiers
- BOOLEAN literals (true/false not 0/1)
- PG date functions (EXTRACT, CURRENT_DATE, INTERVAL arithmetic)
- ON CONFLICT DO NOTHING for idempotency
- TRUNCATE CASCADE for re-runnability

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
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