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,123 @@
# execute
## Purpose
The `executeSeed` function applies one `SeedDefinition` to one
`SeedStore`, returning a single `SeedResult`. It is the per-seed body
that `SeedRunner.run` invokes inside its topo-ordered loop. The
function is split out of `runner.ts` so the orchestration class can
focus on dependency ordering, abort handling, and logging while the
batching, upsert-strategy, and error-wrapping mechanics live here in
their own file under the 250-line soft limit.
The split is purely organisational: callers do not import
`executeSeed` directly — the public entry point is `SeedRunner.run`,
which composes this function with the registry, topological sort,
logger, and abort signal. The function is exported as a named symbol
from the same `runner/` folder so tests can target either layer
independently when that helps with isolation.
## Features
- Resolves `SeedDefinition.data` (array or async factory) into a
concrete record array.
- Honours `dryRun` — returns a synthesised `completed` result with the
resolved record count and zero IO against the store.
- Calls `store.truncate(seed.table, signal)` first when
`seed.truncate` is true.
- Batches the records by `config.batchSize ?? DEFAULT_BATCH_SIZE`.
- Applies the requested upsert strategy per batch:
- `skip` with non-empty `upsertKeys`: filters out records whose key
tuple already exists in the store.
- `update` with non-empty `upsertKeys`: inserts absent records and
updates the rest in two store calls.
- `replace` or empty `upsertKeys`: inserts every record directly.
- Wraps every store exception as
`SeedError({ code: SEED.STORE_ERROR, cause })` so the runner
surfaces a stable code while preserving the original via the
cause-chain.
- Threads `AbortSignal` from the runner into every store call.
## Usage: Basic
```ts
import { executeSeed } from "./execute.js";
import { createInMemoryStore } from "../fixtures/fixtures.js";
const store = createInMemoryStore();
const result = await executeSeed(
store,
{ name: "users", table: "u", data: [{ id: 1 }] },
{ environment: "dev" },
);
console.log(result.status, result.recordsInserted);
```
## Usage: Advanced
```ts
import { executeSeed } from "./execute.js";
const controller = new AbortController();
const result = await executeSeed(
productionStore,
{
name: "users",
table: "user",
data: async () => fetchFromCsv("/users.csv"),
upsertStrategy: "update",
upsertKeys: ["tenantId", "email"],
truncate: false,
},
{
environment: "production",
batchSize: 500,
signal: controller.signal,
},
);
if (result.status === "failed") {
console.error("seed", result.name, "failed:", result.error);
}
```
## API Reference
### `async function executeSeed(store, seed, config): Promise<SeedResult>`
Parameters:
| Name | Type | Description |
| -------- | ---------------- | ------------------------------------------------- |
| `store` | `SeedStore` | Adapter the function calls into. |
| `seed` | `SeedDefinition` | The seed to apply. |
| `config` | `SeedRunConfig` | Run-scoped options (batch size, dry-run, signal). |
Returns `Promise<SeedResult>` — always resolves; failures are captured
on the result rather than thrown. The result's `status` is
`"completed"` on success and `"failed"` when the store rejects;
`recordsInserted` / `recordsUpdated` / `recordsSkipped` reflect the
partial progress at the point of failure.
## Implementation Notes
A single private `processBatch` helper handles the three upsert
strategies so the main `try/catch` block stays short. Empty batches
take the explicit `: 0` ternary branch on both the `insert` and
`update` calls — that branch fires when every record in the batch
already exists (skip strategy) or when there are no inserts or updates
to perform (update strategy with all-existing or all-fresh inputs).
The `buildKey` helper collapses missing or `null` key-field values via
`record[k] ?? ""`. Records sharing the same key tuple — including
those whose key fields are all absent — are deduplicated by the
strategy, which matches the upstream `@suaveplan/server/seed`
behaviour exactly. Adapters that need strict null handling should
provide their own `findExisting` semantics rather than rely on the
runner's key collapse.
Errors that are already `instanceof SeedError` pass through
untouched — useful when downstream code already wraps its failures
into the `SEED.*` code space. Anything else is wrapped with
`SEED.STORE_ERROR` and the original captured on `cause`; the
human-readable message is stored on `SeedResult.error`.

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.