first commit

This commit is contained in:
2026-05-29 08:20:56 +01:00
commit 949299f58d
6368 changed files with 527176 additions and 0 deletions

View File

@@ -0,0 +1,215 @@
# runner
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## 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
```ts
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
```ts
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`
```ts
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.