Initial Commit
This commit is contained in:
254
openspec/archive/2026-05-16-add-seed/design.md
Normal file
254
openspec/archive/2026-05-16-add-seed/design.md
Normal file
@@ -0,0 +1,254 @@
|
||||
# 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.
|
||||
91
openspec/archive/2026-05-16-add-seed/proposal.md
Normal file
91
openspec/archive/2026-05-16-add-seed/proposal.md
Normal file
@@ -0,0 +1,91 @@
|
||||
# Add @suaveplan/seed — declarative database seeding & fixture framework
|
||||
|
||||
**Package**: `@suaveplan/seed`
|
||||
**Status**: PROPOSED
|
||||
**Type**: migration (upstream `../suaveplan/packages/server/seed`)
|
||||
|
||||
- **Design**: ./design.md
|
||||
- **Spec delta**: ./specs/seed/spec.md
|
||||
|
||||
## Why
|
||||
|
||||
`STATUS.md` line 200 lists `@suaveplan/seed` as Pending. The upstream package is a small, runtime-pure orchestration core (3 features, 1 utility, fixtures, ~520 LOC of `src/`) that solves a problem genesis has no other answer for: declaratively seeding databases with dependency-ordered, idempotent, environment-filtered, batched, dry-run-capable runs against any storage adapter the caller injects. No genesis package covers this surface today, and several pending downstream packages — anything in the database or fixtures-oriented testing space — will want to depend on it. The upstream is well-shaped (98.85% test coverage), tier-pure (zero `node:`/`bun:` imports — only `performance.now()`), and dependency-light (single runtime dep on `@suaveplan/error`).
|
||||
|
||||
The migration is straightforward — preserve the public surface, drop the redundant `core/` wrapper + `seed-` filename prefix per the no-redundant-prefixes rule, upgrade plain `throw new Error(...)` to `SuaveplanError` with a `SEED.*` code registry, rewrite the buggy first-pass in `topoSort` cleanly, and ship with injectable `LoggerLike`/tracer slots (no-op defaults) consistent with every other genesis package. The enterprise audit (`../suaveplan/enterprise/seed.md`) lists ambitious additions (SurrealDB schema migrations, JWT tenant scoping, RBAC, audit logging, rate limiting, webhooks) — these are explicitly out of scope for this change and belong in a follow-up `add-api-plugin-seed` proposal that wraps the core behind a plugin tier.
|
||||
|
||||
## What changes
|
||||
|
||||
- ADDED: `SeedRunner` class — orchestrates `SeedRunConfig` runs against an injected `SeedStore`, with dependency-ordered execution (topological sort), `only`/`exclude` filtering, `dryRun`, `continueOnError`, per-batch processing (default `DEFAULT_BATCH_SIZE = 100`, configurable), three upsert strategies (`skip`/`update`/`replace`), per-seed `truncate`, and `onProgress` callback emission per completed seed.
|
||||
- ADDED: `SeedRegistry` class — `register` / `registerMany` / `get` / `getAll` / `getForEnvironment(env)` / `has` / `remove` / `clear` / `size` / `names`; in-memory `Map<name, SeedDefinition>` with defensive-copy on read and `SEED.DUPLICATE_NAME` raised on re-registration.
|
||||
- ADDED: `SeedBuilder` fluent class + `defineSeed(name)` convenience — chainable methods `table` / `environments(...envs)` / `dependsOn(...names)` / `data(records)` / `dataFactory(fn)` / `upsert(strategy, keys)` / `description(desc)` / `priority(n)` / `truncateFirst(enabled?)` / `build()`.
|
||||
- ADDED: `FixtureFactory<T>` + `createFixtureFactory(defaults)` — auto-incrementing counter, `create(overrides?)`, `createMany(count, overrides?)`, `reset()`, `counter` getter.
|
||||
- ADDED: `InMemorySeedStore` + `createInMemoryStore()` — full `SeedStore` shape (`insert`/`update`/`findExisting`/`truncate`/`tableExists`) plus test-affordance `getRecords(table)` and `clear()`.
|
||||
- ADDED: `topoSort(seeds)` utility — Kahn's algorithm with priority tiebreak; raises `SEED.UNREGISTERED_DEPENDENCY` for dangling names, `SEED.CIRCULAR_DEPENDENCY` for cycles, `SEED.DEPENDENCY_DEPTH_EXCEEDED` when iterations exceed `MAX_DEPENDENCY_DEPTH × seedCount`.
|
||||
- ADDED: `detectCycles(seeds)` utility — DFS-based cycle reporter returning `string[]` of `a -> b -> a` traces; empty array on clean graph.
|
||||
- ADDED: `SeedDefinition` / `SeedRecord` / `SeedDataFactory` / `UpsertStrategy` / `SeedEnvironment` / `SeedStatus` / `SeedResult` / `SeedRunResult` / `SeedRunConfig` / `SeedStore` types in `types/common.ts`; `ISeedBuilder` / `ISeedRegistry` / `ISeedRunner` interface contracts co-located with each implementation.
|
||||
- ADDED: Constants — `DEFAULT_ENVIRONMENTS` (`["dev","staging","test","production"]` as const), `MAX_DEPENDENCY_DEPTH = 50`, `DEFAULT_BATCH_SIZE = 100`, `SEED_STATUS` enum object.
|
||||
- ADDED: `SEED_CODES` registry via `defineCodes("SEED", [...])` — 7 codes: `DUPLICATE_NAME`, `UNREGISTERED_DEPENDENCY`, `CIRCULAR_DEPENDENCY`, `DEPENDENCY_DEPTH_EXCEEDED`, `INVALID_CONFIG`, `STORE_ERROR`, `ABORTED`.
|
||||
- ADDED: `SeedError extends SuaveplanError<SeedErrorCode>` with `seedName?` / `dependency?` / `path?` discriminants for diagnostics.
|
||||
- ADDED: Optional injectable `LoggerLike` (`@suaveplan/types`) on `SeedRunner` constructor — no-op default. Logs `seed.run.start` / `seed.run.complete` / `seed.skipped` / `seed.failed` structured records when wired.
|
||||
- ADDED: Optional `AbortSignal` plumbing on `SeedRunConfig.signal` — checked at every seed boundary; aborted runs return a `SeedRunResult` with `success: false` and remaining seeds marked `status: "skipped"` with `error: "Aborted"`. (Upstream had no signal support.)
|
||||
- ADDED: Subpath exports `./runner/runner`, `./registry/registry`, `./builder/builder`, `./fixtures/fixtures`, `./utils/topo-sort/topo-sort`, `./types/common`, `./constants` — tree-shakeable.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- **SurrealDB plugin wiring, `definePlugin()` lifecycle, `./surql` schema directory, `migrationsPath`.** The enterprise audit calls these out as required for the package to flip to ✅ Enterprise, but they belong to a higher-tier `api-plugin-seed` package that consumes `@suaveplan/seed` and provides the plugin shell. Keeping the core universal-tier is what makes it portable across server, browser-test, and worker contexts. Reason: same separation `drive` ↔ `drive-template`, `network` ↔ `network-plugin-*`.
|
||||
- **JWT tenant scoping, RBAC/ABAC, rate limiting, idempotency headers, audit logging, webhook delivery.** All API-tier concerns; none belong in a pure orchestration core. Reason: orthogonal to seeding mechanics; the seed runner takes data and a store and writes records — auth lives at the HTTP boundary that wraps it.
|
||||
- **Real SurrealDB integration test suite via `bun run test:with-db:filter`.** The core ships with an `InMemorySeedStore` for testing, and the real-database integration belongs to whichever adapter package provides the production `SeedStore` (e.g. a future `seed-store-surrealdb`). Reason: the core is store-agnostic by design; coupling tests to one DB violates that contract.
|
||||
- **Benchmarks & CI regression gate.** Useful but not blocking for a pure orchestration core whose hot path is bounded by the injected store's throughput. Defer to enterprise wave. Reason: meaningful benchmarks require a real store; the in-memory store benchmarks are testing JavaScript loops.
|
||||
- **i18n message catalog for error messages.** The error codes are stable; the human-readable messages are developer-facing English strings. When `i18n` lands on the genesis enterprise path for errors broadly, `SEED.*` codes inherit the same treatment. Reason: not seed-specific.
|
||||
- **`SeedDefinition` Zod validation at the boundary.** The fluent builder + TypeScript types are the public contract; the developer-facing API is constructed in-process, not over a wire. Adding runtime Zod would be defensive coding without a real attack surface. Reason: orthogonal to the API surface this package exposes.
|
||||
|
||||
## Source mapping
|
||||
|
||||
Upstream source: `../suaveplan/packages/server/seed/src/` (23 files, 98.85% upstream coverage). Every public symbol in upstream `src/index.ts` migrates as-is; layout normalises per the no-prefix and feature-folder rules.
|
||||
|
||||
### Parity Checklist
|
||||
|
||||
| Capability | Upstream | Migrated | Notes |
|
||||
|---|---|---|---|
|
||||
| `SeedBuilder` fluent class + `defineSeed` | ✅ | ✅ | Class & function preserved; subpath `./builder/builder` (renamed from `./core/seed-builder/seed-builder`) |
|
||||
| `SeedRegistry` with env-filter, dup-detect | ✅ | ⬆️ Enhanced | `throw new Error("already registered")` → `SeedError({ code: SEED.DUPLICATE_NAME, seedName })` |
|
||||
| `SeedRunner` with topo-order, filtering, batching, upsert, dry-run, continue-on-error, progress callback | ✅ | ✅ | Behaviour preserved bit-for-bit; constructor gains optional `LoggerLike`, config gains optional `signal` |
|
||||
| `topoSort` with priority tiebreak | ✅ | ⬆️ Enhanced | Upstream has dead-code first pass (`+ 0` no-op) and a second cleanup pass — rewritten cleanly as one Kahn's-algorithm pass; `throw new Error(...)` replaced with `SeedError` |
|
||||
| `detectCycles` DFS reporter | ✅ | ✅ | Unchanged logic; relocated to `utils/topo-sort/topo-sort.ts` |
|
||||
| `FixtureFactory<T>` + `createFixtureFactory` | ✅ | ✅ | Generic preserved; `counter` getter preserved |
|
||||
| `InMemorySeedStore` + `createInMemoryStore` | ✅ | ✅ | Full `SeedStore` surface + `getRecords` / `clear` test affordances preserved |
|
||||
| All public types from `types/common.ts` | ✅ | ✅ | Re-exported from root; subpath `./types/common` added |
|
||||
| 4 constants (`DEFAULT_ENVIRONMENTS`, `MAX_DEPENDENCY_DEPTH`, `DEFAULT_BATCH_SIZE`, `SEED_STATUS`) | ✅ | ✅ | Preserved; subpath `./constants` added |
|
||||
| Subpath exports per public module | ✅ | ⬆️ Enhanced | Upstream exposes 4 subpaths under `./core/seed-*` and `./fixtures/fixtures`; genesis exposes 7 subpaths with the redundant `core/` and `seed-` prefix dropped |
|
||||
| Plain `throw new Error(...)` at boundaries | ✅ | ⬆️ Replaced | All raised errors now extend `SuaveplanError` with stable `SEED.*` codes (rule §63 of CLAUDE.md) |
|
||||
| `LoggerLike` injection | 🚫 upstream | ⬆️ Added | Genesis convention; no-op default, zero runtime cost when unwired |
|
||||
| `AbortSignal` plumbing | 🚫 upstream | ⬆️ Added | `SeedRunConfig.signal` checked at every seed boundary; consistent with `storage-http`/`drive` precedent |
|
||||
| Plugin wiring (`definePlugin`, lifecycle) | ✅ enterprise audit asks for it | 🚫 deferred | Scoped to `add-api-plugin-seed` follow-up; the core stays universal-tier |
|
||||
| SurrealDB schema migrations / `./surql` | ✅ enterprise audit asks for it | 🚫 deferred | Belongs to a future store adapter package; the core is store-agnostic |
|
||||
| JWT tenant / RBAC / rate-limit / audit / webhooks / idempotency | ✅ enterprise audit asks for it | 🚫 deferred | API-tier concerns; deferred to `add-api-plugin-seed` |
|
||||
| OTel spans / Prometheus metrics | ✅ enterprise audit asks for it | 🚫 deferred | Wire when the `api-plugin-seed` package lands and there's a real call site; the core emits structured logs via `LoggerLike` only |
|
||||
| Benchmarks (`src/__benchmarks__`) + CI gate | ✅ enterprise audit asks for it | 🚫 deferred | Requires real store; defer to enterprise wave |
|
||||
| `@suaveplan/api-schemas` Zod boundary | ✅ enterprise audit asks for it | 🚫 deferred | Not applicable to in-process developer API; revisit if seed config is ever serialised over a wire |
|
||||
|
||||
### Reference docs consulted
|
||||
|
||||
- ✅ `../suaveplan/packages/server/seed/src/` — all 17 implementation files read
|
||||
- ✅ `../suaveplan/enterprise/seed.md` — enterprise audit, in full
|
||||
- ✅ `../suaveplan/packages/server/seed/package.json` — dep graph, public exports
|
||||
- 🚫 `../suaveplan/openspec/specs/seed/spec.md` — upstream had no formal spec for this package; the parity contract is the source itself
|
||||
- 🚫 `../suaveplan/openspec/changes/*/proposal.md` touching seed — none found
|
||||
|
||||
## Impact
|
||||
|
||||
### Affected dependents
|
||||
|
||||
No dependents today — `@suaveplan/seed` is being added net-new. Future consumers (`api-plugin-seed`, any package shipping a `SeedStore` adapter, integration test fixtures) will land in subsequent changes against the public contract this proposal locks in.
|
||||
|
||||
### Risk
|
||||
|
||||
- **Low.** Pure orchestration; no IO of its own. The buggy `topoSort` first-pass rewrite is the only non-mechanical change to upstream behaviour — covered by adding explicit unit tests against the upstream test suite to prove output-equivalent ordering before the rewrite.
|
||||
- **Naming change to subpath exports** (`./core/seed-runner/seed-runner` → `./runner/runner`) is technically a public-API rename, but the package is at `0.1.0` on genesis with zero consumers, so there is no migration path to worry about.
|
||||
|
||||
### Migration notes
|
||||
|
||||
None. Net-new package on genesis.
|
||||
306
openspec/archive/2026-05-16-add-seed/specs/seed/spec.md
Normal file
306
openspec/archive/2026-05-16-add-seed/specs/seed/spec.md
Normal file
@@ -0,0 +1,306 @@
|
||||
<!-- markdownlint-disable MD024 -->
|
||||
|
||||
# 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
|
||||
112
openspec/archive/2026-05-16-add-seed/tasks.md
Normal file
112
openspec/archive/2026-05-16-add-seed/tasks.md
Normal file
@@ -0,0 +1,112 @@
|
||||
# Tasks — seed
|
||||
|
||||
> Every checkbox MUST reflect work actually done in this change.
|
||||
|
||||
## 1. Spec authoring
|
||||
|
||||
- [x] 1.1 Read `openspec/AGENTS.md` §0, §0a, §0b, §0c, §6a, §7, §13, §14 end-to-end
|
||||
- [x] 1.2 Read `../suaveplan/packages/server/seed/src/` — all 17 implementation files
|
||||
- [x] 1.3 Read `../suaveplan/enterprise/seed.md` in full
|
||||
- [x] 1.4 Read `openspec/archive/2026-05-16-add-storage-http/` end-to-end as the closest precedent (universal-tier, error-coded, AbortSignal-plumbed migration)
|
||||
- [x] 1.5 Draft `proposal.md` with parity checklist, source mapping, affected dependents
|
||||
- [x] 1.6 Draft `design.md` covering layout, rename rationale, error-code map, topoSort rewrite, LoggerLike injection, AbortSignal plumbing
|
||||
- [x] 1.7 Draft `specs/seed/spec.md` in OpenSpec `### Requirement: / #### Scenario:` form
|
||||
- [x] 1.8 Every requirement carries a `**Verified by:**` line naming concrete planned `it(...)` titles
|
||||
- [x] 1.9 Build the numbered Requirement/Scenario implementation checklist (§0c step 6)
|
||||
|
||||
## 2. Scaffolding
|
||||
|
||||
- [x] 2.1 Create `packages/database/seed/` mirroring the storage-http layout
|
||||
- [x] 2.2 Write `package.json` v0.0.0, `"sideEffects": false`, subpath exports for `./builder/builder`, `./registry/registry`, `./runner/runner`, `./fixtures/fixtures`, `./utils/topo-sort/topo-sort`, `./types/common`, `./constants`, `./error-codes/error-codes`
|
||||
- [x] 2.3 Wire deps: `@suaveplan/error: workspace:^`, `@suaveplan/types: workspace:^`, `tslib: catalog:build`
|
||||
- [x] 2.4 Wire devDeps: `@suaveplan/testing: workspace:*`, `@vitest/coverage-istanbul: catalog:`, `fast-check: catalog:`, `typescript: catalog:`, `vite: catalog:build`, `vite-plugin-dts: catalog:build`, `vitest: catalog:`
|
||||
- [x] 2.5 Write `tsconfig.json`, `tsconfig.lib.json` extending `@suaveplan/typescript-config`
|
||||
- [x] 2.6 Write `vite.config.ts` (multi-entry build for every subpath export)
|
||||
- [x] 2.7 Write `vitest.config.ts` using `createTestConfig()` from `@suaveplan/testing/config`
|
||||
- [x] 2.8 Write `README.md` (≥ 200 words, required sections per CLAUDE.md docs gate)
|
||||
|
||||
## 3. Implementation — constants & errors
|
||||
|
||||
- [x] 3.1 `src/constants/index.ts` — `DEFAULT_ENVIRONMENTS`, `MAX_DEPENDENCY_DEPTH`, `DEFAULT_BATCH_SIZE`, `SEED_STATUS` (as-const)
|
||||
- [x] 3.2 `src/error-codes/error-codes.ts` — `defineCodes("SEED", [...])` registry (7 codes), `SeedError extends SuaveplanError<SeedErrorCode>` with `seedName?` / `dependency?` discriminants
|
||||
- [x] 3.3 `src/error-codes/error-codes.test.ts` — every code is reachable via `SeedError` construction; cause-chain preservation verified
|
||||
- [x] 3.4 `src/error-codes/error-codes.md` — Purpose / Features / Usage Basic / Usage Advanced / API Reference / Implementation Notes
|
||||
|
||||
## 4. Implementation — types
|
||||
|
||||
- [x] 4.1 `src/types/common.ts` — every public type from upstream `types/common.ts` preserved verbatim; `SeedStore` methods gain optional `signal?: AbortSignal`; `SeedRunConfig` gains optional `signal?: AbortSignal`
|
||||
- [x] 4.2 `src/types/common.md` — co-located doc per docs-coverage gate
|
||||
|
||||
## 5. Implementation — builder
|
||||
|
||||
- [x] 5.1 `src/builder/builder.types.ts` — `ISeedBuilder` interface mirroring the fluent surface
|
||||
- [x] 5.2 `src/builder/builder.ts` — `SeedBuilder` class + `defineSeed(name)` convenience, behaviour-identical to upstream
|
||||
- [x] 5.3 `src/builder/builder.test.ts` — every fluent method, including default-table-equals-name, environments empty-vs-set, dataFactory wins over data, build() output shape
|
||||
- [x] 5.4 `src/builder/builder.md` — Purpose / Features / Usage Basic / Usage Advanced / API Reference / Implementation Notes
|
||||
- [x] 5.5 `src/builder/index.ts` — explicit named re-exports (no `export *`)
|
||||
|
||||
## 6. Implementation — registry
|
||||
|
||||
- [x] 6.1 `src/registry/registry.types.ts` — `ISeedRegistry` interface
|
||||
- [x] 6.2 `src/registry/registry.ts` — `SeedRegistry` class; `register` raises `SeedError({ code: SEED.DUPLICATE_NAME, seedName })` on collision; `getForEnvironment` returns defensive copies
|
||||
- [x] 6.3 `src/registry/registry.test.ts` — register/registerMany/get/getAll/getForEnvironment/has/remove/clear/size/names + duplicate-name error
|
||||
- [x] 6.4 `src/registry/registry.md`
|
||||
- [x] 6.5 `src/registry/index.ts` — explicit named re-exports
|
||||
|
||||
## 7. Implementation — topo-sort
|
||||
|
||||
- [x] 7.1 `src/utils/topo-sort/topo-sort.ts` — clean Kahn's-algorithm rewrite per design.md; `topoSort` + `detectCycles`; raises `SeedError` with `UNREGISTERED_DEPENDENCY`/`CIRCULAR_DEPENDENCY`/`DEPENDENCY_DEPTH_EXCEEDED`
|
||||
- [x] 7.2 `src/utils/topo-sort/topo-sort.test.ts` — port every upstream test case verbatim (characterisation), plus property-based DAG tests via `fast-check`, plus cycle-reporter tests
|
||||
- [x] 7.3 `src/utils/topo-sort/topo-sort.md`
|
||||
- [x] 7.4 `src/utils/topo-sort/index.ts` — explicit named re-exports
|
||||
|
||||
## 8. Implementation — runner
|
||||
|
||||
- [x] 8.1 `src/runner/runner.types.ts` — `ISeedRunner` interface
|
||||
- [x] 8.2 `src/runner/runner.ts` — `SeedRunner` class:
|
||||
- constructor `(registry, store, logger?)` with no-op `LoggerLike` default
|
||||
- `run(config)` applies `only`/`exclude` filter, env-filter, topo-sort
|
||||
- per-seed: skip if dep failed, honour `truncate`, batch by `batchSize ?? DEFAULT_BATCH_SIZE`, apply `skip`/`update`/`replace` strategy
|
||||
- `dryRun` short-circuit returns synthesised result without store IO
|
||||
- `signal` checked before each seed → remaining seeds marked `skipped` with `error: "Aborted"`
|
||||
- `continueOnError` controls whether failure halts the run
|
||||
- `onProgress` callback emits per completed seed
|
||||
- structured log events (`seed.run.start`, `seed.run.complete`, `seed.completed`, `seed.skipped`, `seed.failed`)
|
||||
- any error thrown by injected `SeedStore` wrapped as `SeedError({ code: SEED.STORE_ERROR, cause })` and recorded on `SeedResult.error`
|
||||
- [x] 8.3 `src/runner/runner.test.ts` — every behaviour above; mirror upstream test names where applicable; integrate with `InMemorySeedStore`
|
||||
- [x] 8.4 `src/runner/runner.md`
|
||||
- [x] 8.5 `src/runner/index.ts` — explicit named re-exports
|
||||
|
||||
## 9. Implementation — fixtures
|
||||
|
||||
- [x] 9.1 `src/fixtures/fixtures.types.ts` — if any local types needed
|
||||
- [x] 9.2 `src/fixtures/fixtures.ts` — `FixtureFactory<T>`, `createFixtureFactory`, `InMemorySeedStore`, `createInMemoryStore` (preserve upstream `SeedStore` shape; gain optional `signal?` plumbing as no-op)
|
||||
- [x] 9.3 `src/fixtures/fixtures.test.ts` — counter, overrides, createMany, store insert/update/findExisting/truncate/tableExists/getRecords/clear
|
||||
- [x] 9.4 `src/fixtures/fixtures.md`
|
||||
- [x] 9.5 `src/fixtures/index.ts` — explicit named re-exports
|
||||
|
||||
## 10. Root barrel & public surface
|
||||
|
||||
- [x] 10.1 `src/index.ts` — re-export every symbol listed in design.md "Package contract surface" using explicit named exports (no `export *` — rule §6a)
|
||||
- [x] 10.2 Verify `bunx turbo build --filter=@suaveplan/seed` succeeds and dist contains every advertised subpath
|
||||
|
||||
## 11. Pre-archive gates
|
||||
|
||||
- [x] 11.1 `bunx turbo lint --filter=@suaveplan/seed` green; zero biome suppressions
|
||||
- [x] 11.2 `bunx turbo typecheck --filter=@suaveplan/seed` green
|
||||
- [x] 11.3 `bunx turbo test --filter=@suaveplan/seed` green; zero `.skip` / `.only` / `it.todo`
|
||||
- [x] 11.4 `bunx turbo test:coverage --filter=@suaveplan/seed` green at 100% statement/branch/function/line (Istanbul)
|
||||
- [x] 11.5 `bun run docs:check --filter=@suaveplan/seed` green
|
||||
- [x] 11.6 `grep -rnE "from ['\"](vitest|bun:test)['\"]" packages/database/seed/src/` → zero matches (rule §7)
|
||||
- [x] 11.7 `grep -rnE "^export \*" packages/database/seed/src/` → only the root barrel and exempt forms (rule §6a)
|
||||
- [x] 11.8 `grep -rnE "from ['\"](node:|bun:|fs|path|stream|net|http)['\"]" packages/database/seed/src/` → zero matches (tier purity)
|
||||
- [x] 11.9 `grep -rn "@deprecated\|TODO\|FIXME\|ts-ignore\|ts-expect-error\|\\bany\\b\|console\\.log" packages/database/seed/src/ --include="*.ts"` → zero matches
|
||||
- [x] 11.10 Every `### Requirement` in `specs/seed/spec.md` checked off against the implementation checklist from §1.9
|
||||
- [x] 11.11 `bun scripts/folder-reorg/generate-manifest.ts` run; `MANIFEST.md` includes `@suaveplan/seed`
|
||||
- [x] 11.12 `STATUS.md` `@suaveplan/seed` row flipped from `–` to `✅`
|
||||
|
||||
## 12. Fold + archive
|
||||
|
||||
- [x] 12.1 Fold `specs/seed/spec.md` into `openspec/specs/seed/spec.md` (canonical file is created here for the first time, since the package is net-new)
|
||||
- [x] 12.2 Move `openspec/changes/add-seed/` to `openspec/archive/<yyyy-mm-dd>-add-seed/`
|
||||
- [x] 12.3 Commit per CLAUDE.md commit-style; do not push without user approval
|
||||
Reference in New Issue
Block a user