ADR: Sphere Column Type-Safety — text[] → activity_type[] Enum
ADR: Sphere Column Type-Safety — text[] → activity_type[] Enum
Section titled “ADR: Sphere Column Type-Safety — text[] → activity_type[] Enum”Status
Section titled “Status”PROPOSED
AC ↔ D# mapping
Section titled “AC ↔ D# mapping”| AC | Primary decisions | Notes |
|---|---|---|
| AC-1 | D1 (schema definition update), D2 (ALTER COLUMN SET DATA TYPE USING) | Drizzle schema + drizzle-emitted migration body with hand-edited USING cast. |
| AC-2 | D3 (pre-flight DO block) | RAISE on any non-enum value in either column; full rollback. |
| AC-3 | D2 (PG enum DB constraint) | Native enum type rejects garbage at DB level (22P02). |
| AC-4 | D4 (DTO decorator update — 3 surfaces) | enumName: ‘ActivityType’ on all three sphere DTOs. |
| AC-5 | D5 (no behavior regression — wire format unchanged) | JSON unchanged; existing e2e tests must continue to pass unchanged. |
| AC-6 | D4 + D6 (business client regen) | post-regen patch-spheres-swagger.js consistency check. |
| AC-7 | D7 (4 e2e cases) | happy / pre-flight RAISE / 22P02 / rollback safety. |
Decisions
Section titled “Decisions”D1 — Drizzle schema definitions update first
Section titled “D1 — Drizzle schema definitions update first”Decision: edit libs/shared/data-access-db/src/lib/schema/activities.schema.ts:
defaultActivityType: ActivityTypeEnum('default_activity_type').notNull(),allowedActivityTypes: ActivityTypeEnum('allowed_activity_types').array().notNull(),Then run pnpm db:generate to emit a migration scaffold. drizzle-kit will detect the type change and emit ALTER COLUMN ... SET DATA TYPE for both columns.
Rationale:
- Drizzle schema is the source of truth — TypeScript inference downstream picks up the enum union type automatically.
pnpm db:generateproduces journal entry + snapshot. Hand-editing the migration SQL body is necessary (see D2) but the file itself is drizzle-emitted (memory rule satisfied).
D2 — ALTER COLUMN SET DATA TYPE with explicit USING cast (hand-edit only if drizzle omits it)
Section titled “D2 — ALTER COLUMN SET DATA TYPE with explicit USING cast (hand-edit only if drizzle omits it)”Decision: run pnpm db:generate and inspect the emitted SQL before assuming a hand-edit is needed:
- Scalar
text → enum(default_activity_type): repo evidence shows drizzle-kit DOES emit theUSINGcast for this case — migration0021_lovely_shinko_yamashiro.sql:6containsALTER COLUMN "type" SET DATA TYPE "activities"."activity_type" USING "type"::"activities"."activity_type"(drizzle-generated). Expect the same emission here; no hand-edit. - Array
text[] → enum[](allowed_activity_types): unverified in this repo (first array-typed enum conversion). If the emittedALTER COLUMN allowed_activity_types SET DATA TYPE activities.activity_type[]lacksUSING allowed_activity_types::activities.activity_type[], hand-add it. - Pre-flight DO $$ block (D3): always hand-prepended — drizzle-kit never emits data guards.
Any hand-edit gets a prose block comment at the top of the file in the 0043_pg_trgm_search_indexes.sql style: a multi-line -- comment explaining WHAT was hand-edited, WHY drizzle-kit cannot emit it, and what happens on subsequent pnpm db:generate re-runs (must be a stable no-op because the schema TS already declares the enum-typed columns).
Rationale:
- Postgres requires explicit
USINGwhen changing column type between non-implicitly-castable types. Whether drizzle-kit 0.31.x emits it for the array case is the only open mechanical question — resolved by inspection at generate time, not by assumption. - Existing precedent for hand-edited migration content in this repo:
0043_pg_trgm_search_indexes.sql(extension prepend, prose comment block) and0021(proves scalar USING emission). Same hand-edit discipline — document the deviation in the SQL itself. - The cast
text[] → activity_type[]is lossless when all values are in the enum domain. The pre-flight DO block (D3) guarantees this.
Alternative rejected: drizzle-kit generate --custom produces an empty file without updating the snapshot — the next db:generate would re-emit the ALTER and create drift. The primary path (schema update → generate → hand-edit) is the only snapshot-safe route.
D3 — Pre-flight DO $$ block raises on any garbage
Section titled “D3 — Pre-flight DO $$ block raises on any garbage”Decision: the migration starts with a DO $$ block that counts rows where default_activity_type is not in the enum domain AND rows where any element of allowed_activity_types is not in the enum domain. If either count > 0, aggregate the offending values into a string and RAISE EXCEPTION with a clear message instructing the operator to run cleanup first.
Rationale:
- Without the pre-flight, the ALTER COLUMN … USING cast would
RAISEmid-conversion with a less-informative Postgres error (“invalid input value for enum”). Our DO block surfaces the count and the specific offending values, making operator triage cheaper. - Fail-loud is cheaper than silent data corruption.
- Pre-flight matches the pattern from P0 #1 (default_activity_type=‘MEMBERSHIP’ check) and P3 #8 (SERVICES sphere row existence check). Operators learn one convention.
Operator pre-deploy check: before merging, run on prod:
SELECT count(*) FROM activities.spheresWHERE default_activity_type NOT IN ('SHOW','MOVIE','SLOT_BASED','DINING','SERVICE') OR EXISTS (SELECT 1 FROM unnest(allowed_activity_types) v WHERE v NOT IN ('SHOW','MOVIE','SLOT_BASED','DINING','SERVICE'));Result must be 0. If non-zero, escalate.
D4 — DTO @ApiProperty decorators: exactly TWO declaration sites
Section titled “D4 — DTO @ApiProperty decorators: exactly TWO declaration sites”Decision: the response-DTO hierarchy uses inheritance (SphereSuperAdminDto extends SphereAdminDto extends SphereClientDto — libs/features/spheres/src/lib/dto/sphere.dto.ts), so all three surfaces are covered by updating two declaration sites:
SphereClientDto.allowedActivityTypes(sphere.dto.ts:22, currently@ApiProperty({ type: [String] })) →@ApiProperty({ enum: ActivityTypeEnum.enumValues, enumName: 'ActivityType', isArray: true, example: ['DINING', 'SERVICE'] })— inherited by Admin and SuperAdmin.SphereAdminDto.defaultActivityType(sphere.dto.ts:28, currently bare@ApiProperty()) → same decorator withoutisArray— inherited by SuperAdmin. Client omits the field per D9 ofactivity-spheresADR (verified:SphereClientDtohas nodefaultActivityType).
Mutation DTOs (CreateSphereDto, UpdateSphereDto) need NO changes — P0 #1 already landed enumName: 'ActivityType' + @IsIn(ActivityTypeEnum.enumValues) on both (sphere.dto.ts:58-77 and 107-127). Note on ActivityType schema pre-existence (attribution corrected by Phase A critic): components.schemas.ActivityType is present in all three contract YAMLs primarily because the activities-feature DTOs (libs/features/activities/src/lib/dto/find-activities-client.dto.ts:79, activity.response.dto.ts:97) already carry enumName: 'ActivityType' on activities’ own type fields — those DTOs are exposed on the client + business surfaces. (The super-admin-only mutation DTOs contribute on the superadmin surface.) The pre-existence of the named schema on all three surfaces is what makes this ticket’s contract diff $ref wiring only.
Rationale:
enumName: 'ActivityType'instructs@nestjs/swaggerto emit a top-levelcomponents.schemas.ActivityTypeenum and$refit. Without it, the enum inlines at every reference site, breaking named-enum codegen in clients.- CLAUDE.md OpenAPI authoring checklist explicitly requires
enumNamefor every named enum — this fixes the remaining gap on the sphere response DTOs (mutation DTOs were fixed in P0 #1).
D5 — No wire-format behavior regression
Section titled “D5 — No wire-format behavior regression”Decision: the JSON wire format is unchanged. Existing clients send enum values as strings; this remains true after migration. Existing e2e tests for spheres run unchanged; verify GREEN with no test rewrites needed.
Rationale:
- The enum is a type-level tightening on both sides (TypeScript union, Dart enum) — no behavior change at the protocol layer.
- Backward compatibility: old client builds in the wild (web/mobile/business) continue to work. New regenerations get a stricter named type.
D6 — Business client regen + patch-script consistency check
Section titled “D6 — Business client regen + patch-script consistency check”Decision: after backend migration + sync:contracts, run npm run generate:api in tktspace-business. Then verify:
grep -rn MEMBERSHIP client/src/app/core/api/models/ tools/gen/returns 0 (smoke from P0 #1 work).tools/gen/patch-spheres-swagger.js:55hardcoded enum (post-P0 #1:['SHOW','MOVIE','SLOT_BASED','DINING','SERVICE']) matches the regenerated contract — if anything has drifted, update both the patch script andtools/gen/swagger-api.json.
Rationale:
- The patch-spheres-swagger.js workaround from P0 #1 still exists. Until it’s retired in a future cleanup ticket, every contract regen must verify it’s in sync with the live contract.
- The named
ActivityTypeschema will now appear in the regeneratedswagger-api.jsonautomatically. If the patch script’s hardcodedActivityTypeCodeenum is still present and consistent, the codegen output is unaffected.
D7 — E2E test suite: 4 cases
Section titled “D7 — E2E test suite: 4 cases”Decision: new file apps/api-e2e/src/spheres/sphere-type-safety-0046.spec.ts (migration head verified at 0045; this ticket lands 0046_sphere_columns_to_activity_type_enum.sql). Covers:
- Happy path — apply migration on clean data, assert
information_schema.columns.udt_name = 'activity_type'for both columns. - Pre-flight RAISE — inject
default_activity_type='BANANA'row, run migration, assert RAISE matching/% bad default_activity_type.*Offending values: BANANA/, assert rollback (columns still text). - 22P02 rejection — post-migration, raw
INSERTwith'BANANA'→ assert Postgres error code22P02. - Rollback safety — down-migration (text-cast) preserves data exactly.
Rationale: mirrors the e2e patterns from sphere-migration-0044.spec.ts (P0 #1) and sphere-rebucket-0045.spec.ts (P3 #8). Direct pg client + apply-migration helper, no test rewrites needed.
Out of scope (explicitly deferred)
Section titled “Out of scope (explicitly deferred)”allowedPaymentMethods text[]on activities — same shape problem, different domain (payment method enum, not activity type). Separate ticket.- Adding new
activity_typeenum values — product decision, separate per-feature ADR. activity_categories/categories.sphere_idconsistency — P3 #20.- Retiring
tools/gen/patch-spheres-swagger.js— workaround from earlier sphere endpoint rollout; can retire oncebusiness.openapi.yamlcarries proper sphere schemas from a clean codegen. Out of scope here. - CI seed-lint guard — DB enum constraint replaces it.
Risks (post-mitigation)
Section titled “Risks (post-mitigation)”- Hand-edited migration drifts on next
pnpm db:generate— only relevant if the arrayUSINGcast needs hand-adding (D2). Mitigation: prose comment block in the 0043 style + the committed snapshot reflects the post-migration schema state, so re-runs are a no-op. Future migrations of these columns are rare (enum value addition only). - Pre-flight false-positive blocks legitimate deploys — only fires if garbage exists. Operator runs the manual SELECT pre-deploy (D3). Cleanup follows P0 #1 / P3 #8 patterns.
- Backward-compat regression — none expected. Phase B verifies all existing sphere e2e tests still GREEN.
Verification
Section titled “Verification”- Edit
activities.schema.tsper D1 (current state verified: lines 84-85, bothtext(...);ActivityTypeEnumpgEnum at lines 39-45, 5 values). - Run
pnpm db:generate. Inspect emitted SQL per D2 — scalarUSINGexpected present (0021 precedent); arrayUSINGmay be missing. - Hand-edit the migration: prepend the pre-flight DO block; add the array
USINGclause only if absent; prose comment block in 0043 style. - Run e2e:
pnpm exec jest --testPathPattern=sphere-type-safety— all 4 cases pass. - Run existing sphere e2e:
pnpm exec jest --testPathPattern=spheres/— no regression. pnpm run docker:up && pnpm db:migrate→ smoke:\d+ activities.spheresshows enum types.pnpm run sync:contracts→ all three YAMLs emitActivityTypenamed schema.- In
tktspace-business:npm run generate:api, verifygrep MEMBERSHIPreturns 0. - Manual 22P02 verification: raw SQL
INSERTwith bad value → Postgres error.
STATUS: READY_FOR_REVIEW