Files
2026-05-27 23:29:53 +01:00

255 lines
11 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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<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:
```ts
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:
```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.