255 lines
11 KiB
Markdown
255 lines
11 KiB
Markdown
# 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.
|