Files
suaveplan/openspec/archive/2026-05-16-add-seed/specs/seed/spec.md
2026-05-27 23:29:53 +01:00

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 SeedResult wrapping) a SeedError whose code matches the expected SEED.* constant

Scenario: SuaveplanError contract preserved

  • WHEN a SeedError is thrown with a cause (e.g. wrapping an injected store exception)
  • THEN the resulting error is instanceof SuaveplanError, exposes the original on error.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" and table: "users"

Scenario: optional fields omitted when unset

  • WHEN a caller invokes defineSeed("u").data([{ id: 1 }]).build()
  • THEN the returned definition has no environments, no dependencies, no upsertKeys, no description, no truncate field on the output

Scenario: dataFactory wins over data

  • WHEN a caller invokes defineSeed("u").data([{ a: 1 }]).dataFactory(() => [{ a: 2 }])
  • THEN build().data is 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 SeedError with code === "SEED.DUPLICATE_NAME" and seedName === "x"

Scenario: defensive copies on read

  • WHEN a caller mutates the array returned from registry.getAll()
  • THEN subsequent calls to registry.get or registry.getAll return 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 SeedError is thrown with code === "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 SeedError is thrown with code === "SEED.CIRCULAR_DEPENDENCY" and the message lists both a and b

Scenario: upstream test corpus passes verbatim

  • WHEN every test case from ../suaveplan/packages/server/seed/src/utils/topo-sort.test.ts is 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 SeedError instead of the upstream Error

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:

  1. Query seeds via registry.getForEnvironment(config.environment).
  2. Apply config.only and config.exclude filters (only first, exclude second).
  3. Topologically sort the filtered seeds.
  4. Execute seeds in order. For each seed:
    • If any declared dependency produced a failed result earlier in the run, the seed SHALL be marked status: "skipped" with error: "Dependency failed", recorded in results, surfaced via config.onProgress?.(result), and the runner SHALL move on.
    • Otherwise, if seed.truncate is true, call store.truncate(seed.table) before insertion.
    • Resolve seed.data (calling the factory if it is a function).
    • If config.dryRun, synthesise a completed result with recordsInserted = records.length and no store IO.
    • Otherwise, batch the records by config.batchSize ?? DEFAULT_BATCH_SIZE and apply seed.upsertStrategy (default "skip"):
      • "skip" with non-empty upsertKeys: call store.findExisting(...), insert only records whose key tuple is not present, count the rest as recordsSkipped.
      • "update" with non-empty upsertKeys: insert records whose key tuple is absent; call store.update(...) for the rest.
      • "replace" or empty upsertKeys: call store.insert(...) for every record.
  5. After each seed, invoke config.onProgress?.(result) and append to results.
  6. If a seed throws or store.* rejects, wrap as SeedError({ code: SEED.STORE_ERROR, cause }), record the message on SeedResult.error, and either continue (if continueOnError) or break the loop.
  7. Return SeedRunResult with environment, totalDuration (ms from performance.now()), results, success (true iff zero failed), 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 on users), and tags (no deps), and run({ environment: "dev" }) is invoked
  • THEN the resulting results array has users before posts; tags appears in topo-order with priority 0

Scenario: only + exclude filters compose

  • WHEN four seeds a, b, c, d are registered and the run config sets only: ["a", "b", "c"] and exclude: ["b"]
  • THEN the run executes only a and c

Scenario: dependency failure skips dependents

  • WHEN seed a fails (store throws) and seed b declares dependsOn("a"), and continueOnError: true is set
  • THEN b's result has status: "skipped" and error: "Dependency failed"; a's result has status: "failed" with the underlying store error message

Scenario: dryRun bypasses store IO

  • WHEN run({ environment: "dev", dryRun: true }) is invoked with a SeedStore whose insert throws if called
  • THEN the run completes successfully; insert is never invoked; every result has status: "completed" with recordsInserted equal to the resolved record count

Scenario: batching honours batchSize

  • WHEN a seed has 250 records and config.batchSize = 100
  • THEN store.insert is invoked three times with batch lengths 100, 100, 50

Scenario: upsert strategy "skip"

  • WHEN a seed with upsertStrategy: "skip" and upsertKeys: ["id"] is run, and store.findExisting returns one record with id: 2 from a batch of [{id:1}, {id:2}, {id:3}]
  • THEN store.insert is invoked with [{id:1}, {id:3}], the result has recordsInserted: 2 and recordsSkipped: 1

Scenario: upsert strategy "update"

  • WHEN a seed with upsertStrategy: "update" and upsertKeys: ["id"] is run, and store.findExisting returns one record with id: 2 from a batch of [{id:1}, {id:2}, {id:3}]
  • THEN store.insert is invoked with [{id:1}, {id:3}] (count = 2), store.update is invoked with [{id:2}] (count = 1), the result has recordsInserted: 2 and recordsUpdated: 1

Scenario: store error wrapped with SEED.STORE_ERROR

  • WHEN an injected store.insert rejects with new Error("boom")
  • THEN the corresponding SeedResult has status: "failed" and error contains "boom"; the run continues if continueOnError: true is 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 run is 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 are skipped with error: "Aborted", and success is false

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 } where reason ∈ "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 to new SeedRunner(registry, store, logger) and run executes one successful seed
  • THEN the recorded calls include seed.run.start, seed.completed, and seed.run.complete records in order, each with the documented fields

Scenario: silent execution without logger

  • WHEN new SeedRunner(registry, store) is constructed with no logger and run executes 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 invoked create() three times
  • THEN the returned records are { id: 0 }, { id: 1 }, { id: 2 }; counter is 3; reset() returns counter to 0

Scenario: per-record overrides in createMany

  • WHEN createMany(2, [{ name: "a" }, { name: "b" }]) is called against defaults: (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, then findExisting("t", [{id:1}, {id:3}], ["id"]) is queried, then update("t", [{id:1, v:"x"}], ["id"]) is applied
  • THEN findExisting returns [{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 [] and tableExists(table) remains true

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" and import { 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