Files
skills/docs/persistence/database/seed/src/runner/runner.md
2026-05-29 08:20:56 +01:00

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-sort module).
  • Filtering: only includes a whitelist; exclude removes from the result; the two compose (only first, then exclude).
  • Three upsert strategies (skip, update, replace) honoured at the batch level — the runner delegates to SeedStore.findExisting, SeedStore.insert, and SeedStore.update according to the strategy and the presence of upsertKeys.
  • Batching by SeedRunConfig.batchSize, defaulting to DEFAULT_BATCH_SIZE (100). Records are passed to the store in Array.slice-derived chunks; the store reports the actual insert / update counts.
  • Dry-run mode synthesises completed results 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" and error: "Dependency failed" rather than attempted.
  • AbortSignal plumbing: the signal is checked between seeds and forwarded into every SeedStore call. Aborted runs return success: false and mark every remaining seed skipped with error: "Aborted".
  • Injectable LoggerLike (@suaveplan/types) with a no-op default. Emits structured records at debug / info / warn / error levels for every observable transition.
  • Store-error wrapping: exceptions thrown by SeedStore methods are wrapped as SeedError with code SEED.STORE_ERROR and the original exception attached on error.cause. SeedResult.error carries 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.