Move docs/ package mirror into document-navigator plugin

Relocate the ~6k-file docs tree from the repo root into
plugins/document-navigator/docs/ so the plugin is self-contained and
portable. docs/superpowers/ (specs and plans) stays at the repo root.

Moved via cp+rm rather than git mv because directory rename() fails with
EPERM on this WSL /mnt DrvFs mount; git rename-detection pairs the
identical-content files at commit time.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-30 01:10:00 +01:00
parent fe3fedf42e
commit 9bf56a806b
6022 changed files with 12437 additions and 0 deletions

View 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.

View 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.

View 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`.

View 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.

View 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.

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.

View 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.

View 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.