first commit
This commit is contained in:
123
docs/persistence/database/seed/src/runner/execute.md
Normal file
123
docs/persistence/database/seed/src/runner/execute.md
Normal 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`.
|
||||
215
docs/persistence/database/seed/src/runner/runner.md
Normal file
215
docs/persistence/database/seed/src/runner/runner.md
Normal 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.
|
||||
Reference in New Issue
Block a user