You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
feat(db): deterministic full-scenario seed shared by local dev and dashboard e2e #969
Most dashboard pages can't be checked locally without setting data up by hand:
bun run db:seed <WEBSITE_ID> [EVENT_COUNT] (packages/db/src/seed.ts) writes only events, outgoing_links, error_spans, and web_vitals_spans. It creates no custom events, revenue, profiles, or uptime history, and no Postgres rows, so goals, funnels, flags, monitors, alarms, and links pages stay empty.
Paths are picked at random per event, so any funnel or goal defined on top of the data shows near-zero, meaningless conversion.
It never calls faker.seed, so every run produces different data and screenshots can't be compared.
Dashboard e2e has a second, separate generator in apps/dashboard/app/api/test/e2e/clickhouse/route.ts, so the two drift independently.
Proposal
One deterministic scenario engine in @databuddy/db, used by both the CLI and e2e:
bun run db:seed --scenario saas --email you@example.com [--size small|large] [--days 90] [--seed 42] [--reset]
Ownership: attaches a "Demo" organization and website to an existing local user found by email, so it never bypasses Better Auth. The current db:seed <WEBSITE_ID> [EVENT_COUNT] form keeps working.
A coherent story: a fictional SaaS whose sessions follow a journey model (landing → pricing → signup → onboarding → upgrade) with realistic drop-off, so seeded goals and funnels show real conversion. On top of that:
matching custom events and revenue for upgrades
identified profiles
an error spike after a deploy annotation
web vitals per page
an uptime monitor with history and one incident, plus alarms
flags, links, and AI crawler traffic
Relative to now: dates are anchored to the current time so default date ranges always show data, and a fixed --seed gives identical output across runs.
Safe: refuses to run unless DATABASE_URL and CLICKHOUSE_URL point at a loopback host, like packages/db/src/test-env.ts does.
Can't rot silently: inserts are typed against the Drizzle schema and ClickHouse row types, and an integration test runs the small scenario against the test databases in CI.
Delivery plan
Engine plus web analytics: events, custom events, goals, funnels, revenue, profiles, errors, vitals.
Uptime, alarms, status page, links, and flags.
Point the e2e ClickHouse route at the engine and delete its generator.
Questions
Is SaaS the right story, or would you rather have e-commerce?
Are two sizes (small for UI work, large for query performance) worth it, or is one enough?
Should step 3 happen at all, or do you want e2e data kept separate on purpose?
Disclosure: I used Claude Code to compare the seed and e2e generators and to draft this write-up; I verified the file references by hand.
How I plan to solve this, slice 1 (the engine plus web analytics):
Layout: packages/db/src/seed/
scenario.ts: a journey model for a fictional SaaS. Sessions move between states (landing → pricing → signup → onboarding → first report → upgrade) with per-step continue probabilities, plus acquisition-source mix, device/geo pools, and weekday seasonality.
postgres.ts: typed Drizzle inserts for the demo organization, member, website, goals, funnelDefinitions, and annotations.
clickhouse.ts: events, custom_events, revenue, error_spans, web_vitals_spans, and outgoing_links rows generated from the same sessions, so goals, funnels, revenue, and errors all describe one consistent story, including an error spike right after a "deploy" annotation.
It uses faker.seed(seed) and a single "now" anchor, so repeated runs produce identical data that always lands in default date ranges.
--reset removes only the demo website's rows.
The existing db:seed <WEBSITE_ID> [EVENT_COUNT] form keeps working.
It refuses to run unless DATABASE_URL and CLICKHOUSE_URL point at a loopback host.
Test
An integration test seeds the small scenario into the test databases, then runs the real queries: processFunnelAnalytics must show conversion inside the scenario's expected band, goal completions must match the seeded journey, and ClickHouse revenue must equal the seeded upgrades.
That checks the scenario is coherent, not just that rows exist.
packages/db gets a test:integration script, so the repo's turbo test:integration task runs it in CI and the seed can't rot.
Slice 2 adds uptime history in uptime.uptime_monitor with an incident, alarms, a status page, links with link_visits, flags, and AI crawler traffic. Slice 3 points apps/dashboard/app/api/test/e2e/clickhouse/route.ts at the engine and deletes its generator.
Attaching to an existing user is required, not just simpler.@databuddy/auth depends on @databuddy/db, so a seed engine inside packages/db can't call Better Auth to create users. It attaches the demo organization to an account that already signed up locally.
--reset has to account for materialized views. Five views feed three tables:
daily_pageviews, a SummingMergeTree: re-inserting after deleting only from events would double every total
identity_anon_pairs and identity_session_pairs, plain MergeTree fed from both events and custom_events: the seeded profiles land here, and stale pairs would otherwise stick around
So --reset deletes the demo website in Postgres (cascading its goals, funnels and annotations) and seeds under a fresh website id, rather than running slow ALTER TABLE ... DELETE mutations across every table. The unreachable old rows can be dropped later with an optional --purge. The e2e generator never sets profile_id, so its current cleanup doesn't need the identity tables.
Activity
FindMalek commentedon Sep 28, 2026
How I plan to solve this, slice 1 (the engine plus web analytics):
Layout:
packages/db/src/seed/scenario.ts: a journey model for a fictional SaaS. Sessions move between states (landing → pricing → signup → onboarding → first report → upgrade) with per-step continue probabilities, plus acquisition-source mix, device/geo pools, and weekday seasonality.postgres.ts: typed Drizzle inserts for the demo organization,member, website,goals,funnelDefinitions, andannotations.clickhouse.ts:events,custom_events,revenue,error_spans,web_vitals_spans, andoutgoing_linksrows generated from the same sessions, so goals, funnels, revenue, and errors all describe one consistent story, including an error spike right after a "deploy" annotation.Command:
bun run db:seed --scenario saas --email you@example.com [--size small|large] [--days 90] [--seed 42] [--reset]faker.seed(seed)and a single "now" anchor, so repeated runs produce identical data that always lands in default date ranges.--resetremoves only the demo website's rows.db:seed <WEBSITE_ID> [EVENT_COUNT]form keeps working.DATABASE_URLandCLICKHOUSE_URLpoint at a loopback host.Test
processFunnelAnalyticsmust show conversion inside the scenario's expected band, goal completions must match the seeded journey, and ClickHouse revenue must equal the seeded upgrades.packages/dbgets atest:integrationscript, so the repo's turbotest:integrationtask runs it in CI and the seed can't rot.Slice 2 adds uptime history in
uptime.uptime_monitorwith an incident, alarms, a status page, links withlink_visits, flags, and AI crawler traffic. Slice 3 pointsapps/dashboard/app/api/test/e2e/clickhouse/route.tsat the engine and deletes its generator.FindMalek commentedon Sep 28, 2026
Two refinements after checking the details.
Attaching to an existing user is required, not just simpler.
@databuddy/authdepends on@databuddy/db, so a seed engine insidepackages/dbcan't call Better Auth to create users. It attaches the demo organization to an account that already signed up locally.--resethas to account for materialized views. Five views feed three tables:daily_pageviews, aSummingMergeTree: re-inserting after deleting only fromeventswould double every totalidentity_anon_pairsandidentity_session_pairs, plainMergeTreefed from botheventsandcustom_events: the seeded profiles land here, and stale pairs would otherwise stick aroundSo
--resetdeletes the demo website in Postgres (cascading its goals, funnels and annotations) and seeds under a fresh website id, rather than running slowALTER TABLE ... DELETEmutations across every table. The unreachable old rows can be dropped later with an optional--purge. The e2e generator never setsprofile_id, so its current cleanup doesn't need the identity tables.