first commit
This commit is contained in:
189
docs/persistence/database/seed/README.md
Normal file
189
docs/persistence/database/seed/README.md
Normal file
@@ -0,0 +1,189 @@
|
||||
# @suaveplan/seed - Universal declarative database seeding and fixture framework: SeedBuilder fluent API, SeedRegistry with environment filters, SeedRunner with topological ordering, upsert strategies, batching, dry-run, AbortSignal, injectable LoggerLike, FixtureFactory, InMemorySeedStore, store-agnostic.
|
||||
|
||||
Universal declarative database seeding and fixture framework. Define seed data once, let the runner handle dependency ordering, environment filtering, idempotent upserts, batching, dry-runs, and clean cancellation. The runner is store-agnostic: any object satisfying the `SeedStore` interface plugs in, so the same seed definitions drive in-memory tests, local SurrealDB instances, and production databases without rewriting the data layer.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [builder](./src/builder/builder.md) — `SeedBuilder` is the fluent, chainable API used to construct immutable `SeedDefinition` records. It exists so that call…
|
||||
- [builder](./src/builder/builder.md) — `SeedBuilder` is the fluent, chainable API used to construct immutable `SeedDefinition` records. It exists so that call…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — The `@suaveplan/seed` package centralises every failure mode it can produce behind a single registry built with `define…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — The `@suaveplan/seed` package centralises every failure mode it can produce behind a single registry built with `define…
|
||||
- [fixtures](./src/fixtures/fixtures.md) — The `fixtures` module supplies the two universal building blocks used to exercise seed definitions without a real…
|
||||
- [fixtures](./src/fixtures/fixtures.md) — The `fixtures` module supplies the two universal building blocks used to exercise seed definitions without a real…
|
||||
- [registry](./src/registry/registry.md) — `SeedRegistry` is the canonical in-memory store for `SeedDefinition` entries used by `@suaveplan/seed`. It owns inserti…
|
||||
- [registry](./src/registry/registry.md) — `SeedRegistry` is the canonical in-memory store for `SeedDefinition` entries used by `@suaveplan/seed`. It owns inserti…
|
||||
- [runner](./src/runner/runner.md) — The `SeedRunner` orchestrates a single seeding run against an injected `SeedStore`. It is the public entry point for ac…
|
||||
- [execute](./src/runner/execute.md) — The `executeSeed` function applies one `SeedDefinition` to one `SeedStore`, returning a single `SeedResult`. It is the…
|
||||
- [runner](./src/runner/runner.md) — The `SeedRunner` orchestrates a single seeding run against an injected `SeedStore`. It is the public entry point for ac…
|
||||
- [types](./src/types/common.md) — Shared type module for `@suaveplan/seed`. Every public class and function in the package — `SeedBuilder`, `SeedRegistry…
|
||||
- [common](./src/types/common.md) — Shared type module for `@suaveplan/seed`. Every public class and function in the package — `SeedBuilder`, `SeedRegistry…
|
||||
- [topo-sort](./src/utils/topo-sort/topo-sort.md) — `topo-sort` is the dependency-resolution engine that lets `@suaveplan/seed` execute seed definitions in an order where…
|
||||
- [topo-sort](./src/utils/topo-sort/topo-sort.md) — `topo-sort` is the dependency-resolution engine that lets `@suaveplan/seed` execute seed definitions in an order where…
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Ships a handful of database adapters (`db`, `db-sqlite`, `surrealdb`, `migration`) but no consistent way to populate them. Hand-rolled per-package seeders drift in shape, swallow errors, skip dependency ordering, and re-insert duplicates on every run. `@suaveplan/seed` consolidates the orchestration concerns — registration, topological execution, environment scoping, batched upserts, dry-runs, progress reporting, and cancellation — behind one pluggable contract. The package is intentionally universal: zero `node:`/`bun:` imports, no IO of its own, and a single runtime dependency on `@suaveplan/error`. Adapters that need a production store ship in dedicated `seed-store-*` packages and implement the `SeedStore` interface; the core runs unchanged in tests, server processes, and worker contexts.
|
||||
|
||||
## Features
|
||||
|
||||
- **Fluent `SeedBuilder` + `defineSeed(name)` convenience** — chainable API for declaring tables, dependencies, environments, upsert strategies, priorities, and per-seed truncation
|
||||
- **`SeedRegistry`** — duplicate-name detection (typed `SEED.DUPLICATE_NAME`), defensive-copy reads, environment-aware queries
|
||||
- **`SeedRunner`** — Kahn's-algorithm topological sort with priority tiebreak, `only`/`exclude` filtering, three upsert strategies (`skip`/`update`/`replace`), configurable batching, `dryRun` mode, `continueOnError`, per-seed `onProgress` callback
|
||||
- **`AbortSignal` plumbing** — abort mid-run; remaining seeds are reported as `skipped` with `error: "Aborted"`
|
||||
- **Injectable `LoggerLike`** (`@suaveplan/types`) — structured events (`seed.run.start`, `seed.completed`, `seed.skipped`, `seed.failed`, `seed.run.complete`); no-op default keeps the runtime silent when unwired
|
||||
- **`FixtureFactory<T>`** — deterministic per-record overrides with auto-incrementing index, `createMany`, `reset`
|
||||
- **`InMemorySeedStore`** — full `SeedStore` shape with `getRecords`/`clear` test affordances
|
||||
- **`SeedError` + `SEED_CODES` registry** — every public failure carries a stable `SEED.*` code and threads `cause` per the `@suaveplan/error` contract
|
||||
- **Tree-shakeable subpath exports** — import `@suaveplan/seed/runner/runner` without pulling fixtures
|
||||
- **100% statement/branch/function/line coverage** via Istanbul
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
defineSeed,
|
||||
SeedRegistry,
|
||||
SeedRunner,
|
||||
createInMemoryStore,
|
||||
} from "@suaveplan/seed";
|
||||
|
||||
const registry = new SeedRegistry();
|
||||
|
||||
registry.register(
|
||||
defineSeed("users")
|
||||
.table("user")
|
||||
.data([
|
||||
{ id: 1, name: "alice" },
|
||||
{ id: 2, name: "bob" },
|
||||
])
|
||||
.upsert("skip", ["id"])
|
||||
.build(),
|
||||
);
|
||||
|
||||
registry.register(
|
||||
defineSeed("posts")
|
||||
.dependsOn("users")
|
||||
.data([
|
||||
{ id: 1, authorId: 1, title: "Hello" },
|
||||
{ id: 2, authorId: 2, title: "World" },
|
||||
])
|
||||
.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, result.seedsSkipped);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
defineSeed,
|
||||
SeedRegistry,
|
||||
SeedRunner,
|
||||
type LoggerLike,
|
||||
} from "@suaveplan/seed";
|
||||
import { mySurrealStore } from "./my-surreal-store";
|
||||
|
||||
const registry = new SeedRegistry();
|
||||
|
||||
registry.registerMany([
|
||||
defineSeed("tenants")
|
||||
.environments("dev", "staging")
|
||||
.data(async () => fetchTenantsFromCsv())
|
||||
.upsert("update", ["slug"])
|
||||
.priority(0)
|
||||
.build(),
|
||||
defineSeed("admin-users")
|
||||
.dependsOn("tenants")
|
||||
.dataFactory(async () => seedFactoryFor(currentTenant))
|
||||
.upsert("update", ["tenantId", "email"])
|
||||
.priority(10)
|
||||
.build(),
|
||||
defineSeed("audit-log-buckets")
|
||||
.dependsOn("tenants")
|
||||
.truncateFirst()
|
||||
.data([])
|
||||
.priority(20)
|
||||
.build(),
|
||||
]);
|
||||
|
||||
const logger: LoggerLike = createStructuredLogger();
|
||||
const runner = new SeedRunner(registry, mySurrealStore, logger);
|
||||
|
||||
const controller = new AbortController();
|
||||
setTimeout(() => controller.abort(), 30_000); // hard 30s budget
|
||||
|
||||
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 }),
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(
|
||||
`Seed run failed: ${result.seedsFailed} failed, ${result.seedsSkipped} skipped`,
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### Builder
|
||||
|
||||
- `defineSeed(name: string): SeedBuilder` — convenience factory
|
||||
- `class SeedBuilder` — chainable: `table`, `environments`, `dependsOn`, `data`, `dataFactory`, `upsert`, `description`, `priority`, `truncateFirst`, terminal `build()`
|
||||
|
||||
### Registry
|
||||
|
||||
- `class SeedRegistry` — `register` / `registerMany` / `get` / `getAll` / `getForEnvironment(env)` / `has` / `remove` / `clear` / `size` / `names`. Raises `SeedError({ code: SEED.DUPLICATE_NAME })` on collision.
|
||||
|
||||
### Runner
|
||||
|
||||
- `class SeedRunner(registry, store, logger?)` — `async run(config: SeedRunConfig): Promise<SeedRunResult>`
|
||||
- `SeedRunConfig`: `environment`, `only?`, `exclude?`, `dryRun?`, `batchSize?`, `continueOnError?`, `onProgress?`, `signal?`
|
||||
- `SeedRunResult`: `environment`, `totalDuration`, `results`, `success`, `seedsRun`, `seedsSkipped`, `seedsFailed`
|
||||
- `SeedResult`: `name`, `table`, `status`, `recordsInserted`, `recordsUpdated`, `recordsSkipped`, `duration`, `error?`
|
||||
|
||||
### Fixtures
|
||||
|
||||
- `class FixtureFactory<T extends SeedRecord>` + `createFixtureFactory(defaults)` — `create(overrides?)`, `createMany(count, overrides?)`, `reset()`, `counter`
|
||||
- `class InMemorySeedStore` + `createInMemoryStore()` — full `SeedStore` shape plus `getRecords(table)` / `clear()`
|
||||
|
||||
### Utils
|
||||
|
||||
- `topoSort(seeds): string[]` — Kahn's algorithm with priority tiebreak; raises `SEED.UNREGISTERED_DEPENDENCY` / `SEED.CIRCULAR_DEPENDENCY` / `SEED.DEPENDENCY_DEPTH_EXCEEDED`
|
||||
- `detectCycles(seeds): string[]` — DFS cycle reporter; returns `["a -> b -> a", ...]`; empty array on clean graph
|
||||
|
||||
### Errors
|
||||
|
||||
- `SeedError extends SuaveplanError<SeedErrorCode>` with optional `seedName` / `dependency` discriminants
|
||||
- `SEED_CODES`: `DUPLICATE_NAME`, `UNREGISTERED_DEPENDENCY`, `CIRCULAR_DEPENDENCY`, `DEPENDENCY_DEPTH_EXCEEDED`, `INVALID_CONFIG`, `STORE_ERROR`, `ABORTED`
|
||||
|
||||
### Constants
|
||||
|
||||
- `DEFAULT_ENVIRONMENTS = ["dev", "staging", "test", "production"] as const`
|
||||
- `MAX_DEPENDENCY_DEPTH = 50`
|
||||
- `DEFAULT_BATCH_SIZE = 100`
|
||||
- `SEED_STATUS = { PENDING, RUNNING, COMPLETED, FAILED, SKIPPED }`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- **Universal** — no `node:`/`bun:` imports anywhere in `src/`. The only platform-aware call is `performance.now()`, which every supported runtime provides natively.
|
||||
- **Store-agnostic by design** — `SeedRunner` receives a `SeedStore` via constructor injection. The core ships only `InMemorySeedStore` (for tests); production stores live in dedicated `seed-store-*` packages.
|
||||
- **Errors thread `cause`** — store exceptions are wrapped as `SeedError({ code: SEED.STORE_ERROR, cause })` so callers can inspect the underlying error without losing the stable code.
|
||||
- **Topological sort uses Kahn's algorithm** — one clean pass, priority breaks ties for independent seeds, a defensive iteration guard catches pathological cases before they hang.
|
||||
- **AbortSignal is checked between seeds** — in-flight `SeedStore` calls receive the signal too (every `SeedStore` method accepts an optional `signal?: AbortSignal`).
|
||||
- **Logger is opt-in** — `LoggerLike` defaults to a no-op so the runner stays silent when unwired. Wire it to get `seed.run.start` / `seed.completed` / `seed.skipped` / `seed.failed` / `seed.run.complete` events.
|
||||
- **Enterprise extras are out of scope here** — plugin lifecycle, SurrealDB schema migrations, JWT/RBAC, audit logging, rate limiting, webhooks, OTel spans, and Prometheus metrics all belong to a future `api-plugin-seed` package that wraps this core.
|
||||
|
||||
92
docs/persistence/database/seed/src/builder/builder.md
Normal file
92
docs/persistence/database/seed/src/builder/builder.md
Normal file
@@ -0,0 +1,92 @@
|
||||
# SeedBuilder
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`SeedBuilder` is the fluent, chainable API used to construct immutable `SeedDefinition` records. It exists so that callers can declare seeds in a readable, self-documenting form without hand-assembling the underlying object literal and without worrying about empty-collection or unset-field noise leaking into the serialised output.
|
||||
|
||||
The builder is the single canonical entry point for authoring seed definitions: `SeedRegistry`, `SeedRunner`, and every downstream tool consume the `SeedDefinition` value it produces. By centralising construction here we keep upstream parity with the `../suaveplan/packages/server/seed` reference implementation while honouring the rule that unset optional fields must be omitted entirely from the produced definition rather than serialised as empty arrays, empty strings, or `false`.
|
||||
|
||||
## Features
|
||||
|
||||
- Chainable fluent API — every setter returns the same builder instance.
|
||||
- Default `table` derives from the seed `name` so most call-sites need only one identifier.
|
||||
- `data(records)` and `dataFactory(fn)` share a single internal slot; the most recent call wins, supporting both static record arrays and lazy async factories.
|
||||
- `upsert(strategy, keys)` co-sets the upsert strategy and key tuple atomically.
|
||||
- `truncateFirst()` defaults `enabled` to `true` so the most common opt-in stays terse.
|
||||
- `build()` produces a deeply-immutable shape: optional fields the caller never touched (`environments`, `dependencies`, `upsertKeys`, `description`, `truncate`) are omitted entirely.
|
||||
- Implements the `ISeedBuilder` interface so consumers can depend on the contract rather than the concrete class.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { defineSeed } from "@suaveplan/seed/builder/builder";
|
||||
|
||||
const usersSeed = defineSeed("users")
|
||||
.data([
|
||||
{ id: 1, email: "alice@example.com" },
|
||||
{ id: 2, email: "bob@example.com" },
|
||||
])
|
||||
.build();
|
||||
|
||||
// usersSeed.name === "users"
|
||||
// usersSeed.table === "users" (defaults to name)
|
||||
// usersSeed.upsertStrategy === "skip"
|
||||
// usersSeed.priority === 0
|
||||
// usersSeed has no environments / dependencies / upsertKeys / description / truncate fields
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { defineSeed, SeedBuilder } from "@suaveplan/seed/builder/builder";
|
||||
import type { ISeedBuilder } from "@suaveplan/seed/builder/builder";
|
||||
|
||||
function makeTenantSeed(name: string): ISeedBuilder {
|
||||
return defineSeed(name)
|
||||
.table(`tenant_${name}`)
|
||||
.environments("dev", "staging")
|
||||
.dependsOn("roles", "permissions")
|
||||
.dataFactory(async () => {
|
||||
const rows = await loadFromFixtureService(name);
|
||||
return rows.map((r, i) => ({ ...r, idx: i }));
|
||||
})
|
||||
.upsert("update", ["id"])
|
||||
.description(`Tenant records for ${name}`)
|
||||
.priority(10)
|
||||
.truncateFirst();
|
||||
}
|
||||
|
||||
const usersDef = makeTenantSeed("users").build();
|
||||
const postsDef = new SeedBuilder("posts")
|
||||
.dependsOn("users")
|
||||
.data([{ id: 1, authorId: 1, body: "hello" }])
|
||||
.upsert("skip", ["id"])
|
||||
.build();
|
||||
|
||||
declare function loadFromFixtureService(name: string): Promise<Array<Record<string, unknown>>>;
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `defineSeed(name: string): SeedBuilder` — returns a new builder seeded with `name` (also the default `table`).
|
||||
- `class SeedBuilder implements ISeedBuilder` — constructor takes the seed `name`.
|
||||
- `table(name: string): this` — overrides the default `table`.
|
||||
- `environments(...envs: SeedEnvironment[]): this` — restrict to listed environments; empty rest-args clears the field (omitted from `build()`).
|
||||
- `dependsOn(...names: string[]): this` — list seeds that must run first; empty rest-args clears the field.
|
||||
- `data(records: SeedRecord[]): this` — static record array; replaces any factory.
|
||||
- `dataFactory(fn: SeedDataFactory): this` — sync-or-async factory; replaces any static array.
|
||||
- `upsert(strategy: UpsertStrategy, keys: string[]): this` — atomic strategy + key-tuple setter.
|
||||
- `description(text: string): this` — empty string is treated as unset.
|
||||
- `priority(n: number): this` — lower priorities run earlier when no dependency edge exists.
|
||||
- `truncateFirst(enabled?: boolean): this` — default `true`; passing `false` clears the field.
|
||||
- `build(): SeedDefinition` — terminal accessor; safe to call multiple times.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The builder tracks each optional field in its own private slot and only emits it from `build()` when the slot is non-empty (`length > 0` for arrays/strings, truthy for booleans). The mandatory shape — `name`, `table`, `data`, `upsertStrategy`, `priority` — is always present, matching the upstream reference contract. Defaults: `_environments = []`, `_dependencies = []`, `_data = []`, `_upsertStrategy = "skip"`, `_upsertKeys = []`, `_description = ""`, `_priority = 0`, `_truncate = false`.
|
||||
|
||||
Because `data(...)` and `dataFactory(...)` write to the same slot, ordering matters: calling `.data([...]).dataFactory(fn)` resolves to the factory; the reverse resolves to the array. Both return `this` so the fluent chain is uninterrupted regardless of which slot wins. `build()` is a pure read; it never mutates internal state, so the same builder may produce multiple equivalent definitions or be further refined and re-built. All chainable methods return the typed `this` so subclassing preserves the fluent return type without explicit casts.
|
||||
128
docs/persistence/database/seed/src/error-codes/error-codes.md
Normal file
128
docs/persistence/database/seed/src/error-codes/error-codes.md
Normal file
@@ -0,0 +1,128 @@
|
||||
# error-codes
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
The `@suaveplan/seed` package centralises every failure mode it can produce
|
||||
behind a single registry built with `defineCodes("SEED", [...])` from
|
||||
`@suaveplan/error`. The registry is the stable contract callers depend on:
|
||||
code strings are documented, frozen at module load, and never renamed
|
||||
without an explicit migration. This module exports the `SEED_CODES`
|
||||
registry, the derived `SeedErrorCode` type, and the `SeedError` class that
|
||||
every public failure path raises.
|
||||
|
||||
`SeedError` extends `SuaveplanError<SeedErrorCode>` so `instanceof SeedError`
|
||||
narrows `code` to the exact union of `SEED.*` strings. It adds optional
|
||||
`seedName` and `dependency` discriminants that diagnose which seed (or
|
||||
which missing dependency) triggered the error, while leaving the base
|
||||
`context` map free for caller-supplied structured data. The class threads
|
||||
`cause` straight through `SuaveplanError`, so store-layer exceptions
|
||||
wrapped by the runner remain inspectable through `error.cause`.
|
||||
|
||||
## Features
|
||||
|
||||
- Frozen `SEED_CODES` registry with seven namespaced codes
|
||||
- `SeedErrorCode` literal-union type derived from the registry
|
||||
- `SeedError` class extending `SuaveplanError<SeedErrorCode>`
|
||||
- Optional `seedName` and `dependency` diagnostic discriminants
|
||||
- Full `cause`-chain preservation via `SuaveplanError`
|
||||
- Compatible with the OTel span-event adapter shipped by `@suaveplan/error`
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { SEED_CODES, SeedError } from "@suaveplan/seed";
|
||||
|
||||
if (registry.has(seed.name)) {
|
||||
throw new SeedError(SEED_CODES.DUPLICATE_NAME, `seed "${seed.name}" already registered`, {
|
||||
seedName: seed.name,
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { SEED_CODES, SeedError } from "@suaveplan/seed";
|
||||
import { SuaveplanError } from "@suaveplan/error";
|
||||
|
||||
try {
|
||||
await store.insert(seed.table, batch);
|
||||
} catch (cause) {
|
||||
throw new SeedError(SEED_CODES.STORE_ERROR, `store rejected batch for ${seed.table}`, {
|
||||
cause: cause instanceof Error ? cause : undefined,
|
||||
seedName: seed.name,
|
||||
context: { table: seed.table, batchSize: batch.length },
|
||||
});
|
||||
}
|
||||
|
||||
// Downstream:
|
||||
catch (err) {
|
||||
if (err instanceof SeedError && err.code === SEED_CODES.STORE_ERROR) {
|
||||
logger.error("seed.store.failure", {
|
||||
seedName: err.seedName,
|
||||
rootMessage: err.cause instanceof Error ? err.cause.message : String(err.cause),
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `SEED_CODES`
|
||||
|
||||
A frozen object literal of seven namespaced codes:
|
||||
|
||||
| Property | Value |
|
||||
| ----------------------------- | ---------------------------------- |
|
||||
| `DUPLICATE_NAME` | `"SEED.DUPLICATE_NAME"` |
|
||||
| `UNREGISTERED_DEPENDENCY` | `"SEED.UNREGISTERED_DEPENDENCY"` |
|
||||
| `CIRCULAR_DEPENDENCY` | `"SEED.CIRCULAR_DEPENDENCY"` |
|
||||
| `DEPENDENCY_DEPTH_EXCEEDED` | `"SEED.DEPENDENCY_DEPTH_EXCEEDED"` |
|
||||
| `INVALID_CONFIG` | `"SEED.INVALID_CONFIG"` |
|
||||
| `STORE_ERROR` | `"SEED.STORE_ERROR"` |
|
||||
| `ABORTED` | `"SEED.ABORTED"` |
|
||||
|
||||
### `type SeedErrorCode`
|
||||
|
||||
The literal union of every value in `SEED_CODES`.
|
||||
|
||||
### `class SeedError extends SuaveplanError<SeedErrorCode>`
|
||||
|
||||
| Member | Type |
|
||||
| ------------------ | ---------------------------------------------------------------------- |
|
||||
| `code` | `SeedErrorCode` (narrowed) |
|
||||
| `seedName?` | `string` |
|
||||
| `dependency?` | `string` |
|
||||
| `cause?` | `unknown` (inherited) |
|
||||
| `timestamp` | `number` (inherited; `Date.now()` at construction) |
|
||||
| `constructor(...)` | `(code, message, options?: SeedErrorOptions)` |
|
||||
|
||||
`SeedErrorOptions` extends `SuaveplanErrorOptions` (`cause`, `context`,
|
||||
`clock`, `traceId`) with the additional optional `seedName` and
|
||||
`dependency` fields.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`defineCodes` returns a `Object.freeze`d record with `null` prototype, so
|
||||
the registry cannot be mutated, reassigned, or extended at runtime — any
|
||||
attempt fails in strict mode (which TypeScript modules enforce). The
|
||||
literal type machinery preserves the exact `${Namespace}.${Reason}`
|
||||
string template across every consumer, so `switch (err.code)` enjoys
|
||||
exhaustive narrowing without runtime cost.
|
||||
|
||||
`SeedError`'s constructor passes `message` straight through to
|
||||
`SuaveplanError`'s `messageKey` parameter. The base class resolves the
|
||||
key via the i18n resolver if one has been registered with
|
||||
`setMessageResolver`, falling back to the raw string otherwise — so this
|
||||
package works without i18n wiring and lights up automatically when
|
||||
`@suaveplan/i18n` is present.
|
||||
|
||||
Optional `seedName` / `dependency` fields are stored on the error instance
|
||||
directly (not on `context`) so they survive structured-cloning and
|
||||
serialisation via the `toJSON` helper from `@suaveplan/error`. Consumers
|
||||
that programmatically dispatch on a missing dependency can read
|
||||
`err.dependency` without first parsing `err.context`.
|
||||
142
docs/persistence/database/seed/src/fixtures/fixtures.md
Normal file
142
docs/persistence/database/seed/src/fixtures/fixtures.md
Normal file
@@ -0,0 +1,142 @@
|
||||
# fixtures
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
The `fixtures` module supplies the two universal building blocks used to
|
||||
exercise seed definitions without a real database: `FixtureFactory` for
|
||||
generating deterministic, counter-stamped record sets, and `InMemorySeedStore`
|
||||
for satisfying the `SeedStore` contract entirely in process memory. Together
|
||||
they let every test in the wider ecosystem author realistic seed data and run
|
||||
the same `SeedRunner` paths a production adapter would run, without touching
|
||||
any I/O or external service. The module deliberately has zero dependencies on
|
||||
the runner, registry, or builder layers — it is a leaf consumed by tests and
|
||||
by integration harnesses that want a drop-in store.
|
||||
|
||||
## Features
|
||||
|
||||
- Auto-incrementing fixture indices via a private monotonic counter.
|
||||
- Per-record overrides for `create` and indexed overrides for `createMany`.
|
||||
- Counter reset to support deterministic test sequencing.
|
||||
- Full `SeedStore` implementation: `insert`, `update`, `findExisting`,
|
||||
`truncate`, `tableExists`.
|
||||
- Composite-key support: any number of `keyFields` joined with `"::"`.
|
||||
- Test affordances: `getRecords` returns a defensive copy, `clear` drops
|
||||
every table.
|
||||
- Optional `AbortSignal` parameter accepted on every method for contract
|
||||
compatibility (ignored — operations are synchronous in-memory).
|
||||
- Factory helpers `createFixtureFactory` and `createInMemoryStore` for code
|
||||
that prefers function calls over `new`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
createFixtureFactory,
|
||||
createInMemoryStore,
|
||||
} from "@suaveplan/seed/fixtures/fixtures";
|
||||
|
||||
const userFactory = createFixtureFactory((index) => ({
|
||||
id: index,
|
||||
name: `User ${index}`,
|
||||
email: `user${index}@example.com`,
|
||||
}));
|
||||
|
||||
const users = userFactory.createMany(3);
|
||||
|
||||
const store = createInMemoryStore();
|
||||
await store.insert("users", users);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
FixtureFactory,
|
||||
InMemorySeedStore,
|
||||
} from "@suaveplan/seed/fixtures/fixtures";
|
||||
|
||||
interface Membership extends Record<string, unknown> {
|
||||
orgId: string;
|
||||
userId: string;
|
||||
role: "member" | "admin";
|
||||
}
|
||||
|
||||
const memberships = new FixtureFactory<Membership>((i) => ({
|
||||
orgId: `org-${i % 2}`,
|
||||
userId: `user-${i}`,
|
||||
role: "member",
|
||||
}));
|
||||
|
||||
const rows = memberships.createMany(4, [
|
||||
undefined as never,
|
||||
{ role: "admin" },
|
||||
]);
|
||||
|
||||
const store = new InMemorySeedStore();
|
||||
await store.insert("memberships", rows);
|
||||
const updated = await store.update(
|
||||
"memberships",
|
||||
[{ orgId: "org-0", userId: "user-0", role: "admin" }],
|
||||
["orgId", "userId"]
|
||||
);
|
||||
|
||||
memberships.reset();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `FixtureFactory<T>`
|
||||
|
||||
- `new FixtureFactory(defaults: (index: number) => T)` — wrap a defaults
|
||||
function.
|
||||
- `create(overrides?: Partial<T>): T` — emit one record, advance the counter.
|
||||
- `createMany(count: number, overrides?: Partial<T>[]): T[]` — emit `count`
|
||||
records; `overrides[i]` (if present) merges over `defaults(counter)`.
|
||||
- `reset(): void` — set the counter back to 0.
|
||||
- `get counter(): number` — read the current counter.
|
||||
|
||||
### `createFixtureFactory<T>(defaults)`
|
||||
|
||||
Convenience wrapper for `new FixtureFactory(defaults)`.
|
||||
|
||||
### `InMemorySeedStore implements SeedStore`
|
||||
|
||||
- `insert(table, records, signal?)` — append; returns `records.length`.
|
||||
- `update(table, records, keyFields, signal?)` — composite-key upsert into
|
||||
matching rows; returns the count actually updated.
|
||||
- `findExisting(table, records, keyFields, signal?)` — return stored rows
|
||||
whose composite key appears in the input set.
|
||||
- `truncate(table, signal?)` — empty the table while keeping it present.
|
||||
- `tableExists(table, signal?)` — `true` iff `insert` or `truncate` has ever
|
||||
named the table.
|
||||
- `getRecords(table)` — defensive copy of the table's rows.
|
||||
- `clear()` — drop every table.
|
||||
|
||||
### `createInMemoryStore()`
|
||||
|
||||
Returns a new `InMemorySeedStore`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The composite key is built by mapping each `keyField` to `String(record[k] ??
|
||||
"")` and joining with `"::"`. Nullish field values collapse to the empty
|
||||
string, so records with `undefined` and `null` for the same field share a
|
||||
key; callers that need to distinguish them must use a key field that is
|
||||
always populated. `update` mutates the stored array in place via index
|
||||
assignment so subsequent `getRecords` calls observe the merge; the spread
|
||||
order is `{ ...existing[idx], ...record }`, meaning incoming fields win on
|
||||
collision.
|
||||
|
||||
`AbortSignal` arguments are intentionally ignored: there is no I/O to
|
||||
abort. The parameters exist only so the in-memory store satisfies the same
|
||||
shape as a network-bound store and can be swapped in for one in tests.
|
||||
|
||||
`getRecords` returns a shallow copy of the underlying array so mutating the
|
||||
returned list does not corrupt subsequent assertions. `clear` removes the
|
||||
map entries themselves, after which `tableExists` returns `false` for every
|
||||
table — distinguishing it from `truncate`, which preserves the entry while
|
||||
emptying its contents.
|
||||
113
docs/persistence/database/seed/src/registry/registry.md
Normal file
113
docs/persistence/database/seed/src/registry/registry.md
Normal file
@@ -0,0 +1,113 @@
|
||||
# SeedRegistry
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`SeedRegistry` is the canonical in-memory store for `SeedDefinition` entries used by `@suaveplan/seed`. It owns insertion-order bookkeeping, duplicate-name detection, and environment-scoped query helpers, providing every other layer (builder, runner, fixtures) a single source of truth for "what seeds exist in this process".
|
||||
|
||||
The registry is intentionally simple: no I/O, no concurrency primitives, no dependency resolution. Its sole invariants are (1) seed names are unique, (2) every read accessor returns a defensive shallow copy so caller mutations never leak into internal storage, and (3) duplicate registrations raise a structured `SeedError` with code `SEED.DUPLICATE_NAME` rather than a bare `Error`, so downstream catch sites can dispatch programmatically.
|
||||
|
||||
## Features
|
||||
|
||||
- O(1) name-keyed insertion, lookup, and deletion backed by a `Map`.
|
||||
- Bulk registration via `registerMany` that fails fast on the first duplicate.
|
||||
- Environment-scoped query (`getForEnvironment`) where unrestricted seeds (undefined or empty `environments`) match every environment.
|
||||
- Defensive shallow copies returned from every read accessor (`get`, `getAll`, `getForEnvironment`).
|
||||
- `SeedError`-typed failure with stable `SEED.DUPLICATE_NAME` code and `seedName` discriminant.
|
||||
- Insertion-order iteration via `names` getter (Map semantics).
|
||||
- Implements `ISeedRegistry` so consumers can substitute alternative registry implementations in tests.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { SeedRegistry } from "@suaveplan/seed/registry/registry";
|
||||
|
||||
const registry = new SeedRegistry();
|
||||
|
||||
registry.register({
|
||||
name: "users",
|
||||
table: "users",
|
||||
data: [{ id: 1, email: "a@example.com" }],
|
||||
});
|
||||
|
||||
const seed = registry.get("users");
|
||||
if (seed) {
|
||||
// seed.data is a defensive copy; safe to inspect without affecting storage.
|
||||
}
|
||||
|
||||
console.log(registry.size); // 1
|
||||
console.log(registry.names); // ["users"]
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { SeedRegistry } from "@suaveplan/seed/registry/registry";
|
||||
import {
|
||||
SEED_CODES,
|
||||
SeedError,
|
||||
} from "@suaveplan/seed/error-codes/error-codes";
|
||||
import type { SeedDefinition } from "@suaveplan/seed/types/common";
|
||||
|
||||
const registry = new SeedRegistry();
|
||||
|
||||
const defs: SeedDefinition[] = [
|
||||
{ name: "users", table: "users", data: [] },
|
||||
{
|
||||
name: "fixtures",
|
||||
table: "fixtures",
|
||||
environments: ["test"],
|
||||
data: [],
|
||||
},
|
||||
{
|
||||
name: "demo-data",
|
||||
table: "demo",
|
||||
environments: ["dev"],
|
||||
data: [],
|
||||
},
|
||||
];
|
||||
|
||||
try {
|
||||
registry.registerMany(defs);
|
||||
} catch (err) {
|
||||
if (err instanceof SeedError && err.code === SEED_CODES.DUPLICATE_NAME) {
|
||||
// Structured: err.seedName identifies the colliding entry.
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
// Environment-scoped query: unrestricted + matching seeds only.
|
||||
const forTest = registry.getForEnvironment("test");
|
||||
// → users (unrestricted) + fixtures (env=test)
|
||||
|
||||
// Remove yields a boolean; missing keys are a no-op.
|
||||
registry.remove("demo-data");
|
||||
registry.clear();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Member | Description |
|
||||
| ----------------------------------- | ----------------------------------------------------------------- |
|
||||
| `register(seed)` | Insert one seed; throws `SeedError(SEED.DUPLICATE_NAME)` on collision. |
|
||||
| `registerMany(seeds)` | Iterate and `register` each seed; aborts on first duplicate. |
|
||||
| `get(name)` | Defensive copy of the named seed, or `undefined`. |
|
||||
| `getAll()` | Defensive-copy array of every registered seed. |
|
||||
| `getForEnvironment(env)` | Subset matching `env` or unrestricted (`undefined`/empty). |
|
||||
| `has(name)` | `true` if the name is present, `false` otherwise. |
|
||||
| `remove(name)` | Delete; returns `true` if the entry existed. |
|
||||
| `clear()` | Remove every registered seed. |
|
||||
| `size` | Current entry count. |
|
||||
| `names` | Snapshot of registered names in insertion order. |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- Storage uses `Map<string, SeedDefinition>` so insertion order is preserved by `names`, `getAll`, and `getForEnvironment`.
|
||||
- Defensive copies are *shallow* (`{ ...seed }`). Nested arrays (`environments`, `dependencies`, `upsertKeys`, `data`) are not cloned; they remain `readonly` on the `SeedDefinition` type, so callers cannot mutate them without an explicit cast. This matches the upstream reference behaviour and avoids deep-clone overhead during runner setup.
|
||||
- `getForEnvironment` filters the already-defensive `getAll()` result. The two-pass approach keeps the filter logic small and reuses the copy contract.
|
||||
- `register` throws *before* mutating the map, so a duplicate registration leaves the registry intact. `registerMany` therefore preserves a partial-success prefix when an entry mid-stream collides — consumers who need transactional semantics should snapshot `names` and roll back via `remove` after catching.
|
||||
- Error construction always includes `seedName` so downstream `instanceof SeedError` checks can dispatch without parsing the message string.
|
||||
- No internal concurrency control: the registry is intended for single-threaded use during process startup and test setup. Consumers requiring concurrent registration must serialise externally.
|
||||
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.
|
||||
151
docs/persistence/database/seed/src/types/common.md
Normal file
151
docs/persistence/database/seed/src/types/common.md
Normal file
@@ -0,0 +1,151 @@
|
||||
# types/common
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Shared type module for `@suaveplan/seed`. Every public class and function
|
||||
in the package — `SeedBuilder`, `SeedRegistry`, `SeedRunner`,
|
||||
`FixtureFactory`, `InMemorySeedStore`, `topoSort`, `detectCycles` —
|
||||
references the type contracts declared here. Defining them in one
|
||||
location keeps the public surface coherent, lets consumers pull a single
|
||||
import path when they want to type their own `SeedStore` adapters, and
|
||||
ensures cross-module type-narrowing works without circular imports.
|
||||
|
||||
The types are all data-shapes (`SeedDefinition`, `SeedRecord`,
|
||||
`SeedResult`, `SeedRunResult`, `SeedRunConfig`, `SeedStore`) plus their
|
||||
supporting unions (`SeedEnvironment`, `SeedStatus`, `UpsertStrategy`,
|
||||
`SeedDataFactory`). They are intentionally framework-free — no Zod
|
||||
schemas, no runtime validators — because the package's developer-facing
|
||||
API is constructed in-process, not received over a wire.
|
||||
|
||||
## Features
|
||||
|
||||
- `SeedDefinition` and `SeedRecord` shapes
|
||||
- `SeedResult` / `SeedRunResult` shapes returned by the runner
|
||||
- `SeedRunConfig` shape consumed by `SeedRunner.run`
|
||||
- `SeedStore` adapter contract (`insert` / `update` / `findExisting` / `truncate` / `tableExists`) with optional `AbortSignal` plumbing
|
||||
- `SeedEnvironment` open string-literal union accepting custom project environments
|
||||
- `SeedStatus` and `UpsertStrategy` enums-as-unions
|
||||
- `SeedDataFactory` async-or-sync data producer signature
|
||||
- `readonly` on every collection so consumers cannot mutate definitions in-flight
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import type { SeedDefinition, SeedStore } from "@suaveplan/seed";
|
||||
|
||||
const usersSeed: SeedDefinition = {
|
||||
name: "users",
|
||||
table: "user",
|
||||
data: [{ id: 1, name: "alice" }],
|
||||
upsertStrategy: "skip",
|
||||
upsertKeys: ["id"],
|
||||
};
|
||||
|
||||
class MyStore implements SeedStore {
|
||||
async insert(table, records) {
|
||||
// ...
|
||||
return records.length;
|
||||
}
|
||||
async update() { return 0; }
|
||||
async findExisting() { return []; }
|
||||
async truncate() {}
|
||||
async tableExists() { return true; }
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import type {
|
||||
SeedDataFactory,
|
||||
SeedDefinition,
|
||||
SeedEnvironment,
|
||||
SeedRunConfig,
|
||||
SeedRunResult,
|
||||
SeedStore,
|
||||
} from "@suaveplan/seed";
|
||||
|
||||
const tenantsFactory: SeedDataFactory = async () => {
|
||||
const csv = await fetch("/tenants.csv").then((r) => r.text());
|
||||
return parseTenants(csv);
|
||||
};
|
||||
|
||||
const def: SeedDefinition = {
|
||||
name: "tenants",
|
||||
table: "tenant",
|
||||
environments: ["staging", "production"],
|
||||
dependencies: [],
|
||||
data: tenantsFactory,
|
||||
upsertStrategy: "update",
|
||||
upsertKeys: ["slug"],
|
||||
priority: 0,
|
||||
};
|
||||
|
||||
async function runFor(
|
||||
env: SeedEnvironment,
|
||||
store: SeedStore,
|
||||
signal: AbortSignal,
|
||||
): Promise<SeedRunResult> {
|
||||
const config: SeedRunConfig = {
|
||||
environment: env,
|
||||
batchSize: 500,
|
||||
continueOnError: false,
|
||||
signal,
|
||||
onProgress: (r) => console.log(r.name, r.status, r.duration),
|
||||
};
|
||||
return runner.run(config);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### Types
|
||||
|
||||
- `SeedEnvironment` — string-literal union with `"dev" | "staging" | "test" | "production"` plus any custom string
|
||||
- `SeedStatus` — `"pending" | "running" | "completed" | "failed" | "skipped"`
|
||||
- `SeedRecord` — `Record<string, unknown>`
|
||||
- `SeedDataFactory` — `() => SeedRecord[] | Promise<SeedRecord[]>`
|
||||
- `UpsertStrategy` — `"skip" | "update" | "replace"`
|
||||
|
||||
### Interfaces
|
||||
|
||||
- `SeedDefinition` — every field declarative on a seed (`name`, `table`, `environments?`, `dependencies?`, `data`, `upsertStrategy?`, `upsertKeys?`, `description?`, `priority?`, `truncate?`)
|
||||
- `SeedResult` — single-seed outcome (`name`, `table`, `status`, counts, `duration`, `error?`)
|
||||
- `SeedRunResult` — aggregate outcome of one `run()` call
|
||||
- `SeedStore` — adapter contract (`insert`, `update`, `findExisting`, `truncate`, `tableExists`) with optional `signal` parameter on every method
|
||||
- `SeedRunConfig` — input to `SeedRunner.run` (`environment`, `only?`, `exclude?`, `dryRun?`, `batchSize?`, `continueOnError?`, `onProgress?`, `signal?`)
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`SeedEnvironment` uses the `(string & {})` trick — a no-op intersection
|
||||
that preserves IDE autocomplete for the well-known values while still
|
||||
accepting arbitrary strings. This is the canonical TypeScript idiom for
|
||||
"open enum" string unions and matches the pattern used elsewhere in the
|
||||
project (e.g. `LocaleCode`, `MimeType`).
|
||||
|
||||
Every array field on `SeedDefinition` and `SeedRunConfig` is `readonly`,
|
||||
so callers (or the registry's defensive copies) cannot mutate the
|
||||
definitions mid-run. `SeedStore` methods accept `readonly SeedRecord[]`
|
||||
inputs, freeing implementations from copying defensively while still
|
||||
allowing them to enumerate.
|
||||
|
||||
`SeedDataFactory` returns either a sync `SeedRecord[]` or a
|
||||
`Promise<SeedRecord[]>`. The runner always `await`s the result so callers
|
||||
can treat both forms identically; in practice the sync form covers
|
||||
inline test fixtures and the async form covers loading CSVs, fetching
|
||||
from remote APIs, or generating records from `FixtureFactory.createMany`.
|
||||
|
||||
`SeedStore.insert` returns the number of records actually inserted, not
|
||||
the count the caller passed in — adapters MAY filter (e.g. via
|
||||
`ON CONFLICT DO NOTHING`) and the runner trusts the returned count when
|
||||
populating `SeedResult.recordsInserted`.
|
||||
|
||||
The optional `signal?: AbortSignal` parameter on every `SeedStore` method
|
||||
lets network-bound stores honour cancellation; the `InMemorySeedStore`
|
||||
ignores it. The runner propagates `SeedRunConfig.signal` to every store
|
||||
call but ALSO performs its own check between seeds so a cancelled run
|
||||
shuts down even if the store ignores the signal.
|
||||
109
docs/persistence/database/seed/src/utils/topo-sort/topo-sort.md
Normal file
109
docs/persistence/database/seed/src/utils/topo-sort/topo-sort.md
Normal file
@@ -0,0 +1,109 @@
|
||||
# topo-sort
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`topo-sort` is the dependency-resolution engine that lets `@suaveplan/seed` execute seed definitions in an order where every dependency precedes its dependents. Without it, the runner could not enforce the contract that, for example, `categories` is populated before `products` references it. The module exposes two functions: `topoSort`, which produces a single linear execution order (or throws when the graph is broken), and `detectCycles`, a non-throwing diagnostic counterpart that returns explicit cycle traces for tooling and error reporting. Both functions are pure and operate over structurally-typed inputs, so they work equally well on `SeedDefinition` arrays and on lightweight test fixtures.
|
||||
|
||||
## Features
|
||||
|
||||
- Single-pass [Kahn's algorithm](https://en.wikipedia.org/wiki/Topological_sorting#Kahn's_algorithm) topological sort.
|
||||
- Stable priority tiebreak: among independent seeds, lower `priority` runs first (default `0`).
|
||||
- Stable, `SeedError`-typed failure modes:
|
||||
- `SEED.UNREGISTERED_DEPENDENCY` when a `dependencies[]` entry names a seed that is not present in the input.
|
||||
- `SEED.CIRCULAR_DEPENDENCY` when the graph contains a cycle (detected via residual unprocessed nodes after the queue drains).
|
||||
- `SEED.DEPENDENCY_DEPTH_EXCEEDED` defensive guard against pathological iteration counts.
|
||||
- DFS-based `detectCycles` that returns an array of cycle traces and never throws — ideal for surfacing diagnostics in test setup or CI pre-flight checks.
|
||||
- Universal: zero `node:` / `bun:` imports, runs in browsers, Bun, Node, workers, and Deno.
|
||||
- Accepts `readonly` arrays and `readonly string[]` dependency lists for cheap interop with frozen domain types.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { topoSort } from "@suaveplan/seed/utils/topo-sort/topo-sort";
|
||||
|
||||
const order = topoSort([
|
||||
{ name: "products", dependencies: ["categories"] },
|
||||
{ name: "categories" },
|
||||
{ name: "reviews", dependencies: ["products", "users"] },
|
||||
{ name: "users" },
|
||||
]);
|
||||
// → ["categories", "users", "products", "reviews"] (priority ties broken stably)
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { SeedError, SEED_CODES } from "@suaveplan/seed";
|
||||
import { detectCycles, topoSort } from "@suaveplan/seed/utils/topo-sort/topo-sort";
|
||||
|
||||
const seeds = [
|
||||
{ name: "a", dependencies: ["b"], priority: 0 },
|
||||
{ name: "b", dependencies: ["a"], priority: 0 },
|
||||
];
|
||||
|
||||
// Pre-flight: surface every cycle as an explicit trace.
|
||||
const cycles = detectCycles(seeds);
|
||||
if (cycles.length > 0) {
|
||||
for (const trace of cycles) {
|
||||
console.error(`cycle: ${trace}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Execution path throws a typed error.
|
||||
try {
|
||||
topoSort(seeds);
|
||||
} catch (err) {
|
||||
if (err instanceof SeedError && err.code === SEED_CODES.CIRCULAR_DEPENDENCY) {
|
||||
// remediate
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `topoSort(seeds): string[]`
|
||||
|
||||
```ts
|
||||
function topoSort(
|
||||
seeds: readonly {
|
||||
readonly name: string;
|
||||
readonly dependencies?: readonly string[];
|
||||
readonly priority?: number;
|
||||
}[]
|
||||
): string[];
|
||||
```
|
||||
|
||||
Returns a fresh array of seed names in execution order. Throws `SeedError` (with one of `SEED.UNREGISTERED_DEPENDENCY`, `SEED.CIRCULAR_DEPENDENCY`, `SEED.DEPENDENCY_DEPTH_EXCEEDED`) on any structural problem.
|
||||
|
||||
### `detectCycles(seeds): string[]`
|
||||
|
||||
```ts
|
||||
function detectCycles(
|
||||
seeds: readonly {
|
||||
readonly name: string;
|
||||
readonly dependencies?: readonly string[];
|
||||
}[]
|
||||
): string[];
|
||||
```
|
||||
|
||||
Returns an array of cycle-trace strings shaped like `"a -> b -> a"`. Returns `[]` for an empty input or any DAG. Never throws. Dependencies whose names are not present in the graph are silently skipped — they are reported by `topoSort` as `UNREGISTERED_DEPENDENCY` instead.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`topoSort` performs exactly one Kahn's-algorithm pass:
|
||||
|
||||
1. Build three maps in a single loop: `graph` (name → deps), `inDegree` (name → 0), and `priority` (name → priority).
|
||||
2. Validate every dependency edge: when a name in `dependencies` is absent from `graph`, throw `UNREGISTERED_DEPENDENCY`; otherwise increment the dependent's in-degree.
|
||||
3. Seed the queue with every name whose `inDegree === 0`, sorted by ascending priority.
|
||||
4. Drain the queue: pop, append to `result`, decrement the in-degree of every node that depends on the popped name. When a dependent's in-degree falls to zero, enqueue it and re-sort by priority. Stable priority ordering is preserved by re-sorting on insertion rather than maintaining a binary heap — for the small graphs typical of seed sets, the simpler approach is faster and more legible.
|
||||
5. After the loop, if `result.length !== graph.size`, identify the unprocessed names and throw `CIRCULAR_DEPENDENCY` with the offending names embedded in the message.
|
||||
|
||||
The `DEPENDENCY_DEPTH_EXCEEDED` guard is structurally unreachable from valid input because Kahn's algorithm enqueues each node at most once. It remains in place as belt-and-braces against future refactors that might violate that invariant — the corresponding line is excluded from coverage with `/* c8 ignore */` plus a justifying comment.
|
||||
|
||||
`detectCycles` uses iterative DFS with two sets: `visited` (fully explored) and `stack` (current recursion path). When DFS encounters a node already on `stack`, it slices the path from that node forward, appends the cycle-closing edge, and joins with `" -> "`. Dependencies that name non-existent nodes are skipped so external references (e.g., seeds defined outside the current registry slice) do not surface as false-positive cycles.
|
||||
|
||||
Both functions are pure: they allocate no shared state, do not mutate their inputs, and produce deterministic output for any given input. Priority is the only tiebreak — insertion order is not preserved beyond what priority encodes, which is intentional so callers cannot accidentally depend on registration order.
|
||||
Reference in New Issue
Block a user