Seeding Guide
This guide covers filling a running platform with data — for a demo, for manual exploration, or as an end-to-end smoke test. It is for application developers who have a platform running locally and are looking at nine empty grids.
The online-shop-hybrid example is the running illustration; its demo data is a
shareable seed-data package, and each platform (platform-local,
platform-aws) has a thin entry script. Both are built on
@reventlessdev/reventless-seed,
with AWS connect in
@reventlessdev/reventless-seed-aws.
1. Why an empty store is a problem
Auto UI generates a page per queryable component. Before there is data, every one of them renders an empty grid — and an empty grid is indistinguishable from a broken projection, a mis-wired extension point, or a component that never deployed. "The component is broken" and "there is no data" look the same.
Seeding removes that ambiguity. Everything else in this guide follows from wanting to keep it removed.
2. Drive the command API, not the store
The tempting shortcut is to write events straight into the log, or rows into the query database. It is faster to write and it ages badly:
- it can produce states no command could ever create, so the UI ends up displaying situations the domain considers impossible;
- it keeps working after the command that used to produce those states is renamed or deleted, so the seed silently drifts away from the domain;
- it skips projections, the append path, extension points and automations — the parts most likely to actually be broken.
Sending real commands through the public GraphQL API costs a little more and buys
all three back. A seed run becomes a smoke test: in the hybrid example, seeding
150 orders exercises the DCB append path, both extension points, the
AutoShipOrder automation, and every projection, because the data has to travel
those paths to exist at all.
It also means no privileged back door has to exist for seeding.
3. Split the seed by coupling
Seeding is domain-coupled by design — that is the point. Concentrate the coupling in a shareable data package, and keep the per-provider entries thin:
| Part | Holds | Example |
|---|---|---|
| Data | What to seed: literal data and the generation that turns it into entities | seed-data/src/DemoData.res |
| Adapter | How your command values map onto mutation fields and arguments | seed-data/src/DemoCommands.res |
| Data sets | Phases, view verification, summary — exported as Seed.dataSet values | seed-data/src/HybridSeedData.res |
| Entry | Pick a set, connect, seed — one line per provider | platform-*/src/Seed{Local,Aws}.res |
Everything domain-agnostic — transport, prompts, the connection, deterministic
randomness, view checks, failure reporting — comes from the reventless-seed
package (AWS connect from reventless-seed-aws) and needs no per-project code.
Because the data sets are a package rather than app-local code, a second platform
— or another repository consuming the same published plugins — imports the same
sets instead of copying them.
The adapter is where type safety lands
Build commands as real plugin command values, then pattern-match them in the adapter:
let addProduct = (command: CatalogPlugin.AddProduct.command): Seed.mutation =>
switch command {
| AddProduct({productId, name, description, price, categoryId}) =>
Seed.mutation(
"Catalog_AddProduct",
[
("productId", Id(productId)),
("name", String(name)),
("description", String(description)),
("price", Float(price)),
("categoryId", Id(categoryId)),
],
)
}
Rename a field on the command, and this stops compiling. Compare that with a seed built from hand-written GraphQL strings, which compiles happily and fails mid-run, leaving a half-seeded store behind.
The same trick catches commands that should not be seeded at all. A @noApi
variant has no mutation field to encode to, so the adapter can say so explicitly:
| ReopenOrder(_) =>
throw(Seed.Failed("ReopenOrder is @noApi — it cannot be seeded through the GraphQL API"))
4. Make it deterministic
Use a fixed seed and fixed literal data. No Date.now(), no unseeded randomness:
let random = ReventlessSeed.Seed.Random.make(~seed=0x5eed)
Two runs against a fresh store then produce byte-identical rows, which is what makes a seeded store usable as a baseline — for screenshots, for comparing two branches, or for a future visual regression check. Determinism is per implementation: changing how the generator is consumed changes the dataset, so verify by resetting and re-running, then diffing a dump of every view.
5. Shape the data deliberately
Volume alone demonstrates nothing. A board with every card in one column, a leaderboard with a flat line, or a dashboard reading zero everywhere is as uninformative as an empty grid. Decide the shape:
- Status mixes that read on a board. Check what your domain actually allows. If an automation moves every entity out of its initial state immediately, that state is unreachable and no seed can show it — the fix is in the domain, not the seeder.
- Head-and-tail distributions.
Seed.Random.zipfWeightsover a shuffled array gives a clear leader and a long tail, so a top-N view looks like a real leaderboard. - Long-tailed numerics. A log-uniform price spread reads better than a linear one, which clusters in the middle of its range.
- Some post-creation churn. Rename, reprice, archive a few entities so views are not uniformly "created once and never touched".
6. Verify what you produced
Declare every queryable view, and split them by whether the seed can fill them:
let views = [
Seed.Runner.Seeded("Catalog_Products"),
Seed.Runner.Seeded("Ordering_Orders"),
Seed.Runner.Unfillable(
"Ordering_SendOrderConfirmationTodos",
"that OutboundTranslationSlice does not run on the local platform",
),
]
let counts = await Seed.Runner.verifyViews(client, ~views)
Seed.Runner.warn(Seed.Runner.unfillableWarnings(~views, ~counts))
An unexpectedly empty Seeded view fails the run — it means the seed missed a
component, which is exactly the ambiguity you are trying to remove. Unfillable
is not a way to silence that: it records a view no volume of data can reach,
along with the reason, so a zero there is never misread as a gap. If you find
yourself adding Unfillable entries, you have found a defect worth writing down.
7. Fail loudly, recover by resetting
A GraphQL error or an unexpected rejection aborts the run and prints the offending mutation with the response. Half-seeded data is worse than none, because it looks like a working dataset.
There is deliberately no idempotence logic. Seed against a fresh store:
pnpm run serve:reset # in one shell
pnpm run seed # in another
Locally that reset is serve:reset (a fresh in-memory / SQLite store). On a
deployed AWS stack, emptying the durable stores is seed:reset from
platform-aws/ — the inverse of seeding, and fail-closed so it cannot be fired
against the wrong stack:
- a deployment is usually several Pulumi projects sharing a stack name (a platform
plus one per domain plugin), so the reset first asks which scope to empty:
domain(all plugins — the default, and the normal choice, since it leaves the platform's plugin registry intact so a re-seed just works), a single plugin,platform, oreverything.SEED_RESET_SCOPEpicks it non-interactively; - it refuses any stack not named
alpha,dev, orpr-*, and any project whosePulumi.<stack>.yamldoes not declarereventless:wipeable: "true"; - it discovers what to empty only through the
reventless:platform+reventless:environmenttags every framework resource carries (scoped to each chosen project's stack — the stack name alone can collide across projects), and re-checks both per resource before deleting; - it is dry-run by default; to actually wipe, re-type the stack name at the
prompt (interactively), or set
REVENTLESS_WIPE_CONFIRM=<stack>when there is no TTY (CI), then it re-counts every store to0.
cd platform-aws
pnpm run seed:reset # pick a scope, then type the stack name to confirm
SEED_RESET_SCOPE=domain REVENTLESS_WIPE_CONFIRM=alpha pnpm run seed:reset # non-interactive
The reset authenticates to AWS with the ambient credential chain (env / profile /
SSO) — not the Cognito login the seed uses — so it prompts for no username or
password. Every refusal names its cause — an off-allowlist name, a missing
wipeable declaration, an unreadable stack config — so a rejected reset says
exactly why.
8. Pitfalls
Four things that are easy to get wrong and quiet when you do:
- GraphQL enums are bare identifiers.
shippingMethod: Express, notshippingMethod: "Express"— a quoted enum is rejected. UseSeed.Enum. - Connections page at 50. Any count taken from a single request silently
truncates. Use
queryAllNodes, which walks the cursors, before believing a total. - Cross-plugin propagation is asynchronous. Events travelling extension point
→ extension → command have not landed when the originating mutation returns. A
command depending on the result will be rejected. Use
Seed.Client.waitForIdsto wait on the observable result rather than sleeping for a guessed interval. - Seeding is in the review scope of command changes. With typed commands the build tells you, but only if the seeder is built — keep it in CI or in the workspace build.
9. Where to put it
Put the domain data in a shareable package (the hybrid example's seed-data), and
give each platform a thin entry that names the provider — the provider is where
the script runs, not a prompt. Each is wired as a seed script:
// platform-local/package.json
{ "scripts": { "seed": "node src/SeedLocal.res.mjs" } }
// platform-aws/package.json
{ "scripts": { "seed": "node src/SeedAws.res.mjs" } }
Each entry is a single call — pick a set, connect, seed:
// SeedLocal.res
Seed.Runner.seed(~sets=HybridSeedData.dataSets, ~connect=Seed.Connect.local())
// SeedAws.res
Seed.Runner.seed(~sets=HybridSeedData.dataSets, ~connect=ReventlessSeedAws.connect())
If more than one data set exists the runner prompts which to seed (or SEED_SET
selects one). Override the local endpoints with REVENTLESS_GRAPHQL_ENDPOINT /
REVENTLESS_LOGIN_ENDPOINT; nothing in the harness assumes a local runtime.
Who it logs in as. Both providers take their accounts from the platform's
.reventless/users.yaml — the file the local auth adapter already loads, and the
record of the Cognito accounts on AWS. The run lists its entries in file order
with the first as the default and takes the password from the chosen entry, so
nothing is typed; SEED_USER picks by username or 1-based index and
SEED_USERS_FILE points elsewhere. It matters which one you pick: owner-scoped
rows belong to whoever seeded them. A platform with no such file falls back to
prompting for a username and password, and REVENTLESS_DEMO_USER +
REVENTLESS_DEMO_PASSWORD together bypass both — the CI path.
Related
- Generated GraphQL API Guide — the mutation names and argument shapes an adapter encodes to
- Writing Unit Tests — component-level testing, which a seed run complements rather than replaces
- Build your own app