Skip to content

feat(programs): ProgramInstance tier + backfill (restructure phase 1)#953

Open
jee7s wants to merge 1 commit into
mainfrom
feat/program-instances-phase1
Open

feat(programs): ProgramInstance tier + backfill (restructure phase 1)#953
jee7s wants to merge 1 commit into
mainfrom
feat/program-instances-phase1

Conversation

@jee7s

@jee7s jee7s commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

What & why

Phase 1 of the Program → ProgramInstance → Event 3-tier restructure
(docs/designs/PROGRAM_INSTANCE_RESTRUCTURE.md). Today Program conflates a
catalog definition ("Woodworking 101") with a concrete offering ("Fall 2026
Woodworking"); the fix is a middle ProgramInstance tier. This PR ships the
doc's P1 only — the additive schema + a 1:1 backfill — and nothing that
reads it yet. It is expand/contract-safe: purely additive, safe for old code
during the deploy drain window (docs/DEPLOY_MIGRATION_ORDER_OF_OPERATIONS.md).

See the new Re-validation section at the top of the design doc for the full
scope + cut line.

Decisions

  • Scope = the doc's P1, additive only. New ProgramInstance table (all
    offering columns mirrored from Program + nullable narrowing overrides),
    nullable Event.instanceId FK, Program.instances / Person.instancesLed
    back-relations. No FK repoint, no read/security-claim cutover, no
    write/UI, no column drops — those are P2–P5 and are RENAME/DROP (contract)
    steps deliberately deferred to later releases.
  • Re-validated against Shopify single-pool capacity + scholarship lifecycle inventory (supersedes two-pool mirror) #930, not docs(design): propose Shopify member-segment pricing #929 (which is unmerged). The doc assumed
    SHOPIFY_MEMBER_SEGMENT_PRICING.md (docs(design): propose Shopify member-segment pricing #929) landed first (one variant + a stored
    shopifyMemberDiscountId). What merged is Shopify single-pool capacity + scholarship lifecycle inventory (supersedes two-pool mirror) #930 (single-pool capacity +
    scholarship hold ledger, checkin-app/docs/PROGRAM_CAPACITY_AND_SCHOLARSHIPS.md).
    So the instance mirrors the Shopify columns that actually exist
    shopifyProductId, shopifyVariantId, and the legacy
    shopifyOrgMemberVariantId/shopifyNonOrgMemberVariantId pair — and carries
    no shopifyMemberDiscountId. Capacity (maxParticipants, Shopify-sourced)
    and the hold-ledger fields (inventoryHeldAt/paymentPlanDeniedAt on
    ProgramParticipant) are untouched in P1 and consistent with Shopify single-pool capacity + scholarship lifecycle inventory (supersedes two-pool mirror) #930; the doc's
    re-validation section flags where P2/P3 must re-target adjustProgramInventory
    and withdrawAndReleaseHold to the instance.
  • Backfill (MB), id-aliased + idempotent. One ProgramInstance per Program
    with id = Program.id, so the later FK repoint (P2) is a pure RENAME with no
    value rewrite; Event.instanceId = programId; setval bumps the sequence past
    the aliased ids. Hand-written SQL (not a Prisma DROP+ADD), wrapped in one
    BEGIN/COMMIT, idempotent (skip programs that already have an instance, only
    fill still-null instanceId, setval derived from MAX(id) so it never
    regresses).
  • Security: ProgramInstance is sensitive+scopable (via programId) but has
    no read consumers in P1, so it goes in OPT_OUT_PENDING_ROUTE; its real
    their_program_participants binding lands with the atomic claim swap in P3.
  • No admin UI / no seed change (see the design doc's cut line): P1 adds no
    read consumers by design, and no live write path creates instances yet, so a
    read-only page or a seed that mints an instance would misrepresent the running
    app. The backfill is proven by the integration test.

Testing

  • src/app/__tests__/programInstanceBackfill.integration.test.ts — seeds real
    Program/Event rows (legacy two-variant + single-pool shapes, a program-bound
    and a program-less event), runs the migration's own backfill SQL (extracted
    verbatim between markers), and asserts the id-alias, full column copy, event
    linkage, sequence bump (a new insert doesn't collide), and idempotency (second
    run is a no-op). Passing under INTEGRATION_DB=1.
  • Full unit suite green: 1686 passed / 207 suites. Security scope-validator +
    binding-equivalence tests confirm the OPT_OUT_PENDING_ROUTE addition.
  • npx tsc --noEmit clean; eslint --max-warnings 0 clean on touched files.
  • Migration DDL generated by prisma migrate diff (matches schema.prisma
    exactly → drift check passes); migration-safety.yml re-runs the backfill on
    the seeded Woodworking 101 program on a populated DB.

Deferred (later phases, per the design doc §7)

P2 FK repoint (RENAME), P3 read cutover + atomic programsLedinstancesLed
claim swap, P4 write/UI flip + public /programs schedule, P5 Program column
drops. Each is called out in the doc's Phase-1 cut line.

🤖 Generated with Claude Code

…e 1)

Phase 1 of PROGRAM_INSTANCE_RESTRUCTURE.md: additive/expand-only introduction of
the ProgramInstance offering tier between Program (definition) and Event.

- New ProgramInstance model mirroring Program's offering columns + nullable
  narrowing overrides; nullable Event.instanceId FK; Program.instances /
  Person.instancesLed back-relations. Sensitivity-annotated; classifications
  regenerated.
- Migration 20260708040000: generated additive DDL + hand-written idempotent 1:1
  backfill (id-aliased instance per program, Event.instanceId=programId, sequence
  bump), wrapped in BEGIN/COMMIT.
- ProgramInstance added to OPT_OUT_PENDING_ROUTE (no read consumers until the P3
  claim swap).
- Re-validated against #930 (single-pool capacity + hold ledger), NOT the
  unmerged #929: instance mirrors the real Shopify columns (single variant +
  legacy pair), no shopifyMemberDiscountId. See the doc's re-validation section.

No FK repoint, read/security cutover, UI, or column drops — those are P2-P5,
deferred per the expand-contract migration policy.

Integration test covers the backfill (id-alias, column copy, event linkage,
sequence bump, idempotency) against a populated schema.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@jee7s jee7s added the post-first-release Deferred until after the first production release label Jul 7, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

post-first-release Deferred until after the first production release

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant