8.8 KiB
runner
(no submodules)
Purpose
The SeedRunner orchestrates a single seeding run against an injected
SeedStore. It is the public entry point for actually applying a set of
declarative SeedDefinition objects: it picks the seeds that apply to
the requested environment, filters them by the caller's only /
exclude lists, topologically sorts the survivors, and executes each in
turn with the configured upsert strategy, batching, dry-run, and
cancellation behaviour.
The runner is intentionally store-agnostic. It does not know about
SurrealDB, Postgres, or any particular driver — it just calls the
SeedStore interface declared in types/common.ts. That keeps the core
universal (no node: / bun: imports) and lets the same runner
drive in-memory tests, local containers, and production databases
without rewriting the orchestration layer.
Features
- Dependency-ordered execution via Kahn's-algorithm topological sort
(uses the sibling
utils/topo-sortmodule). - Filtering:
onlyincludes a whitelist;excluderemoves from the result; the two compose (onlyfirst, thenexclude). - Three upsert strategies (
skip,update,replace) honoured at the batch level — the runner delegates toSeedStore.findExisting,SeedStore.insert, andSeedStore.updateaccording to the strategy and the presence ofupsertKeys. - Batching by
SeedRunConfig.batchSize, defaulting toDEFAULT_BATCH_SIZE(100). Records are passed to the store inArray.slice-derived chunks; the store reports the actual insert / update counts. - Dry-run mode synthesises
completedresults without invoking the store — useful for CI smoke tests and for validating environment filters. - Dependency-aware skipping: when a seed fails, every dependent in
the topo-order is recorded with
status: "skipped"anderror: "Dependency failed"rather than attempted. AbortSignalplumbing: the signal is checked between seeds and forwarded into everySeedStorecall. Aborted runs returnsuccess: falseand mark every remaining seedskippedwitherror: "Aborted".- Injectable
LoggerLike(@suaveplan/types) with a no-op default. Emits structured records atdebug/info/warn/errorlevels for every observable transition. - Store-error wrapping: exceptions thrown by
SeedStoremethods are wrapped asSeedErrorwith codeSEED.STORE_ERRORand the original exception attached onerror.cause.SeedResult.errorcarries the human-readable message.
Usage: Basic
import {
createInMemoryStore,
defineSeed,
SeedRegistry,
SeedRunner,
} from "@suaveplan/seed";
const registry = new SeedRegistry();
registry.register(
defineSeed("users")
.data([{ id: 1, name: "alice" }])
.upsert("skip", ["id"])
.build()
);
const store = createInMemoryStore();
const runner = new SeedRunner(registry, store);
const result = await runner.run({ environment: "dev" });
console.log(result.success, result.seedsRun);
Usage: Advanced
import {
createInMemoryStore,
defineSeed,
SeedRegistry,
SeedRunner,
} from "@suaveplan/seed";
import type { LoggerLike } from "@suaveplan/types";
const registry = new SeedRegistry();
registry.registerMany([
defineSeed("tenants")
.environments("staging", "production")
.data(async () => fetchTenantsFromCsv())
.upsert("update", ["slug"])
.priority(0)
.build(),
defineSeed("admin-users")
.dependsOn("tenants")
.data(adminUsers)
.upsert("update", ["tenantId", "email"])
.priority(10)
.build(),
defineSeed("audit-buckets")
.dependsOn("tenants")
.truncateFirst()
.data([])
.priority(20)
.build(),
]);
const logger: LoggerLike = createStructuredLogger();
const runner = new SeedRunner(registry, productionStore, logger);
const controller = new AbortController();
setTimeout(() => controller.abort(), 30_000);
const result = await runner.run({
environment: "staging",
only: ["tenants", "admin-users"],
batchSize: 500,
continueOnError: false,
signal: controller.signal,
onProgress: (r) => logger.info("seed.progress", {
name: r.name,
status: r.status,
duration: r.duration,
}),
});
if (!result.success) {
throw new Error(
`Seed run failed: ${result.seedsFailed} failed, ${result.seedsSkipped} skipped`,
);
}
API Reference
class SeedRunner implements ISeedRunner
constructor(
registry: SeedRegistry,
store: SeedStore,
logger?: LoggerLike,
)
run(config: SeedRunConfig): Promise<SeedRunResult>
SeedRunConfig
| Field | Type | Default |
|---|---|---|
environment |
SeedEnvironment |
(required) |
only? |
readonly string[] |
undefined (all seeds) |
exclude? |
readonly string[] |
undefined |
dryRun? |
boolean |
false |
batchSize? |
number |
DEFAULT_BATCH_SIZE |
continueOnError? |
boolean |
false |
onProgress? |
(r: SeedResult) => void |
undefined |
signal? |
AbortSignal |
undefined |
SeedRunResult
| Field | Type | Meaning |
|---|---|---|
environment |
SeedEnvironment |
The environment the run was scoped to |
totalDuration |
number |
Milliseconds via performance.now() |
results |
readonly SeedResult[] |
One entry per attempted (or skipped) seed |
success |
boolean |
True iff zero failures AND not aborted |
seedsRun |
number |
Results with status other than "skipped" |
seedsSkipped |
number |
Results with status "skipped" |
seedsFailed |
number |
Results with status "failed" |
Logger events
| Event | Level | Fields |
|---|---|---|
seed.run.preparing |
debug | environment, filteredCount, orderedCount |
seed.run.start |
info | environment, totalSeeds |
seed.completed |
info | name, table, recordsInserted, recordsUpdated, recordsSkipped, duration |
seed.skipped |
warn | name, table, reason ("Dependency failed" | "Aborted") |
seed.failed |
error | name, table, error, duration |
seed.run.complete |
info | environment, success, seedsRun, seedsSkipped, seedsFailed, totalDuration |
Implementation Notes
The actual batched insert/update/findExisting work lives in a sibling
execute.ts module so this file stays under the 250-line soft limit.
The split is purely organisational: executeSeed(store, seed, config)
is invoked from SeedRunner.run and returns a single SeedResult per
seed. Errors thrown by the store inside executeSeed are wrapped as
SeedError({ code: SEED.STORE_ERROR, cause }); the runner records the
message string on SeedResult.error while the structured fields
(code, seedName, cause) remain reachable to consumers that catch
the wrapped error from higher up.
SeedRunner keeps the abort-on-signal check at the top of the
execution loop so a signal raised between seeds short-circuits before
the next seed's data factory is even invoked. AbortSignal is also
forwarded into every SeedStore method via the optional signal?
parameter on the interface, letting network-bound adapters honour
cancellation mid-batch. The InMemorySeedStore shipped in
fixtures/fixtures.ts accepts but ignores the signal — it has nothing
to cancel.
Defensive if (!seed) continue; checks against seedMap.get(name)
were removed: order and seedMap are both built from the same
filtered array, so every name topoSort returns is guaranteed to
resolve. The TypeScript assertion (as SeedDefinition) records that
invariant without the false-positive coverage gap a runtime check would
introduce.