ReviewOS

stacks/reportshq

Framework: append-only model-driven migrations (unblock membership models)

#5
Closed chris opened this 8 days ago ·
8 days ago

Context

This is a stacksjs/stacks framework fix, worked in ~/Code/stacks, not in this repo - but it blocks #4, so it lives on this board.

When loghq added models for its membership tables, buddy generate:migrations renumbered existing migration files, so loghq fell back to hand-written raw SQL migrations (database/migrations/0000000006-*.sql) with no model on top. ReportsHQ bans hand-written migrations outright, so the generator has to be fixed at the root: adding a new model must produce a stable, append-only migration - existing files and their ordering are immutable history.

Scope

  • Reproduce: in a scratch Stacks app with several models already migrated, add a new model (with relations to existing tables) and run buddy generate:migrations. Document exactly what gets renumbered/rewritten and why (the generator lives around storage/framework/core/database/ / the generate:migrations action path).
  • Fix the generator so new models append new migration files; previously emitted files are never renamed, renumbered, or rewritten. Diffing against the live schema (the model-snapshot mechanism) must keep working for migrate:fresh.
  • Cover pivot/membership-shaped models explicitly: email-keyed membership tables, tokened invite tables, composite uniques, partial indexes (loghq's LogEntry uses partial indexes on trace_id/request_id - the model attribute API must be able to express whatever #4 and #9 need; extend defineModel if it cannot).
  • Tests in the framework repo for: append-only behavior, new-model-with-FK-to-existing-table, and re-running the generator twice being a no-op.
  • Release the framework packages touched (bun run release:patch per the owning package convention) and bump reportshq to the released versions.

Acceptance

  • In reportshq, defining ProjectMember / ProjectInvite models (#4) and running buddy generate:migrations twice yields: new append-only migration files on the first run, no changes on the second, and untouched prior migrations - verified in a feature test or scripted check.

Blocks #4 and #9. Part of #1 (M1 Foundation). Ground rules: see #1.

8 days ago

Verified against current Stacks (stacks@0.70.371): the generator is already append-only, so the workaround this issue was written to remove is no longer needed. Closing on evidence rather than a fix.

Reproduced all three acceptance criteria in this repo, on the 189-file corpus the scaffold ships:

1. A new model appends and renumbers nothing. Added app/Models/ProjectMember.ts (email-keyed membership, the exact shape loghq could not model) and ran ./buddy generate:migrations:

Generated 1 migration file
$ git status --short database/
?? database/migrations/1785502251834-create-project_members-table.sql

One new file, no modifications and no renames to the existing 189. New migrations use a timestamp ordinal and sort after the committed 00000000NN- corpus.

2. Re-running is a no-op. A second generate:migrations produced no files and left the tree byte-identical.

3. A new model with a foreign key to an existing table appends correctly. Added ProjectInvite with belongsTo: ['ProjectMember']:

Generated 1 migration file
"project_member_id" INTEGER REFERENCES "project_members"("id"),

./buddy migrate then applied both cleanly, and PRAGMA table_info(project_invites) returned id, token, project_member_id, created_at, updated_at.

Also worth recording for #4: once app/Models/ exists, the generator scopes the schema to your models and leaves the 77 framework-owned tables in place rather than dropping them, which is the behaviour we want.

Two real bugs did surface while testing this, both fixed or filed upstream:

  • The failure that made this look like a generator problem was handleError('Migration generation failed', error) discarding the actual error - the thrown value went into the context slot and was JSON.stringify'd to {}, because message, stack and cause are non-enumerable. generate:migrations printed nothing but its own label. Fixed in stacksjs/stacks@6c6d452 with tests; the real error (schema.string().maxLength is not a function) was then immediately visible.
  • The framework docs, including the AGENTS.md that ships into every scaffolded app and the stacks-orm / stacks-new-feature / stacks-validation skills, all taught schema.string().maxLength(n). That method does not exist; the API is .min() / .max(), which is what every built-in model already uses. Corrected upstream so models written from the docs actually generate.
  • ./buddy migrate:fresh --force fails on a clean scaffold and pollutes database/migrations/ with duplicates: stacksjs/stacks#2323. Use rm database/stacks.sqlite && ./buddy migrate to reset locally until that lands.

So #4 can define ProjectMember and ProjectInvite as real models. No raw SQL, no renumbering risk.

Sign in to comment on this issue.