Skip to content

feat(db): the development seed, idempotent for real - #48

Closed
Willi363363 wants to merge 1 commit into
feat/rewrite-phase-2-usagefrom
feat/rewrite-phase-2-seed
Closed

Willi363363 wants to merge 1 commit into
feat/rewrite-phase-2-usagefrom
feat/rewrite-phase-2-seed

Conversation

@Willi363363

Copy link
Copy Markdown
Owner

Step 2.6 of plans/rewrite/phase-02-data.md — the last step of phase 2.

Stacked on #47. Base is feat/rewrite-phase-2-usage, so the diff here is step 2.6
alone.

What it seeds

A room, a finished game with its solution, two participants — one account and one
guest — their answers, the hints one of them bought, the items they threw, the
model calls it cost, and a flag report. Enough to develop phases 3 to 8 without
playing a game first, which is the point: not test data. Tests build exactly
the rows they assert on.

"Idempotent" was false, and the test is what caught it

My first version printed seeded twice, exited zero both times, and quietly
doubled three tables. item_use, llm_call and flag_report default their
primary key to a random UUID, so onConflictDoNothing had no conflict to
find
.

That is exactly the failure mode "replays without error" cannot detect — which is
why the test counts rows across all eleven tables instead of trusting the exit
code:

items=4  llm=6  flags=2      before   (two runs)
items=2  llm=3  flags=1      after    (three runs)

Verified biting by removing one id: flag_report goes from 1 to 3.

(A note on my own process: my first attempt at that verification reported the
test as still passing. The regex had not matched, so the file was never modified
— the test was fine, my check was not. Worth redoing rather than reporting a
green I had not earned.)

It could not run at all at first

The seed went through connectFromEnv, which validates the whole
environment. So a command whose only job is to talk to Postgres refused to start
without a Redis URL and a model key — and named two variables the reader would
then go hunting for in the wrong place.

requireDatabaseUrl is the tool-shaped version. drizzle.config.ts had the same
logic inline; it now shares this one instead.

tsx, declared

Node's type stripping executes TypeScript but does not resolve a .js import
onto a .ts file, and this package imports that way throughout — as the rest of
the monorepo does. tsx was already in the tree under drizzle-kit; declaring
it is better than relying on someone else's dependency.

The seeded scores are the scale, applied by hand

ada:    2×150 − 1×80 − 200 − 50 + 90 =  60
chloé:  1×150 −    0 −   0 −  0 + 60 = 210

A test checks both. If the scale ever moves, that is where the seed stops
describing a real game.

The phase exit gate

gate evidence
drizzle-kit migrate on a fresh database, in CI 4 migrations from nothing, and CI's postgres:17 service since #44
all exported queries typed, no free-form SQL outside the package queries/ only; the one sql.raw is the test harness's truncate
the cost of a game is a query that answers on the seed 2.5 — the cost of a game answers, and the failure is not in it
build && test && lint && typecheck green
CI=true pnpm test    db 61 · domain 233 · protocol 220 · config 18 · env 7

Phase 2's six steps are all ticked. I left the phase state as in progress:
the gate is passed, but nothing is merged.

Something you should read before merging anything

I had an agent audit the whole 18-PR stack read-only, and it found three things
that would have bitten. Summarised in my message rather than buried here — the
short version is: merge commits, not squash, and two of the base branches
(feat/rewrite-phase-1, feat/rewrite-phase-2) currently lead nowhere.

🤖 Generated with Claude Code

https://claude.ai/code/session_01533WTTbgmfCaDP5tmK8Gkv

Step 2.6 of phase 2, and the last one. A room, a finished game with its
solution, two participants — one account and one guest — their answers, the
hints one of them bought, the items they threw, the model calls it cost, and
a flag report.

Enough to develop phases 3 to 8 without playing a game first, which is the
point: not test data. Tests build exactly the rows they assert on.

Fixed identifiers, literal timestamps, no randomness — replaying has to
produce the same database, or "idempotent" is a claim rather than a property.

That claim was false in my first version, and the test is what caught it.
`item_use`, `llm_call` and `flag_report` default their primary key to a
random UUID, so `onConflictDoNothing` had no conflict to find: the seed
printed "seeded" twice, exited zero both times, and quietly doubled three
tables. Every row carries an explicit id now, and the test counts rows across
all eleven tables instead of trusting the exit code. Verified biting by
removing one id: `flag_report` goes from 1 to 3.

The seed also could not run at all at first. It went through
`connectFromEnv`, which validates the whole environment, so a command whose
only job is to talk to Postgres refused to start without a Redis URL and a
model key — and named two variables the reader would then go looking for in
the wrong place. `requireDatabaseUrl` is the tool-shaped version, now shared
with `drizzle.config.ts` instead of duplicated in it.

Run through `tsx`: Node's type stripping executes TypeScript but does not
resolve a `.js` import onto a `.ts` file, and the package imports that way
throughout. `tsx` was already in the tree under `drizzle-kit`; declaring it
is better than relying on someone else's dependency.

The seeded scores are C2.1 applied by hand, and a test checks them — if the
scale ever moves, that is where the seed stops describing a real game.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01533WTTbgmfCaDP5tmK8Gkv
@qodo-code-review

Copy link
Copy Markdown

PR Summary by Qodo

Add truly idempotent development DB seed with dedicated DATABASE_URL helper

✨ Enhancement 🧪 Tests ⚙️ Configuration changes 📝 Documentation 🕐 40+ Minutes

Grey Divider

AI Description

• Add a deterministic, replayable development seed for a full finished game scenario.
• Ensure true idempotency by using fixed IDs and verifying table row counts on replay.
• Introduce a DB-only env helper and run TS scripts via declared tsx dependency.
Diagram

graph TD
  A["pnpm --filter @wikifake/db seed"] --> B["scripts/seed.ts"] --> C["requireDatabaseUrl()"] --> D["connect()"] --> E["seed(db)"] --> F[("Postgres")]
  E --> G["seed/data.ts"]
  H["drizzle-kit migrate"] --> I["drizzle.config.ts"] --> C
  subgraph Legend
    direction LR
    _cmd["Command"] ~~~ _file["TS/Config file"] ~~~ _db[("Database")]
  end
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Truncate-then-insert seeding
  • ➕ Simple mental model: always ends in the same dataset
  • ➕ Avoids needing explicit IDs for tables with UUID defaults
  • ➖ Destructive for a developer database (removes a developer’s own rows)
  • ➖ Harder to safely run against a partially-seeded DB without data loss
2. Use a dedicated seeding/fixture framework (factory-based)
  • ➕ Can reduce manual fixture maintenance as schema grows
  • ➕ Can generate related rows programmatically with clearer intent
  • ➖ Risk of reintroducing non-determinism unless carefully constrained
  • ➖ Adds complexity/dependency surface vs straightforward inserts
3. Ship seed as migrations (seed-migrations)
  • ➕ Seed data evolves with schema changes in a single migration pipeline
  • ➕ Easy to apply on fresh DBs during setup
  • ➖ Migrations are expected to be schema-focused; mixing large fixture datasets can be noisy
  • ➖ More friction to re-run/iterate during development without migration churn

Recommendation: The PR’s approach (explicit fixed identifiers + transactional onConflictDoNothing inserts) is the best fit for a non-destructive development seed. It makes idempotency a property of the data model (no randomness, stable timestamps/IDs) and proves it via row-count assertions across all seeded tables, which directly addresses the prior failure mode where replays exited cleanly while silently duplicating rows.

Files changed (10) +516 / -13

Enhancement (5) +343 / -0
seed.tsIntroduce CLI entrypoint for development seeding +19/-0

Introduce CLI entrypoint for development seeding

• Adds a small CLI wrapper that reads DATABASE_URL via 'requireDatabaseUrl()', connects with a single-connection pool, executes 'seed(db)', prints 'seeded', and closes cleanly.

packages/db/scripts/seed.ts

database-url.tsAdd 'requireDatabaseUrl()' utility for CLI tools +15/-0

Add 'requireDatabaseUrl()' utility for CLI tools

• Introduces a focused helper that validates only 'DATABASE_URL' with a targeted error message. This avoids using 'connectFromEnv' for tooling paths that should not require unrelated service configuration (e.g., Redis/model keys).

packages/db/src/database-url.ts

index.tsExport database URL helper and seed API from db package +2/-0

Export database URL helper and seed API from db package

• Re-exports 'requireDatabaseUrl' and 'seed' from the package public surface so tooling and consumers can share the same entrypoints.

packages/db/src/index.ts

data.tsAdd deterministic seed fixture dataset for a finished game +224/-0

Add deterministic seed fixture dataset for a finished game

• Defines a complete, realistic development dataset: users/profiles, a room, a finished game with positions, participants, answers, hint purchases, item uses, LLM calls (including a failure case), and a flag report. Uses fixed IDs and literal timestamps specifically to guarantee replay idempotency and stable ordering.

packages/db/src/seed/data.ts

seed.tsImplement transactional, non-destructive idempotent seed +83/-0

Implement transactional, non-destructive idempotent seed

• Implements 'seed(db)' as a single transaction inserting into all relevant tables. Uses 'onConflictDoNothing' and relies on fixed identifiers to make replays and partial replays safe and non-destructive.

packages/db/src/seed/seed.ts

Tests (1) +160 / -0
seed.test.tsTest seed idempotency and verify key queries over seeded data +160/-0

Test seed idempotency and verify key queries over seeded data

• Adds tests that (1) assert every seeded table is non-empty on a fresh DB and (2) assert repeated seeding does not change row counts across all seeded tables. Also validates that existing query surfaces (game read/solution separation, leaderboard scores, hint purchase monotonicity, item-use reads, and usage totals excluding failures) behave correctly on the seeded dataset.

packages/db/src/seed/seed.test.ts

Documentation (1) +2 / -2
phase-02-data.mdMark phase 2.6 seed step as completed and gate passed +2/-2

Mark phase 2.6 seed step as completed and gate passed

• Updates the phase plan status and marks the development seed step as done, reflecting completion of phase 2’s exit criteria.

plans/rewrite/phase-02-data.md

Other (3) +11 / -11
drizzle.config.tsReuse DB-only env helper for drizzle-kit credentials +4/-9

Reuse DB-only env helper for drizzle-kit credentials

• Replaces inline DATABASE_URL validation with 'requireDatabaseUrl()'. Keeps drizzle-kit behavior focused on the single variable it needs, avoiding full application env validation semantics.

packages/db/drizzle.config.ts

package.jsonAdd seed script and declare tsx for TS execution +4/-2

Add seed script and declare tsx for TS execution

• Runs migration-normalisation and seeding via 'tsx' instead of 'node' to match the repo’s '.js'-import-to-'.ts' resolution style. Adds a 'seed' script and makes 'tsx' an explicit devDependency.

packages/db/package.json

pnpm-lock.yamlLockfile update for explicit tsx dependency +3/-0

Lockfile update for explicit tsx dependency

• Adds 'tsx@^4.23.12' to the lockfile under the db package importer to match the new explicit devDependency.

pnpm-lock.yaml

@qodo-code-review

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (1) 📘 Rule violations (0) 📜 Skill insights (0)

Grey Divider


Remediation recommended

1. Seed skips required parents 🐞 Bug ☼ Reliability
Description
seed() uses onConflictDoNothing() without a conflict target for user, so a conflict on
user.email can skip inserting the seed_user_* row; the subsequent profile insert then fails
its FK to user.id, breaking seeding on non-empty dev DBs.
Code

packages/db/src/seed/seed.ts[R47-54]

+    await tx
+      .insert(user)
+      .values([...SEED_USERS])
+      .onConflictDoNothing();
+    await tx
+      .insert(profile)
+      .values([...SEED_PROFILES])
+      .onConflictDoNothing();
Evidence
The seed performs user insert with ON CONFLICT DO NOTHING and then inserts profile rows that
must reference user.id. Because user.email is also unique, a pre-existing row with the same
email but a different id will cause the user insert to be skipped, and the profile insert to fail
its FK constraint.

packages/db/src/seed/seed.ts[47-54]
packages/db/src/schema/auth.ts[18-23]
packages/db/src/schema/profile.ts[10-14]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`seed()` currently uses `.onConflictDoNothing()` without specifying a conflict target. In Postgres this ignores *any* unique-constraint conflict, including `user.email`. If a developer database already contains a row with `ada@example.org` under a different `user.id`, the user insert will be skipped, and the subsequent `profile` insert (which references `seed_user_ada`) will fail with a FK violation.

## Issue Context
This shows up specifically because `user.email` is unique, and `profile.userId` must reference `user.id`.

## Fix Focus Areas
- packages/db/src/seed/seed.ts[47-54]
- packages/db/src/schema/auth.ts[18-23]
- packages/db/src/schema/profile.ts[10-14]

## Suggested fix
1) For tables that are referenced by later inserts (notably `user`), use `onConflictDoNothing({ target: <primary key> })` so only *id-based* replays are treated as no-ops.
2) (Optional but robust) Make the seed emails even less likely to collide (e.g., `seed-ada@example.org`), or add a preflight check that if `seed_user_ada` does not exist after the user insert, throw a clear error indicating an email conflict.

Example direction:
- Change the user insert to `.onConflictDoNothing({ target: user.id })` so an email collision becomes an immediate, clear unique-violation error instead of a later FK failure.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Context sources
✅ Web pages:
  +7 more
Review mode: ⚖️ Balanced

Grey Divider

Tip of the day
💡 Did you know, you can switch off images and animations for a plain-text comment

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Qodo Logo

Comment on lines +47 to +54
await tx
.insert(user)
.values([...SEED_USERS])
.onConflictDoNothing();
await tx
.insert(profile)
.values([...SEED_PROFILES])
.onConflictDoNothing();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

1. Seed skips required parents 🐞 Bug ☼ Reliability

seed() uses onConflictDoNothing() without a conflict target for user, so a conflict on
user.email can skip inserting the seed_user_* row; the subsequent profile insert then fails
its FK to user.id, breaking seeding on non-empty dev DBs.
Agent Prompt
## Issue description
`seed()` currently uses `.onConflictDoNothing()` without specifying a conflict target. In Postgres this ignores *any* unique-constraint conflict, including `user.email`. If a developer database already contains a row with `ada@example.org` under a different `user.id`, the user insert will be skipped, and the subsequent `profile` insert (which references `seed_user_ada`) will fail with a FK violation.

## Issue Context
This shows up specifically because `user.email` is unique, and `profile.userId` must reference `user.id`.

## Fix Focus Areas
- packages/db/src/seed/seed.ts[47-54]
- packages/db/src/schema/auth.ts[18-23]
- packages/db/src/schema/profile.ts[10-14]

## Suggested fix
1) For tables that are referenced by later inserts (notably `user`), use `onConflictDoNothing({ target: <primary key> })` so only *id-based* replays are treated as no-ops.
2) (Optional but robust) Make the seed emails even less likely to collide (e.g., `seed-ada@example.org`), or add a preflight check that if `seed_user_ada` does not exist after the user insert, throw a clear error indicating an email conflict.

Example direction:
- Change the user insert to `.onConflictDoNothing({ target: user.id })` so an email collision becomes an immediate, clear unique-violation error instead of a later FK failure.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

@Willi363363

Copy link
Copy Markdown
Owner Author

Closed by the stack collapse, not abandoned.

Every commit of this pull request is in willi363/refonte via #114, which
merged the whole rewrite in one go: the stack was strictly linear, so the top
branch was an ancestor-of-nothing and a descendant-of-everything, and the merge
was a fast-forward with no conflict possible. The history keeps one commit per
step, which is what squash-per-step was there to produce.

Three commit messages were reworded on the way — a wip: type, a 74-character
subject and a (web,realtime) scope, none of which scripts/checks.sh allows.
The trees are byte-identical.

This description and its review thread stay readable here.

@Willi363363
Willi363363 deleted the feat/rewrite-phase-2-seed branch August 29, 2026 16:11
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