11 KiB
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)
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:
- Drop
core/— the wrapper is non-informative (every package's main features are "core"). Removing it shortens subpath exports from./core/seed-runner/seed-runnerto./runner/runnerwithout losing meaning. - Drop the
seed-prefix on child folders and files — the package itself is namedseed, soseed/src/seed-runner/seed-runner.tsrepeats the word three times. Per the no-redundant-prefixes pattern, becomesseed/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
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:
class SeedError extends SuaveplanError<SeedErrorCode> {
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:
function topoSort(seeds: Seed[]): string[] {
const graph = new Map<string, string[]>();
const inDegree = new Map<string, number>();
const priority = new Map<string, number>();
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:
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:
// 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. Theapi-plugin-seedfollow-up package owns the plugin tier. Keeping the core plugin-shell-free preserves portability (universal-tier, no@suaveplan/apidependency). - No baked-in SurrealDB store. The
SeedStoreinterface is the boundary; any store ships in its own package (seed-store-surrealdb,seed-store-postgres, etc.). TheInMemorySeedStorecovers 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 ifSeedDefinitionever crosses a wire boundary.