Skip to content

feat(db): four additive migrations, and the delivery health, durability and retention they unlock - #63

Open
houko wants to merge 3 commits into
perf/wave3from
feat/migrations
Open

houko wants to merge 3 commits into
perf/wave3from
feat/migrations

Conversation

@houko

@houko houko commented Sep 22, 2026

Copy link
Copy Markdown
Member

What & why

Was: Every finished response loaded every enabled workflow's full published definition and threw most of it away. A Redis blip dropped the pipeline event with nothing recording it. Display and WorkflowRun grew forever. An integration that kept failing looked exactly like one that kept working.

Now: The candidate set is narrowed in SQL, the event survives a blip, both tables are swept on a configured window, and a failing integration says so.

  • Four migrations, every statement additive and non-blocking.
  • Three of the analysis's migration items are deliberately not here — see Open gaps.

Where to look

Breaking changes

  • This PR contains breaking changes
Change Before After Who's affected Action required
Four schema migrations — Three nullable columns on Integration, one on Workflow, two on Organization, one new table Every deployment Run migrations as usual. All additive; no rewrite, no backfill of a large table
Workflow index [workspaceId, status] [workspaceId, status, triggerSurveyId] Nobody directly None — the old index is a strict prefix of the new one, so no access path is lost
Retention sweep Nothing was ever pruned Display and WorkflowRun pruned on a per-organization window Self-hosters None by default: null means keep forever, and an organization that sets nothing is never visited. Deletion starts only once a window is configured

Migrations & env

  • 20260922130000_add_integration_delivery_health, 20260922131000_add_workflow_trigger_survey_id, 20260922132000_add_response_pipeline_outbox, 20260922133000_add_organization_retention_windows.
  • The triggerSurveyId backfill runs inside its migration and is bounded by the workflow tables, which hold one row per hand-authored workflow. It is guarded by IS NULL, so re-running it is safe.

How this was tested

Rerun: pnpm --filter @forma/web test --project=unit lib/organization/service.test lib/integration/service.test lib/authorization/resource-inventory.test instrumentation-jobs.test modules/workflows/lib/runner modules/response-pipeline/lib/outbox-drain.test lib/jobs/retention && pnpm --filter @forma/workflows test

Coverage

Behaviour How Outcome
A null trigger column still matches every survey unit (red on main) A workflow the backfill did not reach still fires
The matcher, not the column, decides what runs unit (guard) A drifted column costs a wasted fetch, never a wrong run
A retried pipeline event is idempotent unit (red on main) Job id derived from event + response + updated timestamp
An unconfigured organization is never swept unit (guard) Null window means keep forever
A failing integration delivery is recorded unit (red on main) Consecutive failures and last error persisted
Every Prisma model is classified exactly once unit (guard) The new table had to be named before this went green
Migration safety, stated per statement
  • Integration — one ALTER TABLE, one ACCESS EXCLUSIVE lock for the catalog update, no rewrite. The NOT NULL DEFAULT 0 is a constant default, stored as a missing value since PostgreSQL 11; the lowest major this project supports is 15 (packages/database/.squawk.toml).
  • Workflow — nullable TEXT add (catalog only), then a backfill joining Workflow to a DISTINCT ON over WorkflowVersion, both of which hold one row per authored workflow. Then the index swap.
  • Indexes are not CONCURRENTLY, for the reason an earlier migration in this repository already records: prisma migrate deploy sends the file as one multi-statement script, PostgreSQL runs it in an implicit transaction, and a concurrent build aborts with 25001.
  • ResponsePipelineOutbox — new table, so nothing to lock.
  • Each migration sets lock_timeout so a blocked run rolls back cleanly and can be re-run rather than queueing behind a long reader.

Twelve fixtures across six files gained the new columns. That is the type system working, not churn — consecutiveFailures is NOT NULL, so every stand-in for a persisted row must carry it. One of those fixtures doubled as its own expectation; select deliberately does not fetch the retention windows, so the expectation strips them rather than the query growing a column nothing reads.

Open gaps

  • squawk was not run locally — it is configured in this repo but not installed here, so the per-statement safety above is reasoning over the SQL, not a tool's verdict. CI's judgement is the one that counts.
  • No migration was applied. There is no database in this environment, so every claim is from reading. A staging apply before production is the obvious next step, particularly for the Workflow index swap.
  • Three migration items are deliberately absent: Response/Display gaining their own workspaceId (a backfill across the largest table, which needs its own rollout plan), retiring Survey.questions (a destructive drop that should span two releases), and the contacts trigram index (needs CREATE EXTENSION pg_trgm, which a self-hosted database user may not be able to grant). Each deserves its own PR.

Risks

  • The retention sweep deletes rows. It is opt-in and batched, but it is the only change here that destroys data, and the first configured window is the moment to watch.
  • The Workflow index swap drops the old index in the same transaction that builds the new one. Past the lock_timeout the whole migration rolls back cleanly, but on a busy instance it may need a retry.
  • triggerSurveyId can drift from the published definition if a future write path forgets it. The matcher covers correctness; what drifts is only how much the query narrows.

Note

AI model used — claude-opus-5[1m] (Claude Code), reasoning effort unknown.

… losing a response event

- Every finished response loaded every enabled workflow's full published definition and discarded most of it in memory. The denormalised trigger column narrows the candidate set in the query instead, served by the widened index. `matchWorkflowsForResponse` stays the sole authority on whether a workflow fires, so a column that has drifted from its definition can only cost a wasted fetch — never a run that should not have happened.
- The workflow list counted every run ever recorded, once per row. The "Runs" column is rendered, so the count could not simply be dropped; it is one grouped query now.
- A Redis blip dropped the response pipeline event silently: no webhook, no follow-up, no billing event, and nothing recording it. The job id is now derived from the event, the response and its updated timestamp, which makes a retry idempotent without any schema at all, and the outbox carries what genuinely needs durability.
…gers, pipeline durability and retention

Every statement is additive and non-blocking. `Integration` gains three delivery-health columns; the `NOT NULL DEFAULT 0` is a constant default, which PostgreSQL stores as a missing value rather than rewriting the heap on every version this project supports — the lowest is 15, per `.squawk.toml`. `Workflow` gains a nullable `triggerSurveyId` with a partial, re-runnable backfill, and its index widens to a superset of the old one, so dropping the old loses no access path. `ResponsePipelineOutbox` is a new table. `Organization` gains two nullable retention windows.

A null `triggerSurveyId` means "matches every survey", never "matches none". That is what makes the partial backfill safe: a row it did not reach — draft, disabled, a different trigger type, a malformed definition — still reaches the in-memory matcher exactly as before. Reading null as an empty match would silently stop those workflows firing. A null retention window likewise means keep forever: the sweep only visits organizations that have set one.

Indexes are created without CONCURRENTLY for the reason the repository already records in an earlier migration: `prisma migrate deploy` sends the file as one multi-statement script, PostgreSQL runs it in an implicit transaction, and a concurrent build aborts with 25001.

One unrelated line moved: `zod/organizations.ts` carried a type assertion on `isAISmartToolsEnabled` that `no-unnecessary-type-assertion` rejects. It is pre-existing, and only surfaces because the pre-commit hook runs type-aware rules over files a commit touches.
…nd classify the new table

- Notion, Airtable, Google Sheets and Slack deliveries record their outcome against the new columns, and integrations get the retry the webhook path already had, reusing that mechanism rather than a second one. This only became honest work once Notion and Airtable started reading their HTTP responses: a health surface built before that would have reported a permanently broken integration as healthy.
- Display and WorkflowRun grew without bound; only the AuthZed outbox was pruned. A batched sweep prunes both now, following that pruner's shape. The window is per-organization configuration rather than a constant, because how long a customer's data is kept is not a decision a job function gets to make, and null means keep forever — an organization that has configured nothing is never visited.
- `ResponsePipelineOutbox` is classified in the authorization resource inventory. That guard exists so every new model has someone state what it is in the authorization model; this one is internal delivery infrastructure nobody is granted access to, like the AuthZed outbox beside it.

Twelve fixtures across six files gained the new columns, which is the type system working rather than churn: `consecutiveFailures` is NOT NULL, so every stand-in for a persisted row has to carry it. In `organization/service.test.ts` the fixture also doubled as the expectation, and that no longer holds — `select` deliberately does not fetch the retention windows, so the mapper cannot return them. The expectation strips them rather than the query growing a column nothing reads.
@github-actions

Copy link
Copy Markdown

🚨 PR Size Warning

This PR has approximately 1585 lines of changes (1519 additions, 66 deletions across 25 files).

Large PRs (>800 lines) are significantly harder to review and increase the chance of merge conflicts. Consider splitting this into smaller, self-contained PRs.

💡 Suggestions:

  • Split by feature or module - Break down into logical, independent pieces
  • Create a sequence of PRs - Each building on the previous one
  • Branch off PR branches - Don't wait for reviews to continue dependent work

📊 What was counted:

  • ✅ Source files, stylesheets, configuration files
  • ❌ Excluded 14 files (tests, locales, locks, generated files)

📚 Guidelines:

  • Ideal: 300-500 lines per PR
  • Warning: 500-800 lines
  • Critical: 800+ lines ⚠️

If this large PR is unavoidable (e.g., migration, dependency update, major refactor), please explain in the PR description why it couldn't be split.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant