22 KiB
Capability Spec Delta: seed (seed)
Package: @suaveplan/seed
Status: PROPOSED
Every requirement below carries a
**Verified by:**line at authoring time. No exceptions.
ADDED Requirements
Requirement: Universal-tier purity
The package SHALL ship as a universal-tier package — no imports from node:*, bun:*, or the bare specifiers fs/path/stream/net/http/crypto in any file under src/. The package SHALL run unchanged under Node ≥ 18, Bun, modern browsers, and Web Workers. The package.json SHALL NOT declare a "browser" field, SHALL NOT pin engines.node or engines.bun, and SHALL declare "sideEffects": false. The single permitted platform-aware call is performance.now() (available in every supported runtime).
Scenario: no platform imports in src/
- WHEN a CI step runs
grep -rnE "from ['\"](node:|bun:|fs|path|stream|net|http|crypto)['\"]" packages/database/seed/src/ - THEN the command produces zero matches
Verified by: src/runner/runner.test.ts::"runner has no node/bun imports (tier purity)" AND src/utils/topo-sort/topo-sort.test.ts::"topo-sort has no node/bun imports (tier purity)"
Requirement: Stable SEED_CODES error registry
The package SHALL declare an error-code registry via defineCodes("SEED", [...]) containing exactly the codes DUPLICATE_NAME, UNREGISTERED_DEPENDENCY, CIRCULAR_DEPENDENCY, DEPENDENCY_DEPTH_EXCEEDED, INVALID_CONFIG, STORE_ERROR, ABORTED. Every public error path SHALL throw or wrap a SeedError extends SuaveplanError<SeedErrorCode> carrying one of those codes. SeedError SHALL expose optional seedName and dependency discriminants for diagnostics. The package SHALL NOT throw raw Error instances from any public method.
Scenario: every code is reachable from a real call site
- WHEN the test suite exercises duplicate-name registration, missing-dependency topo-sort, cyclic topo-sort, depth-guard overflow, invalid run config, store throwing, and aborted run
- THEN each path raises (or returns a
SeedResultwrapping) aSeedErrorwhosecodematches the expectedSEED.*constant
Scenario: SuaveplanError contract preserved
- WHEN a
SeedErroris thrown with acause(e.g. wrapping an injected store exception) - THEN the resulting error is
instanceof SuaveplanError, exposes the original onerror.cause, and the wrapped code remains stable across renames
Verified by: src/error-codes/error-codes.test.ts::"every SEED.* code is reachable from a call site" AND src/error-codes/error-codes.test.ts::"SeedError preserves cause and SuaveplanError contract"
Requirement: SeedBuilder fluent API + defineSeed
The package SHALL provide a SeedBuilder class and a defineSeed(name: string): SeedBuilder convenience function. SeedBuilder SHALL expose chainable methods table(name), environments(...envs), dependsOn(...names), data(records), dataFactory(fn), upsert(strategy, keys), description(text), priority(n), truncateFirst(enabled?), and a terminal build() returning a SeedDefinition. The default table SHALL equal the seed name. build() SHALL omit optional fields that were never set (no empty arrays or empty strings serialised into the output). When both data(...) and dataFactory(...) are called, the most recent call SHALL win. The ISeedBuilder interface SHALL match the public method shape exactly so callers can program against the contract.
Scenario: default table equals name
- WHEN a caller invokes
defineSeed("users").build() - THEN the returned definition has
name: "users"andtable: "users"
Scenario: optional fields omitted when unset
- WHEN a caller invokes
defineSeed("u").data([{ id: 1 }]).build() - THEN the returned definition has no
environments, nodependencies, noupsertKeys, nodescription, notruncatefield on the output
Scenario: dataFactory wins over data
- WHEN a caller invokes
defineSeed("u").data([{ a: 1 }]).dataFactory(() => [{ a: 2 }]) - THEN
build().datais the factory function
Verified by: src/builder/builder.test.ts::"default table equals name" AND src/builder/builder.test.ts::"omits optional fields that were never set" AND src/builder/builder.test.ts::"dataFactory overrides prior data() call"
Requirement: SeedRegistry registration, query, and duplicate detection
The package SHALL provide a SeedRegistry class with methods register(seed), registerMany(seeds), get(name), getAll(), getForEnvironment(env), has(name), remove(name), clear(), and accessors size and names. register SHALL throw SeedError({ code: SEED.DUPLICATE_NAME, seedName }) when the name is already registered. get and getAll SHALL return defensive copies of stored definitions (mutations of the returned objects MUST NOT affect the registry). getForEnvironment(env) SHALL return seeds whose environments array is undefined, empty, OR contains env. The ISeedRegistry interface SHALL match the public method shape exactly.
Scenario: duplicate registration raises SEED.DUPLICATE_NAME
- WHEN
registry.register({ name: "x", table: "x", data: [] })is called twice - THEN the second call throws
SeedErrorwithcode === "SEED.DUPLICATE_NAME"andseedName === "x"
Scenario: defensive copies on read
- WHEN a caller mutates the array returned from
registry.getAll() - THEN subsequent calls to
registry.getorregistry.getAllreturn the original unmodified definitions
Scenario: environment filter includes unrestricted seeds
- WHEN the registry contains seed A (no
environments), seed B (environments: ["test"]), seed C (environments: ["dev"]) - THEN
getForEnvironment("test")returns A and B but not C
Verified by: src/registry/registry.test.ts::"register raises SEED.DUPLICATE_NAME on collision" AND src/registry/registry.test.ts::"getAll returns defensive copies" AND src/registry/registry.test.ts::"getForEnvironment honours unrestricted and matching seeds"
Requirement: topoSort produces dependency-respecting order with priority tiebreak
The topoSort(seeds) utility SHALL return a string array of seed names where every name precedes every seed that lists it in dependencies. Among seeds with the same in-degree, ordering SHALL be stable by ascending priority (lower priority = earlier execution; default 0). When a seed's dependencies entry names a seed that is not present in the input, topoSort SHALL throw SeedError({ code: SEED.UNREGISTERED_DEPENDENCY, seedName: <dependent>, dependency: <missing> }). When a cycle is detected (either during traversal or via residual unprocessed nodes), topoSort SHALL throw SeedError({ code: SEED.CIRCULAR_DEPENDENCY, message: <names involved> }). When the iteration counter exceeds MAX_DEPENDENCY_DEPTH * seeds.length, topoSort SHALL throw SeedError({ code: SEED.DEPENDENCY_DEPTH_EXCEEDED }) as a defensive guard.
Scenario: respects dependency edges
- WHEN
topoSort([{ name: "a", dependencies: ["b"] }, { name: "b" }])is called - THEN the result is
["b", "a"]
Scenario: priority breaks ties for independent seeds
- WHEN
topoSort([{ name: "a", priority: 10 }, { name: "b", priority: 1 }])is called - THEN the result is
["b", "a"]
Scenario: missing dependency raises UNREGISTERED_DEPENDENCY
- WHEN
topoSort([{ name: "a", dependencies: ["missing"] }])is called - THEN a
SeedErroris thrown withcode === "SEED.UNREGISTERED_DEPENDENCY",seedName === "a",dependency === "missing"
Scenario: cycle raises CIRCULAR_DEPENDENCY
- WHEN
topoSort([{ name: "a", dependencies: ["b"] }, { name: "b", dependencies: ["a"] }])is called - THEN a
SeedErroris thrown withcode === "SEED.CIRCULAR_DEPENDENCY"and the message lists bothaandb
Scenario: upstream test corpus passes verbatim
- WHEN every test case from
../suaveplan/packages/server/seed/src/utils/topo-sort.test.tsis re-run against the genesis implementation - THEN every assertion that expected a specific order passes, and every assertion that expected an error matches the new
SeedErrorinstead of the upstreamError
Verified by: src/utils/topo-sort/topo-sort.test.ts::"respects dependency edges" AND src/utils/topo-sort/topo-sort.test.ts::"priority breaks ties for independent seeds" AND src/utils/topo-sort/topo-sort.test.ts::"raises UNREGISTERED_DEPENDENCY for missing dep" AND src/utils/topo-sort/topo-sort.test.ts::"raises CIRCULAR_DEPENDENCY for cycle" AND src/utils/topo-sort/topo-sort.test.ts::"upstream characterisation cases pass"
Requirement: detectCycles returns explicit cycle traces
The detectCycles(seeds) utility SHALL perform DFS-based cycle detection across the dependency graph and return an array of cycle-trace strings of the form "a -> b -> a" for every cycle found. The function SHALL NOT throw; an acyclic graph SHALL return an empty array. Nodes that depend on names not in the graph SHALL be skipped (no false-positive cycles).
Scenario: empty result on DAG
- WHEN
detectCycles([{ name: "a", dependencies: ["b"] }, { name: "b" }])is called - THEN the return value is
[]
Scenario: cycle trace identifies participants
- WHEN
detectCycles([{ name: "a", dependencies: ["b"] }, { name: "b", dependencies: ["a"] }])is called - THEN the result contains at least one string matching
/a -> b -> a|b -> a -> b/
Verified by: src/utils/topo-sort/topo-sort.test.ts::"detectCycles returns empty on DAG" AND src/utils/topo-sort/topo-sort.test.ts::"detectCycles reports cycle participants"
Requirement: SeedRunner dependency-ordered, filtered, batched execution
The package SHALL provide a SeedRunner class with constructor (registry: SeedRegistry, store: SeedStore, logger?: LoggerLike) and an async run(config: SeedRunConfig): Promise<SeedRunResult> method. run SHALL:
- Query seeds via
registry.getForEnvironment(config.environment). - Apply
config.onlyandconfig.excludefilters (onlyfirst,excludesecond). - Topologically sort the filtered seeds.
- Execute seeds in order. For each seed:
- If any declared
dependencyproduced afailedresult earlier in the run, the seed SHALL be markedstatus: "skipped"witherror: "Dependency failed", recorded inresults, surfaced viaconfig.onProgress?.(result), and the runner SHALL move on. - Otherwise, if
seed.truncateis true, callstore.truncate(seed.table)before insertion. - Resolve
seed.data(calling the factory if it is a function). - If
config.dryRun, synthesise acompletedresult withrecordsInserted = records.lengthand no store IO. - Otherwise, batch the records by
config.batchSize ?? DEFAULT_BATCH_SIZEand applyseed.upsertStrategy(default"skip"):"skip"with non-emptyupsertKeys: callstore.findExisting(...), insert only records whose key tuple is not present, count the rest asrecordsSkipped."update"with non-emptyupsertKeys: insert records whose key tuple is absent; callstore.update(...)for the rest."replace"or emptyupsertKeys: callstore.insert(...)for every record.
- If any declared
- After each seed, invoke
config.onProgress?.(result)and append toresults. - If a seed throws or
store.*rejects, wrap asSeedError({ code: SEED.STORE_ERROR, cause }), record the message onSeedResult.error, and either continue (ifcontinueOnError) or break the loop. - Return
SeedRunResultwithenvironment,totalDuration(ms fromperformance.now()),results,success(true iff zerofailed),seedsRun(non-skipped),seedsSkipped,seedsFailed.
SeedRunner SHALL NOT mutate the registry, the store's internal state beyond the documented operations, or the config object.
Scenario: dependency-ordered execution
- WHEN the registry holds
users,posts(depends onusers), andtags(no deps), andrun({ environment: "dev" })is invoked - THEN the resulting
resultsarray hasusersbeforeposts;tagsappears in topo-order with priority0
Scenario: only + exclude filters compose
- WHEN four seeds
a,b,c,dare registered and the run config setsonly: ["a", "b", "c"]andexclude: ["b"] - THEN the run executes only
aandc
Scenario: dependency failure skips dependents
- WHEN seed
afails (store throws) and seedbdeclaresdependsOn("a"), andcontinueOnError: trueis set - THEN
b's result hasstatus: "skipped"anderror: "Dependency failed";a's result hasstatus: "failed"with the underlying store error message
Scenario: dryRun bypasses store IO
- WHEN
run({ environment: "dev", dryRun: true })is invoked with aSeedStorewhoseinsertthrows if called - THEN the run completes successfully;
insertis never invoked; every result hasstatus: "completed"withrecordsInsertedequal to the resolved record count
Scenario: batching honours batchSize
- WHEN a seed has 250 records and
config.batchSize = 100 - THEN
store.insertis invoked three times with batch lengths100, 100, 50
Scenario: upsert strategy "skip"
- WHEN a seed with
upsertStrategy: "skip"andupsertKeys: ["id"]is run, andstore.findExistingreturns one record withid: 2from a batch of[{id:1}, {id:2}, {id:3}] - THEN
store.insertis invoked with[{id:1}, {id:3}], the result hasrecordsInserted: 2andrecordsSkipped: 1
Scenario: upsert strategy "update"
- WHEN a seed with
upsertStrategy: "update"andupsertKeys: ["id"]is run, andstore.findExistingreturns one record withid: 2from a batch of[{id:1}, {id:2}, {id:3}] - THEN
store.insertis invoked with[{id:1}, {id:3}](count = 2),store.updateis invoked with[{id:2}](count = 1), the result hasrecordsInserted: 2andrecordsUpdated: 1
Scenario: store error wrapped with SEED.STORE_ERROR
- WHEN an injected
store.insertrejects withnew Error("boom") - THEN the corresponding
SeedResulthasstatus: "failed"anderrorcontains"boom"; the run continues ifcontinueOnError: trueis set, or halts otherwise
Verified by: src/runner/runner.test.ts::"executes seeds in dependency order" AND src/runner/runner.test.ts::"only and exclude filters compose" AND src/runner/runner.test.ts::"dependent seeds skipped when dep fails" AND src/runner/runner.test.ts::"dryRun bypasses store IO" AND src/runner/runner.test.ts::"batches by batchSize" AND src/runner/runner.test.ts::"skip upsert strategy" AND src/runner/runner.test.ts::"update upsert strategy" AND src/runner/runner.test.ts::"wraps store errors with SEED.STORE_ERROR"
Requirement: AbortSignal plumbing on SeedRunConfig
SeedRunConfig SHALL accept an optional signal: AbortSignal. SeedRunner.run SHALL check signal.aborted between seeds; when aborted, the runner SHALL stop executing further seeds and mark every remaining seed in the topo-order with status: "skipped" and error: "Aborted". The returned SeedRunResult.success SHALL be false. The signal SHALL be propagated to SeedStore method calls via the optional signal?: AbortSignal parameter on every SeedStore method (default: ignored by InMemorySeedStore).
Scenario: aborted mid-run
- WHEN a
runis invoked with three seeds and the signal is aborted after the first seed completes - THEN the first seed's result is
completed, the second and third areskippedwitherror: "Aborted", andsuccessisfalse
Verified by: src/runner/runner.test.ts::"abort signal halts run and skips remaining seeds"
Requirement: Structured log events via injected LoggerLike
When a LoggerLike is injected into SeedRunner, the runner SHALL emit structured log records at the following moments with the listed fields. When no logger is injected, the runner SHALL behave identically to the upstream package (silent execution, no IO). Emitted records:
seed.run.start—{ environment, totalSeeds }seed.completed—{ name, table, recordsInserted, recordsUpdated, recordsSkipped, duration }seed.skipped—{ name, table, reason }wherereason∈"Dependency failed"|"Aborted"seed.failed—{ name, table, error, duration }seed.run.complete—{ environment, success, seedsRun, seedsSkipped, seedsFailed, totalDuration }
Scenario: log events emitted when logger wired
- WHEN a recording
LoggerLike(captures every call) is passed tonew SeedRunner(registry, store, logger)andrunexecutes one successful seed - THEN the recorded calls include
seed.run.start,seed.completed, andseed.run.completerecords in order, each with the documented fields
Scenario: silent execution without logger
- WHEN
new SeedRunner(registry, store)is constructed with no logger andrunexecutes any seed graph - THEN no log IO is performed and the run completes identically to the equivalent invocation with a no-op logger
Verified by: src/runner/runner.test.ts::"emits structured log events when logger is wired" AND src/runner/runner.test.ts::"runs silently when no logger is provided"
Requirement: FixtureFactory generic with deterministic incrementing index
The package SHALL provide a FixtureFactory<T extends SeedRecord> class and a createFixtureFactory<T>(defaults: (index: number) => T): FixtureFactory<T> factory function. The class SHALL expose create(overrides?: Partial<T>): T, createMany(count: number, overrides?: Partial<T>[]): T[], reset(): void, and a counter getter. The internal counter SHALL start at 0, increment by 1 for every create call (including each iteration of createMany), and reset to 0 only via reset().
Scenario: counter increments deterministically
- WHEN a factory built from
defaults: (i) => ({ id: i })is invokedcreate()three times - THEN the returned records are
{ id: 0 },{ id: 1 },{ id: 2 };counteris3;reset()returns counter to0
Scenario: per-record overrides in createMany
- WHEN
createMany(2, [{ name: "a" }, { name: "b" }])is called againstdefaults: (i) => ({ id: i, name: "default" }) - THEN the returned records are
[{ id: 0, name: "a" }, { id: 1, name: "b" }]
Verified by: src/fixtures/fixtures.test.ts::"counter increments deterministically and resets" AND src/fixtures/fixtures.test.ts::"createMany applies per-record overrides"
Requirement: InMemorySeedStore implements SeedStore + test affordances
The package SHALL provide an InMemorySeedStore class and a createInMemoryStore(): InMemorySeedStore factory implementing the full SeedStore interface (insert/update/findExisting/truncate/tableExists) plus the test affordances getRecords(table): SeedRecord[] and clear(): void. All operations SHALL be in-memory, synchronous in effect but exposed as Promise-returning to match the contract. findExisting SHALL compare records by the join of key::value tuples for the supplied keyFields. truncate(table) SHALL replace the table's contents with [] (the table key remains in the map so tableExists returns true).
Scenario: round-trip insert / findExisting / update
- WHEN records
[{id:1}, {id:2}]are inserted, thenfindExisting("t", [{id:1}, {id:3}], ["id"])is queried, thenupdate("t", [{id:1, v:"x"}], ["id"])is applied - THEN
findExistingreturns[{id:1}]; after update,getRecords("t")reflects the change to{id:1, v:"x"}
Scenario: truncate preserves table existence
- WHEN a table has records and
truncate(table)is called - THEN
getRecords(table)returns[]andtableExists(table)remainstrue
Verified by: src/fixtures/fixtures.test.ts::"in-memory store round-trips insert/findExisting/update" AND src/fixtures/fixtures.test.ts::"truncate empties table but preserves existence"
Requirement: Subpath exports tree-shake per feature module
The package.json SHALL declare subpath exports for every public feature module so consumers can import them without dragging the entire package surface. The required subpaths are exactly: . (root barrel), ./constants, ./error-codes/error-codes, ./types/common, ./builder/builder, ./registry/registry, ./runner/runner, ./fixtures/fixtures, ./utils/topo-sort/topo-sort. Each subpath SHALL resolve to a built .js + .d.ts pair under dist/. The sideEffects field SHALL be false.
Scenario: every public symbol importable via subpath
- WHEN a consumer writes
import { SeedRunner } from "@suaveplan/seed/runner/runner"andimport { topoSort } from "@suaveplan/seed/utils/topo-sort/topo-sort" - THEN both imports resolve at build time and the resolved file is the dedicated feature module, not the root barrel
Verified by: src/runner/runner.test.ts::"subpath export resolves to runner module" AND src/utils/topo-sort/topo-sort.test.ts::"subpath export resolves to topo-sort module"
Requirement: Banned patterns absent from src/
The package SHALL ship with zero matches of @deprecated, TODO, FIXME, ts-ignore, ts-expect-error, it.skip, test.skip, describe.skip, .only, raw any (the word as a TypeScript type), or console.log in any .ts or .tsx file under src/. The package SHALL ship with zero // biome-ignore or other static-analysis suppression comments. All test files SHALL import runner primitives from @suaveplan/testing/runner (rule §7).
Scenario: clean static-analysis grep
- WHEN the CI step
grep -rn "@deprecated\|TODO\|FIXME\|ts-ignore\|ts-expect-error\|\\.skip\\|\\.only\\|\\bany\\b\\|console\\.log" packages/database/seed/src/ --include="*.ts"runs - THEN the command produces zero matches
Scenario: test runner imports go through wrapper
- WHEN
grep -rnE "from ['\"](vitest|bun:test)['\"]" packages/database/seed/src/runs - THEN the command produces zero matches
Verified by: src/error-codes/error-codes.test.ts::"banned patterns absent from src" AND a CI-level scripts/lint/check-test-runner-imports.ts gate