# Design — @suaveplan/seed Non-normative. Architecture, file layout, rename rationale, error-code map, and integration trade-offs. The normative contract lives in `./specs/seed/spec.md`. ## File layout (post-migration) ```text packages/database/seed/ package.json # name=@suaveplan/seed, version=0.0.0, sideEffects=false tsconfig.json tsconfig.lib.json vite.config.ts vitest.config.ts # createTestConfig() from @suaveplan/testing/config README.md src/ index.ts # root barrel — explicit named re-exports (rule §6a) constants/ index.ts # DEFAULT_ENVIRONMENTS, MAX_DEPENDENCY_DEPTH, DEFAULT_BATCH_SIZE, SEED_STATUS error-codes/ error-codes.ts # defineCodes("SEED", [...]) + SeedError class error-codes.test.ts error-codes.md index.ts types/ common.ts # SeedDefinition / SeedRecord / SeedStore / SeedRunConfig / ... common.md builder/ builder.ts # SeedBuilder class + defineSeed() builder.test.ts builder.types.ts # ISeedBuilder builder.md index.ts registry/ registry.ts # SeedRegistry class registry.test.ts registry.types.ts # ISeedRegistry registry.md index.ts runner/ runner.ts # SeedRunner class runner.test.ts runner.types.ts # ISeedRunner runner.md index.ts fixtures/ fixtures.ts # FixtureFactory + InMemorySeedStore + factories fixtures.test.ts fixtures.types.ts fixtures.md index.ts utils/ topo-sort/ topo-sort.ts # topoSort + detectCycles topo-sort.test.ts topo-sort.md index.ts ``` ## Rename rationale Upstream lived under `packages/server/seed/src/` with `core/seed-runner/seed-runner.ts`-style paths. Two normalisations: 1. **Drop `core/`** — the wrapper is non-informative (every package's main features are "core"). Removing it shortens subpath exports from `./core/seed-runner/seed-runner` to `./runner/runner` without losing meaning. 2. **Drop the `seed-` prefix on child folders and files** — the package itself is named `seed`, so `seed/src/seed-runner/seed-runner.ts` repeats the word three times. Per the no-redundant-prefixes pattern, becomes `seed/src/runner/runner.ts`. Public class names (`SeedRunner`, `SeedRegistry`, `SeedBuilder`, `SeedError`, etc.) keep the `Seed` prefix because they are user-facing identifiers and namespaces survive `import { SeedRunner } from '@suaveplan/seed'` regardless of file layout. ## Tier rationale: universal The upstream package was filed under `packages/server/` but a `grep -rnE "from ['\"](node:|bun:|fs|path|crypto|stream|net|http)" packages/server/seed/src/` returns **zero matches**. The only platform-aware call is `performance.now()`, which is available in every modern runtime (Node ≥16, Bun, browsers, Deno, workers). The package is therefore **universal-tier**, and lands at `packages/database/seed/` per the `database` plugin slot in `scripts/folder-reorg/assignment.yml:184`. The `engines` field omits `bun` / `node` constraints; the `"browser"` field is unset (no browser-only entry point); peer dependencies are empty. ## Error-code map ```text SEED.DUPLICATE_NAME // SeedRegistry.register on existing name SEED.UNREGISTERED_DEPENDENCY // topoSort: seed depends on a name not in the registry SEED.CIRCULAR_DEPENDENCY // topoSort: cycle detected mid-traversal or via residual check SEED.DEPENDENCY_DEPTH_EXCEEDED // topoSort: iterations > MAX_DEPENDENCY_DEPTH * seedCount (defensive) SEED.INVALID_CONFIG // SeedRunner.run: missing environment, conflicting only/exclude, etc. SEED.STORE_ERROR // SeedRunner: wraps an exception thrown by the injected SeedStore so it carries a stable code (preserves original via `cause`) SEED.ABORTED // SeedRunner: signal aborted between or during seed execution ``` `SeedError` discriminants: ```ts class SeedError extends SuaveplanError { readonly seedName?: string; // populated for per-seed errors readonly dependency?: string; // populated for UNREGISTERED_DEPENDENCY } ``` `STORE_ERROR` preserves the underlying exception on `cause` per the `@suaveplan/error` contract; per-seed `SeedResult.error` stores the human-readable message and the code is available via the cause chain. This matches the `storage-file` precedent where adapter errors are wrapped without losing the underlying. ## topoSort rewrite Upstream `topo-sort.ts` lines 21–50 are dead code: a first pass that increments `inDegree` by `+ 0` (no-op), a misleading `const _current = inDegree.get(graph.has(dep) ? "" : dep)` whose result is discarded, followed by a "Recalculate in-degrees properly" cleanup pass using a fresh `degrees` map. The cleanup is correct; the first pass is residue from an earlier draft. Genesis rewrites as one Kahn's pass: ```ts function topoSort(seeds: Seed[]): string[] { const graph = new Map(); const inDegree = new Map(); const priority = new Map(); for (const s of seeds) { graph.set(s.name, s.dependencies ?? []); inDegree.set(s.name, 0); priority.set(s.name, s.priority ?? 0); } for (const [name, deps] of graph) { for (const dep of deps) { if (!graph.has(dep)) { throw new SeedError({ code: SEED.UNREGISTERED_DEPENDENCY, message: `Seed "${dep}" is listed as a dependency but not registered`, seedName: name, dependency: dep, }); } inDegree.set(name, (inDegree.get(name) ?? 0) + 1); } } const queue: string[] = []; for (const [name, degree] of inDegree) if (degree === 0) queue.push(name); queue.sort((a, b) => (priority.get(a) ?? 0) - (priority.get(b) ?? 0)); const result: string[] = []; const guard = MAX_DEPENDENCY_DEPTH * seeds.length; let iter = 0; while (queue.length > 0) { if (iter++ > guard) { throw new SeedError({ code: SEED.DEPENDENCY_DEPTH_EXCEEDED, message: "Dependency resolution exceeded guard" }); } const current = queue.shift()!; result.push(current); for (const [name, deps] of graph) { if (deps.includes(current)) { const newDegree = (inDegree.get(name) ?? 1) - 1; inDegree.set(name, newDegree); if (newDegree === 0) { queue.push(name); queue.sort((a, b) => (priority.get(a) ?? 0) - (priority.get(b) ?? 0)); } } } } if (result.length !== graph.size) { const remaining = [...graph.keys()].filter((n) => !result.includes(n)); throw new SeedError({ code: SEED.CIRCULAR_DEPENDENCY, message: `Circular dependency detected involving: ${remaining.join(", ")}`, }); } return result; } ``` A characterisation test seeded with the upstream test cases (read from `../suaveplan/packages/server/seed/src/utils/topo-sort.test.ts`) gates the rewrite — for any seed list upstream accepts, the genesis order matches exactly modulo priority tiebreak determinism. ## LoggerLike injection `SeedRunner` constructor gains an optional third arg: ```ts constructor(registry: SeedRegistry, store: SeedStore, logger?: LoggerLike) ``` Default is a no-op (`{ info() {}, warn() {}, error() {}, debug() {} }`). When wired, the runner emits: - `seed.run.start` `{ environment, totalSeeds }` - `seed.run.complete` `{ environment, success, seedsRun, seedsSkipped, seedsFailed, totalDuration }` - `seed.completed` `{ name, table, recordsInserted, recordsUpdated, recordsSkipped, duration }` (after each seed completes) - `seed.skipped` `{ name, table, reason: "Dependency failed" | "Aborted" }` - `seed.failed` `{ name, table, error, duration }` This is preparation for the future `api-plugin-seed` OTel wiring — the same fields map to span events without changing the public log API. ## AbortSignal plumbing `SeedRunConfig.signal?: AbortSignal`. The runner checks `signal.aborted` between seeds and short-circuits the loop, returning a `SeedRunResult` where the remaining seeds are status `"skipped"` with `error: "Aborted"`. Per-seed in-flight `SeedStore` calls receive the signal too (the `SeedStore` shape gains an optional `signal?: AbortSignal` parameter on every method, with default ignore). This matches the `storage-http`/`drive` plumbing pattern and is genesis-mandatory. ## Package contract surface Root barrel (`src/index.ts`) re-exports — explicit named exports per §6a: ```ts // Constants export { DEFAULT_BATCH_SIZE, DEFAULT_ENVIRONMENTS, MAX_DEPENDENCY_DEPTH, SEED_STATUS, } from "./constants/index"; // Errors export { SEED_CODES, SeedError } from "./error-codes/error-codes"; export type { SeedErrorCode } from "./error-codes/error-codes"; // Builder export { defineSeed, SeedBuilder } from "./builder/builder"; export type { ISeedBuilder } from "./builder/builder.types"; // Registry export { SeedRegistry } from "./registry/registry"; export type { ISeedRegistry } from "./registry/registry.types"; // Runner export { SeedRunner } from "./runner/runner"; export type { ISeedRunner } from "./runner/runner.types"; // Fixtures export { createFixtureFactory, createInMemoryStore, FixtureFactory, InMemorySeedStore, } from "./fixtures/fixtures"; // Types export type { SeedDataFactory, SeedDefinition, SeedEnvironment, SeedRecord, SeedResult, SeedRunConfig, SeedRunResult, SeedStatus, SeedStore, UpsertStrategy, } from "./types/common"; // Utils export { detectCycles, topoSort } from "./utils/topo-sort/topo-sort"; ``` Subpath exports in `package.json` mirror each feature folder so consumers can tree-shake import the runner without pulling the fixtures store. ## Test plan Upstream had `.test.ts` siblings for `seed-builder`, `seed-registry`, `seed-runner`, `fixtures`, and `topo-sort`. Genesis matches each and adds `error-codes.test.ts` for the new code registry. Tests use `@suaveplan/testing/runner` (rule §11). `vitest.config.ts` uses `createTestConfig()`; coverage gate is 100% statement/branch/function/line via `provider: 'istanbul'`. Property-based coverage (`fast-check`) goes on `topoSort`: for any DAG generated with priorities, the output order respects every dependency edge and is stable under permutation of input order modulo priority. Cycle generators feed `detectCycles` to verify cycle reporting. The in-memory store's upsert behaviour is verified across all three strategies (`skip`/`update`/`replace`) with `upsertKeys` of varying cardinality, including the empty-keys edge case where strategies degrade to `replace` (per upstream behaviour at `seed-runner.ts:216–222`). ## Out-of-package decisions - **No `definePlugin()` shell.** The `api-plugin-seed` follow-up package owns the plugin tier. Keeping the core plugin-shell-free preserves portability (universal-tier, no `@suaveplan/api` dependency). - **No baked-in SurrealDB store.** The `SeedStore` interface is the boundary; any store ships in its own package (`seed-store-surrealdb`, `seed-store-postgres`, etc.). The `InMemorySeedStore` covers testing and is intentionally not a production option. - **No Zod validation of `SeedDefinition`.** The fluent builder + TypeScript types are the contract; runtime Zod adds cost without an attack surface. Revisit if `SeedDefinition` ever crosses a wire boundary.