Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View File

@@ -0,0 +1,189 @@
# @suaveplan/seed - Universal declarative database seeding and fixture framework: SeedBuilder fluent API, SeedRegistry with environment filters, SeedRunner with topological ordering, upsert strategies, batching, dry-run, AbortSignal, injectable LoggerLike, FixtureFactory, InMemorySeedStore, store-agnostic.
Universal declarative database seeding and fixture framework. Define seed data once, let the runner handle dependency ordering, environment filtering, idempotent upserts, batching, dry-runs, and clean cancellation. The runner is store-agnostic: any object satisfying the `SeedStore` interface plugs in, so the same seed definitions drive in-memory tests, local SurrealDB instances, and production databases without rewriting the data layer.
<!-- begin:auto-docs -->
## Documentation tree
- [builder](./src/builder/builder.md) — `SeedBuilder` is the fluent, chainable API used to construct immutable `SeedDefinition` records. It exists so that call…
- [builder](./src/builder/builder.md) — `SeedBuilder` is the fluent, chainable API used to construct immutable `SeedDefinition` records. It exists so that call…
- [error-codes](./src/error-codes/error-codes.md) — The `@suaveplan/seed` package centralises every failure mode it can produce behind a single registry built with `define…
- [error-codes](./src/error-codes/error-codes.md) — The `@suaveplan/seed` package centralises every failure mode it can produce behind a single registry built with `define…
- [fixtures](./src/fixtures/fixtures.md) — The `fixtures` module supplies the two universal building blocks used to exercise seed definitions without a real…
- [fixtures](./src/fixtures/fixtures.md) — The `fixtures` module supplies the two universal building blocks used to exercise seed definitions without a real…
- [registry](./src/registry/registry.md) — `SeedRegistry` is the canonical in-memory store for `SeedDefinition` entries used by `@suaveplan/seed`. It owns inserti…
- [registry](./src/registry/registry.md) — `SeedRegistry` is the canonical in-memory store for `SeedDefinition` entries used by `@suaveplan/seed`. It owns inserti…
- [runner](./src/runner/runner.md) — The `SeedRunner` orchestrates a single seeding run against an injected `SeedStore`. It is the public entry point for ac…
- [execute](./src/runner/execute.md) — The `executeSeed` function applies one `SeedDefinition` to one `SeedStore`, returning a single `SeedResult`. It is the…
- [runner](./src/runner/runner.md) — The `SeedRunner` orchestrates a single seeding run against an injected `SeedStore`. It is the public entry point for ac…
- [types](./src/types/common.md) — Shared type module for `@suaveplan/seed`. Every public class and function in the package — `SeedBuilder`, `SeedRegistry…
- [common](./src/types/common.md) — Shared type module for `@suaveplan/seed`. Every public class and function in the package — `SeedBuilder`, `SeedRegistry…
- [topo-sort](./src/utils/topo-sort/topo-sort.md) — `topo-sort` is the dependency-resolution engine that lets `@suaveplan/seed` execute seed definitions in an order where…
- [topo-sort](./src/utils/topo-sort/topo-sort.md) — `topo-sort` is the dependency-resolution engine that lets `@suaveplan/seed` execute seed definitions in an order where…
<!-- end:auto-docs -->
## Purpose
Ships a handful of database adapters (`db`, `db-sqlite`, `surrealdb`, `migration`) but no consistent way to populate them. Hand-rolled per-package seeders drift in shape, swallow errors, skip dependency ordering, and re-insert duplicates on every run. `@suaveplan/seed` consolidates the orchestration concerns — registration, topological execution, environment scoping, batched upserts, dry-runs, progress reporting, and cancellation — behind one pluggable contract. The package is intentionally universal: zero `node:`/`bun:` imports, no IO of its own, and a single runtime dependency on `@suaveplan/error`. Adapters that need a production store ship in dedicated `seed-store-*` packages and implement the `SeedStore` interface; the core runs unchanged in tests, server processes, and worker contexts.
## Features
- **Fluent `SeedBuilder` + `defineSeed(name)` convenience** — chainable API for declaring tables, dependencies, environments, upsert strategies, priorities, and per-seed truncation
- **`SeedRegistry`** — duplicate-name detection (typed `SEED.DUPLICATE_NAME`), defensive-copy reads, environment-aware queries
- **`SeedRunner`** — Kahn's-algorithm topological sort with priority tiebreak, `only`/`exclude` filtering, three upsert strategies (`skip`/`update`/`replace`), configurable batching, `dryRun` mode, `continueOnError`, per-seed `onProgress` callback
- **`AbortSignal` plumbing** — abort mid-run; remaining seeds are reported as `skipped` with `error: "Aborted"`
- **Injectable `LoggerLike`** (`@suaveplan/types`) — structured events (`seed.run.start`, `seed.completed`, `seed.skipped`, `seed.failed`, `seed.run.complete`); no-op default keeps the runtime silent when unwired
- **`FixtureFactory<T>`** — deterministic per-record overrides with auto-incrementing index, `createMany`, `reset`
- **`InMemorySeedStore`** — full `SeedStore` shape with `getRecords`/`clear` test affordances
- **`SeedError` + `SEED_CODES` registry** — every public failure carries a stable `SEED.*` code and threads `cause` per the `@suaveplan/error` contract
- **Tree-shakeable subpath exports** — import `@suaveplan/seed/runner/runner` without pulling fixtures
- **100% statement/branch/function/line coverage** via Istanbul
## Usage: Basic
```ts
import {
defineSeed,
SeedRegistry,
SeedRunner,
createInMemoryStore,
} from "@suaveplan/seed";
const registry = new SeedRegistry();
registry.register(
defineSeed("users")
.table("user")
.data([
{ id: 1, name: "alice" },
{ id: 2, name: "bob" },
])
.upsert("skip", ["id"])
.build(),
);
registry.register(
defineSeed("posts")
.dependsOn("users")
.data([
{ id: 1, authorId: 1, title: "Hello" },
{ id: 2, authorId: 2, title: "World" },
])
.upsert("skip", ["id"])
.build(),
);
const store = createInMemoryStore();
const runner = new SeedRunner(registry, store);
const result = await runner.run({ environment: "dev" });
console.log(result.success, result.seedsRun, result.seedsSkipped);
```
## Usage: Advanced
```ts
import {
defineSeed,
SeedRegistry,
SeedRunner,
type LoggerLike,
} from "@suaveplan/seed";
import { mySurrealStore } from "./my-surreal-store";
const registry = new SeedRegistry();
registry.registerMany([
defineSeed("tenants")
.environments("dev", "staging")
.data(async () => fetchTenantsFromCsv())
.upsert("update", ["slug"])
.priority(0)
.build(),
defineSeed("admin-users")
.dependsOn("tenants")
.dataFactory(async () => seedFactoryFor(currentTenant))
.upsert("update", ["tenantId", "email"])
.priority(10)
.build(),
defineSeed("audit-log-buckets")
.dependsOn("tenants")
.truncateFirst()
.data([])
.priority(20)
.build(),
]);
const logger: LoggerLike = createStructuredLogger();
const runner = new SeedRunner(registry, mySurrealStore, logger);
const controller = new AbortController();
setTimeout(() => controller.abort(), 30_000); // hard 30s budget
const result = await runner.run({
environment: "staging",
only: ["tenants", "admin-users"],
batchSize: 500,
continueOnError: false,
signal: controller.signal,
onProgress: (r) =>
logger.info(`seed.progress`, { name: r.name, status: r.status }),
});
if (!result.success) {
throw new Error(
`Seed run failed: ${result.seedsFailed} failed, ${result.seedsSkipped} skipped`,
);
}
```
## API Reference
### Builder
- `defineSeed(name: string): SeedBuilder` — convenience factory
- `class SeedBuilder` — chainable: `table`, `environments`, `dependsOn`, `data`, `dataFactory`, `upsert`, `description`, `priority`, `truncateFirst`, terminal `build()`
### Registry
- `class SeedRegistry` — `register` / `registerMany` / `get` / `getAll` / `getForEnvironment(env)` / `has` / `remove` / `clear` / `size` / `names`. Raises `SeedError({ code: SEED.DUPLICATE_NAME })` on collision.
### Runner
- `class SeedRunner(registry, store, logger?)` — `async run(config: SeedRunConfig): Promise<SeedRunResult>`
- `SeedRunConfig`: `environment`, `only?`, `exclude?`, `dryRun?`, `batchSize?`, `continueOnError?`, `onProgress?`, `signal?`
- `SeedRunResult`: `environment`, `totalDuration`, `results`, `success`, `seedsRun`, `seedsSkipped`, `seedsFailed`
- `SeedResult`: `name`, `table`, `status`, `recordsInserted`, `recordsUpdated`, `recordsSkipped`, `duration`, `error?`
### Fixtures
- `class FixtureFactory<T extends SeedRecord>` + `createFixtureFactory(defaults)` — `create(overrides?)`, `createMany(count, overrides?)`, `reset()`, `counter`
- `class InMemorySeedStore` + `createInMemoryStore()` — full `SeedStore` shape plus `getRecords(table)` / `clear()`
### Utils
- `topoSort(seeds): string[]` — Kahn's algorithm with priority tiebreak; raises `SEED.UNREGISTERED_DEPENDENCY` / `SEED.CIRCULAR_DEPENDENCY` / `SEED.DEPENDENCY_DEPTH_EXCEEDED`
- `detectCycles(seeds): string[]` — DFS cycle reporter; returns `["a -> b -> a", ...]`; empty array on clean graph
### Errors
- `SeedError extends SuaveplanError<SeedErrorCode>` with optional `seedName` / `dependency` discriminants
- `SEED_CODES`: `DUPLICATE_NAME`, `UNREGISTERED_DEPENDENCY`, `CIRCULAR_DEPENDENCY`, `DEPENDENCY_DEPTH_EXCEEDED`, `INVALID_CONFIG`, `STORE_ERROR`, `ABORTED`
### Constants
- `DEFAULT_ENVIRONMENTS = ["dev", "staging", "test", "production"] as const`
- `MAX_DEPENDENCY_DEPTH = 50`
- `DEFAULT_BATCH_SIZE = 100`
- `SEED_STATUS = { PENDING, RUNNING, COMPLETED, FAILED, SKIPPED }`
## Implementation Notes
- **Universal** — no `node:`/`bun:` imports anywhere in `src/`. The only platform-aware call is `performance.now()`, which every supported runtime provides natively.
- **Store-agnostic by design** — `SeedRunner` receives a `SeedStore` via constructor injection. The core ships only `InMemorySeedStore` (for tests); production stores live in dedicated `seed-store-*` packages.
- **Errors thread `cause`** — store exceptions are wrapped as `SeedError({ code: SEED.STORE_ERROR, cause })` so callers can inspect the underlying error without losing the stable code.
- **Topological sort uses Kahn's algorithm** — one clean pass, priority breaks ties for independent seeds, a defensive iteration guard catches pathological cases before they hang.
- **AbortSignal is checked between seeds** — in-flight `SeedStore` calls receive the signal too (every `SeedStore` method accepts an optional `signal?: AbortSignal`).
- **Logger is opt-in** — `LoggerLike` defaults to a no-op so the runner stays silent when unwired. Wire it to get `seed.run.start` / `seed.completed` / `seed.skipped` / `seed.failed` / `seed.run.complete` events.
- **Enterprise extras are out of scope here** — plugin lifecycle, SurrealDB schema migrations, JWT/RBAC, audit logging, rate limiting, webhooks, OTel spans, and Prometheus metrics all belong to a future `api-plugin-seed` package that wraps this core.

View File

@@ -0,0 +1,141 @@
{
"name": "@suaveplan/seed",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "Universal declarative database seeding and fixture framework: SeedBuilder fluent API, SeedRegistry with environment filters, SeedRunner with topological ordering, upsert strategies, batching, dry-run, AbortSignal, injectable LoggerLike, FixtureFactory, InMemorySeedStore, store-agnostic.",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"./constants": {
"import": {
"types": "./dist/constants/index.d.ts",
"default": "./dist/constants/index.js"
},
"require": {
"types": "./dist/constants/index.d.ts",
"default": "./dist/constants/index.js"
}
},
"./error-codes/error-codes": {
"import": {
"types": "./dist/error-codes/error-codes.d.ts",
"default": "./dist/error-codes/error-codes.js"
},
"require": {
"types": "./dist/error-codes/error-codes.d.ts",
"default": "./dist/error-codes/error-codes.js"
}
},
"./types/common": {
"import": {
"types": "./dist/types/common.d.ts",
"default": "./dist/types/common.js"
},
"require": {
"types": "./dist/types/common.d.ts",
"default": "./dist/types/common.js"
}
},
"./builder/builder": {
"import": {
"types": "./dist/builder/builder.d.ts",
"default": "./dist/builder/builder.js"
},
"require": {
"types": "./dist/builder/builder.d.ts",
"default": "./dist/builder/builder.js"
}
},
"./registry/registry": {
"import": {
"types": "./dist/registry/registry.d.ts",
"default": "./dist/registry/registry.js"
},
"require": {
"types": "./dist/registry/registry.d.ts",
"default": "./dist/registry/registry.js"
}
},
"./runner/runner": {
"import": {
"types": "./dist/runner/runner.d.ts",
"default": "./dist/runner/runner.js"
},
"require": {
"types": "./dist/runner/runner.d.ts",
"default": "./dist/runner/runner.js"
}
},
"./fixtures/fixtures": {
"import": {
"types": "./dist/fixtures/fixtures.d.ts",
"default": "./dist/fixtures/fixtures.js"
},
"require": {
"types": "./dist/fixtures/fixtures.d.ts",
"default": "./dist/fixtures/fixtures.js"
}
},
"./utils/topo-sort/topo-sort": {
"import": {
"types": "./dist/utils/topo-sort/topo-sort.d.ts",
"default": "./dist/utils/topo-sort/topo-sort.js"
},
"require": {
"types": "./dist/utils/topo-sort/topo-sort.d.ts",
"default": "./dist/utils/topo-sort/topo-sort.js"
}
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"lint": "bunx biome check src/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
"format": "bunx biome format --write --max-diagnostics 500 src/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@suaveplan/error": "workspace:^",
"@suaveplan/types": "workspace:^",
"tslib": "catalog:build"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"fast-check": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
},
"files": [
"dist",
"src",
"README.md"
]
}

View File

@@ -0,0 +1,92 @@
# SeedBuilder
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`SeedBuilder` is the fluent, chainable API used to construct immutable `SeedDefinition` records. It exists so that callers can declare seeds in a readable, self-documenting form without hand-assembling the underlying object literal and without worrying about empty-collection or unset-field noise leaking into the serialised output.
The builder is the single canonical entry point for authoring seed definitions: `SeedRegistry`, `SeedRunner`, and every downstream tool consume the `SeedDefinition` value it produces. By centralising construction here we keep upstream parity with the `../suaveplan/packages/server/seed` reference implementation while honouring the rule that unset optional fields must be omitted entirely from the produced definition rather than serialised as empty arrays, empty strings, or `false`.
## Features
- Chainable fluent API — every setter returns the same builder instance.
- Default `table` derives from the seed `name` so most call-sites need only one identifier.
- `data(records)` and `dataFactory(fn)` share a single internal slot; the most recent call wins, supporting both static record arrays and lazy async factories.
- `upsert(strategy, keys)` co-sets the upsert strategy and key tuple atomically.
- `truncateFirst()` defaults `enabled` to `true` so the most common opt-in stays terse.
- `build()` produces a deeply-immutable shape: optional fields the caller never touched (`environments`, `dependencies`, `upsertKeys`, `description`, `truncate`) are omitted entirely.
- Implements the `ISeedBuilder` interface so consumers can depend on the contract rather than the concrete class.
## Usage: Basic
```ts
import { defineSeed } from "@suaveplan/seed/builder/builder";
const usersSeed = defineSeed("users")
.data([
{ id: 1, email: "alice@example.com" },
{ id: 2, email: "bob@example.com" },
])
.build();
// usersSeed.name === "users"
// usersSeed.table === "users" (defaults to name)
// usersSeed.upsertStrategy === "skip"
// usersSeed.priority === 0
// usersSeed has no environments / dependencies / upsertKeys / description / truncate fields
```
## Usage: Advanced
```ts
import { defineSeed, SeedBuilder } from "@suaveplan/seed/builder/builder";
import type { ISeedBuilder } from "@suaveplan/seed/builder/builder";
function makeTenantSeed(name: string): ISeedBuilder {
return defineSeed(name)
.table(`tenant_${name}`)
.environments("dev", "staging")
.dependsOn("roles", "permissions")
.dataFactory(async () => {
const rows = await loadFromFixtureService(name);
return rows.map((r, i) => ({ ...r, idx: i }));
})
.upsert("update", ["id"])
.description(`Tenant records for ${name}`)
.priority(10)
.truncateFirst();
}
const usersDef = makeTenantSeed("users").build();
const postsDef = new SeedBuilder("posts")
.dependsOn("users")
.data([{ id: 1, authorId: 1, body: "hello" }])
.upsert("skip", ["id"])
.build();
declare function loadFromFixtureService(name: string): Promise<Array<Record<string, unknown>>>;
```
## API Reference
- `defineSeed(name: string): SeedBuilder` — returns a new builder seeded with `name` (also the default `table`).
- `class SeedBuilder implements ISeedBuilder` — constructor takes the seed `name`.
- `table(name: string): this` — overrides the default `table`.
- `environments(...envs: SeedEnvironment[]): this` — restrict to listed environments; empty rest-args clears the field (omitted from `build()`).
- `dependsOn(...names: string[]): this` — list seeds that must run first; empty rest-args clears the field.
- `data(records: SeedRecord[]): this` — static record array; replaces any factory.
- `dataFactory(fn: SeedDataFactory): this` — sync-or-async factory; replaces any static array.
- `upsert(strategy: UpsertStrategy, keys: string[]): this` — atomic strategy + key-tuple setter.
- `description(text: string): this` — empty string is treated as unset.
- `priority(n: number): this` — lower priorities run earlier when no dependency edge exists.
- `truncateFirst(enabled?: boolean): this` — default `true`; passing `false` clears the field.
- `build(): SeedDefinition` — terminal accessor; safe to call multiple times.
## Implementation Notes
The builder tracks each optional field in its own private slot and only emits it from `build()` when the slot is non-empty (`length > 0` for arrays/strings, truthy for booleans). The mandatory shape — `name`, `table`, `data`, `upsertStrategy`, `priority` — is always present, matching the upstream reference contract. Defaults: `_environments = []`, `_dependencies = []`, `_data = []`, `_upsertStrategy = "skip"`, `_upsertKeys = []`, `_description = ""`, `_priority = 0`, `_truncate = false`.
Because `data(...)` and `dataFactory(...)` write to the same slot, ordering matters: calling `.data([...]).dataFactory(fn)` resolves to the factory; the reverse resolves to the array. Both return `this` so the fluent chain is uninterrupted regardless of which slot wins. `build()` is a pure read; it never mutates internal state, so the same builder may produce multiple equivalent definitions or be further refined and re-built. All chainable methods return the typed `this` so subclassing preserves the fluent return type without explicit casts.

View File

@@ -0,0 +1,190 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { SeedDataFactory, SeedRecord } from "../types/common.js";
import { defineSeed, SeedBuilder } from "./builder.js";
describe("SeedBuilder", () => {
it("default table equals name", { timeout: 5000 }, () => {
const def = defineSeed("users").build();
expect(def.name).toBe("users");
expect(def.table).toBe("users");
});
it("omits optional fields that were never set", { timeout: 5000 }, () => {
const def = defineSeed("u")
.data([{ id: 1 }])
.build();
expect(def).not.toHaveProperty("environments");
expect(def).not.toHaveProperty("dependencies");
expect(def).not.toHaveProperty("upsertKeys");
expect(def).not.toHaveProperty("description");
expect(def).not.toHaveProperty("truncate");
expect(def.name).toBe("u");
expect(def.table).toBe("u");
expect(def.data).toEqual([{ id: 1 }]);
expect(def.upsertStrategy).toBe("skip");
expect(def.priority).toBe(0);
});
it("dataFactory overrides prior data() call", { timeout: 5000 }, () => {
const factory: SeedDataFactory = () => [{ a: 2 }];
const def = defineSeed("u")
.data([{ a: 1 }])
.dataFactory(factory)
.build();
expect(def.data).toBe(factory);
});
it("data array overrides prior dataFactory call", { timeout: 5000 }, () => {
const records: SeedRecord[] = [{ a: 1 }];
const def = defineSeed("u")
.dataFactory(() => [{ a: 2 }])
.data(records)
.build();
expect(def.data).toBe(records);
});
it("table() overrides default name-derived table", {
timeout: 5000,
}, () => {
const def = defineSeed("seed-name").table("real_table").build();
expect(def.name).toBe("seed-name");
expect(def.table).toBe("real_table");
});
it("environments() with values populates field", { timeout: 5000 }, () => {
const def = defineSeed("u").environments("dev", "test").build();
expect(def.environments).toEqual(["dev", "test"]);
});
it("environments() with no args yields undefined in build()", {
timeout: 5000,
}, () => {
const def = defineSeed("u").environments().build();
expect(def).not.toHaveProperty("environments");
});
it("dependsOn() with values populates field", { timeout: 5000 }, () => {
const def = defineSeed("u").dependsOn("a", "b").build();
expect(def.dependencies).toEqual(["a", "b"]);
});
it("dependsOn() with no args yields undefined in build()", {
timeout: 5000,
}, () => {
const def = defineSeed("u").dependsOn().build();
expect(def).not.toHaveProperty("dependencies");
});
it("upsert(strategy, keys) sets both upsertStrategy and upsertKeys", {
timeout: 5000,
}, () => {
const def = defineSeed("u").upsert("update", ["id"]).build();
expect(def.upsertStrategy).toBe("update");
expect(def.upsertKeys).toEqual(["id"]);
});
it("upsert with empty keys array omits upsertKeys", {
timeout: 5000,
}, () => {
const def = defineSeed("u").upsert("replace", []).build();
expect(def.upsertStrategy).toBe("replace");
expect(def).not.toHaveProperty("upsertKeys");
});
it("description() sets description when non-empty", {
timeout: 5000,
}, () => {
const def = defineSeed("u").description("hello").build();
expect(def.description).toBe("hello");
});
it("description('') yields undefined in build()", { timeout: 5000 }, () => {
const def = defineSeed("u").description("").build();
expect(def).not.toHaveProperty("description");
});
it("priority() sets numeric priority", { timeout: 5000 }, () => {
const def = defineSeed("u").priority(42).build();
expect(def.priority).toBe(42);
});
it("truncateFirst() defaults to true", { timeout: 5000 }, () => {
const def = defineSeed("u").truncateFirst().build();
expect(def.truncate).toBe(true);
});
it("truncateFirst(true) sets truncate true", { timeout: 5000 }, () => {
const def = defineSeed("u").truncateFirst(true).build();
expect(def.truncate).toBe(true);
});
it("truncateFirst(false) yields undefined truncate in build()", {
timeout: 5000,
}, () => {
const def = defineSeed("u").truncateFirst(false).build();
expect(def).not.toHaveProperty("truncate");
});
it("all chainable methods return the same builder instance", {
timeout: 5000,
}, () => {
const b = new SeedBuilder("u");
expect(b.table("t")).toBe(b);
expect(b.environments("dev")).toBe(b);
expect(b.dependsOn("x")).toBe(b);
expect(b.data([{ id: 1 }])).toBe(b);
expect(b.dataFactory(() => [])).toBe(b);
expect(b.upsert("skip", ["id"])).toBe(b);
expect(b.description("d")).toBe(b);
expect(b.priority(1)).toBe(b);
expect(b.truncateFirst()).toBe(b);
});
it("defineSeed(name).build() yields name/table equal and empty default data", {
timeout: 5000,
}, () => {
const def = defineSeed("x").build();
expect(def.name).toBe("x");
expect(def.table).toBe("x");
expect(def.data).toEqual([]);
expect(def.upsertStrategy).toBe("skip");
expect(def.priority).toBe(0);
expect(def).not.toHaveProperty("environments");
expect(def).not.toHaveProperty("dependencies");
expect(def).not.toHaveProperty("upsertKeys");
expect(def).not.toHaveProperty("description");
expect(def).not.toHaveProperty("truncate");
});
it("defineSeed returns a SeedBuilder instance", { timeout: 5000 }, () => {
expect(defineSeed("x")).toBeInstanceOf(SeedBuilder);
});
it("full fluent chain populates every optional field", {
timeout: 5000,
}, () => {
const factory: SeedDataFactory = async () => [{ id: 1 }];
const def = defineSeed("users")
.table("user_records")
.environments("dev", "staging")
.dependsOn("roles", "tenants")
.dataFactory(factory)
.upsert("update", ["id"])
.description("seed user records")
.priority(5)
.truncateFirst()
.build();
expect(def).toEqual({
name: "users",
table: "user_records",
environments: ["dev", "staging"],
dependencies: ["roles", "tenants"],
data: factory,
upsertStrategy: "update",
upsertKeys: ["id"],
description: "seed user records",
priority: 5,
truncate: true,
});
});
});

View File

@@ -0,0 +1,119 @@
import type {
SeedDataFactory,
SeedDefinition,
SeedEnvironment,
SeedRecord,
UpsertStrategy,
} from "../types/common.js";
import type { ISeedBuilder } from "./builder.types.js";
/**
* Fluent builder for SeedDefinition objects. Tracks which optional fields the
* caller has set so `build()` can omit unset fields entirely (no empty arrays,
* no empty strings, no `truncate: false` slipping through to the output).
*/
export class SeedBuilder implements ISeedBuilder {
private readonly _name: string;
private _table: string;
private _environments: SeedEnvironment[] = [];
private _dependencies: string[] = [];
private _data: SeedRecord[] | SeedDataFactory = [];
private _upsertStrategy: UpsertStrategy = "skip";
private _upsertKeys: string[] = [];
private _description = "";
private _priority = 0;
private _truncate = false;
constructor(name: string) {
this._name = name;
this._table = name;
}
table(table: string): this {
this._table = table;
return this;
}
environments(...envs: SeedEnvironment[]): this {
this._environments = envs;
return this;
}
dependsOn(...names: string[]): this {
this._dependencies = names;
return this;
}
data(records: SeedRecord[]): this {
this._data = records;
return this;
}
dataFactory(factory: SeedDataFactory): this {
this._data = factory;
return this;
}
upsert(strategy: UpsertStrategy, keys: string[]): this {
this._upsertStrategy = strategy;
this._upsertKeys = keys;
return this;
}
description(desc: string): this {
this._description = desc;
return this;
}
priority(p: number): this {
this._priority = p;
return this;
}
truncateFirst(enabled = true): this {
this._truncate = enabled;
return this;
}
build(): SeedDefinition {
const definition: {
name: string;
table: string;
environments?: SeedEnvironment[];
dependencies?: string[];
data: SeedRecord[] | SeedDataFactory;
upsertStrategy: UpsertStrategy;
upsertKeys?: string[];
description?: string;
priority: number;
truncate?: boolean;
} = {
name: this._name,
table: this._table,
data: this._data,
upsertStrategy: this._upsertStrategy,
priority: this._priority,
};
if (this._environments.length > 0) {
definition.environments = this._environments;
}
if (this._dependencies.length > 0) {
definition.dependencies = this._dependencies;
}
if (this._upsertKeys.length > 0) {
definition.upsertKeys = this._upsertKeys;
}
if (this._description.length > 0) {
definition.description = this._description;
}
if (this._truncate) {
definition.truncate = true;
}
return definition;
}
}
/** Convenience function for creating a SeedBuilder. */
export function defineSeed(name: string): SeedBuilder {
return new SeedBuilder(name);
}

View File

@@ -0,0 +1,35 @@
import type {
SeedDataFactory,
SeedDefinition,
SeedEnvironment,
SeedRecord,
UpsertStrategy,
} from "../types/common.js";
/**
* Public contract for the fluent SeedBuilder. Every chainable method returns
* the builder so callers can program against this interface without coupling
* to the concrete implementation.
*/
export interface ISeedBuilder {
/** Sets the target table name (defaults to the seed name). */
table(table: string): ISeedBuilder;
/** Restricts this seed to specific environments. */
environments(...envs: SeedEnvironment[]): ISeedBuilder;
/** Declares dependencies that must run before this seed. */
dependsOn(...names: string[]): ISeedBuilder;
/** Sets the static data to seed. */
data(records: SeedRecord[]): ISeedBuilder;
/** Sets a factory function that produces seed data. */
dataFactory(factory: SeedDataFactory): ISeedBuilder;
/** Sets the upsert strategy and identifying keys. */
upsert(strategy: UpsertStrategy, keys: string[]): ISeedBuilder;
/** Sets an optional human-readable description. */
description(text: string): ISeedBuilder;
/** Sets the priority (lower runs earlier among independent seeds). */
priority(n: number): ISeedBuilder;
/** Enables (or disables) table truncation before seeding. */
truncateFirst(enabled?: boolean): ISeedBuilder;
/** Builds the immutable SeedDefinition. */
build(): SeedDefinition;
}

View File

@@ -0,0 +1,2 @@
export { defineSeed, SeedBuilder } from "./builder.js";
export type { ISeedBuilder } from "./builder.types.js";

View File

@@ -0,0 +1,22 @@
/** Default seed environments matched against `SeedRunConfig.environment`. */
export const DEFAULT_ENVIRONMENTS = [
"dev",
"staging",
"test",
"production",
] as const;
/** Iteration guard for `topoSort` — when exceeded, the algorithm raises `SEED.DEPENDENCY_DEPTH_EXCEEDED`. */
export const MAX_DEPENDENCY_DEPTH = 50;
/** Number of records the runner inserts per `SeedStore.insert` call when the caller does not override via `SeedRunConfig.batchSize`. */
export const DEFAULT_BATCH_SIZE = 100;
/** String-literal status values surfaced on every `SeedResult.status`. */
export const SEED_STATUS = {
PENDING: "pending",
RUNNING: "running",
COMPLETED: "completed",
FAILED: "failed",
SKIPPED: "skipped",
} as const;

View File

@@ -0,0 +1,128 @@
# error-codes
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `@suaveplan/seed` package centralises every failure mode it can produce
behind a single registry built with `defineCodes("SEED", [...])` from
`@suaveplan/error`. The registry is the stable contract callers depend on:
code strings are documented, frozen at module load, and never renamed
without an explicit migration. This module exports the `SEED_CODES`
registry, the derived `SeedErrorCode` type, and the `SeedError` class that
every public failure path raises.
`SeedError` extends `SuaveplanError<SeedErrorCode>` so `instanceof SeedError`
narrows `code` to the exact union of `SEED.*` strings. It adds optional
`seedName` and `dependency` discriminants that diagnose which seed (or
which missing dependency) triggered the error, while leaving the base
`context` map free for caller-supplied structured data. The class threads
`cause` straight through `SuaveplanError`, so store-layer exceptions
wrapped by the runner remain inspectable through `error.cause`.
## Features
- Frozen `SEED_CODES` registry with seven namespaced codes
- `SeedErrorCode` literal-union type derived from the registry
- `SeedError` class extending `SuaveplanError<SeedErrorCode>`
- Optional `seedName` and `dependency` diagnostic discriminants
- Full `cause`-chain preservation via `SuaveplanError`
- Compatible with the OTel span-event adapter shipped by `@suaveplan/error`
## Usage: Basic
```ts
import { SEED_CODES, SeedError } from "@suaveplan/seed";
if (registry.has(seed.name)) {
throw new SeedError(SEED_CODES.DUPLICATE_NAME, `seed "${seed.name}" already registered`, {
seedName: seed.name,
});
}
```
## Usage: Advanced
```ts
import { SEED_CODES, SeedError } from "@suaveplan/seed";
import { SuaveplanError } from "@suaveplan/error";
try {
await store.insert(seed.table, batch);
} catch (cause) {
throw new SeedError(SEED_CODES.STORE_ERROR, `store rejected batch for ${seed.table}`, {
cause: cause instanceof Error ? cause : undefined,
seedName: seed.name,
context: { table: seed.table, batchSize: batch.length },
});
}
// Downstream:
catch (err) {
if (err instanceof SeedError && err.code === SEED_CODES.STORE_ERROR) {
logger.error("seed.store.failure", {
seedName: err.seedName,
rootMessage: err.cause instanceof Error ? err.cause.message : String(err.cause),
});
}
}
```
## API Reference
### `SEED_CODES`
A frozen object literal of seven namespaced codes:
| Property | Value |
| ----------------------------- | ---------------------------------- |
| `DUPLICATE_NAME` | `"SEED.DUPLICATE_NAME"` |
| `UNREGISTERED_DEPENDENCY` | `"SEED.UNREGISTERED_DEPENDENCY"` |
| `CIRCULAR_DEPENDENCY` | `"SEED.CIRCULAR_DEPENDENCY"` |
| `DEPENDENCY_DEPTH_EXCEEDED` | `"SEED.DEPENDENCY_DEPTH_EXCEEDED"` |
| `INVALID_CONFIG` | `"SEED.INVALID_CONFIG"` |
| `STORE_ERROR` | `"SEED.STORE_ERROR"` |
| `ABORTED` | `"SEED.ABORTED"` |
### `type SeedErrorCode`
The literal union of every value in `SEED_CODES`.
### `class SeedError extends SuaveplanError<SeedErrorCode>`
| Member | Type |
| ------------------ | ---------------------------------------------------------------------- |
| `code` | `SeedErrorCode` (narrowed) |
| `seedName?` | `string` |
| `dependency?` | `string` |
| `cause?` | `unknown` (inherited) |
| `timestamp` | `number` (inherited; `Date.now()` at construction) |
| `constructor(...)` | `(code, message, options?: SeedErrorOptions)` |
`SeedErrorOptions` extends `SuaveplanErrorOptions` (`cause`, `context`,
`clock`, `traceId`) with the additional optional `seedName` and
`dependency` fields.
## Implementation Notes
`defineCodes` returns a `Object.freeze`d record with `null` prototype, so
the registry cannot be mutated, reassigned, or extended at runtime — any
attempt fails in strict mode (which TypeScript modules enforce). The
literal type machinery preserves the exact `${Namespace}.${Reason}`
string template across every consumer, so `switch (err.code)` enjoys
exhaustive narrowing without runtime cost.
`SeedError`'s constructor passes `message` straight through to
`SuaveplanError`'s `messageKey` parameter. The base class resolves the
key via the i18n resolver if one has been registered with
`setMessageResolver`, falling back to the raw string otherwise — so this
package works without i18n wiring and lights up automatically when
`@suaveplan/i18n` is present.
Optional `seedName` / `dependency` fields are stored on the error instance
directly (not on `context`) so they survive structured-cloning and
serialisation via the `toJSON` helper from `@suaveplan/error`. Consumers
that programmatically dispatch on a missing dependency can read
`err.dependency` without first parsing `err.context`.

View File

@@ -0,0 +1,73 @@
import { SuaveplanError } from "@suaveplan/error";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { SEED_CODES, SeedError } from "./error-codes.js";
describe("SEED_CODES registry", () => {
it("declares every required code with namespaced value", () => {
expect(SEED_CODES.DUPLICATE_NAME).toBe("SEED.DUPLICATE_NAME");
expect(SEED_CODES.UNREGISTERED_DEPENDENCY).toBe(
"SEED.UNREGISTERED_DEPENDENCY"
);
expect(SEED_CODES.CIRCULAR_DEPENDENCY).toBe("SEED.CIRCULAR_DEPENDENCY");
expect(SEED_CODES.DEPENDENCY_DEPTH_EXCEEDED).toBe(
"SEED.DEPENDENCY_DEPTH_EXCEEDED"
);
expect(SEED_CODES.INVALID_CONFIG).toBe("SEED.INVALID_CONFIG");
expect(SEED_CODES.STORE_ERROR).toBe("SEED.STORE_ERROR");
expect(SEED_CODES.ABORTED).toBe("SEED.ABORTED");
});
it("registry is frozen", () => {
expect(Object.isFrozen(SEED_CODES)).toBe(true);
});
});
describe("SeedError", () => {
it("every SEED.* code is reachable from a call site", () => {
for (const code of Object.values(SEED_CODES)) {
const err = new SeedError(code, `test ${code}`);
expect(err.code).toBe(code);
expect(err).toBeInstanceOf(SuaveplanError);
expect(err).toBeInstanceOf(SeedError);
expect(err).toBeInstanceOf(Error);
}
});
it("SeedError preserves cause and SuaveplanError contract", () => {
const root = new Error("underlying boom");
const err = new SeedError(SEED_CODES.STORE_ERROR, "wrap", {
cause: root,
});
expect(err.cause).toBe(root);
expect(err.code).toBe("SEED.STORE_ERROR");
expect(err.timestamp).toBeTypeOf("number");
expect(err.message).toBe("wrap");
});
it("exposes seedName discriminant when provided", () => {
const err = new SeedError(SEED_CODES.DUPLICATE_NAME, "dup", {
seedName: "users",
});
expect(err.seedName).toBe("users");
expect(err.dependency).toBeUndefined();
});
it("exposes dependency discriminant when provided", () => {
const err = new SeedError(
SEED_CODES.UNREGISTERED_DEPENDENCY,
"missing",
{
seedName: "posts",
dependency: "users",
}
);
expect(err.seedName).toBe("posts");
expect(err.dependency).toBe("users");
});
it("omits discriminants when not provided", () => {
const err = new SeedError(SEED_CODES.INVALID_CONFIG, "bad config");
expect(err.seedName).toBeUndefined();
expect(err.dependency).toBeUndefined();
});
});

View File

@@ -0,0 +1,51 @@
import type { SuaveplanErrorOptions } from "@suaveplan/error";
import { defineCodes, SuaveplanError } from "@suaveplan/error";
/**
* Stable error-code registry for `@suaveplan/seed`. Every public failure path
* raises a `SeedError` carrying one of these codes; consumers programmatically
* dispatch on the namespaced string (`"SEED.DUPLICATE_NAME"` etc.).
*/
export const SEED_CODES = defineCodes("SEED", [
"DUPLICATE_NAME",
"UNREGISTERED_DEPENDENCY",
"CIRCULAR_DEPENDENCY",
"DEPENDENCY_DEPTH_EXCEEDED",
"INVALID_CONFIG",
"STORE_ERROR",
"ABORTED",
] as const);
export type SeedErrorCode = (typeof SEED_CODES)[keyof typeof SEED_CODES];
export interface SeedErrorOptions extends SuaveplanErrorOptions {
/** Name of the seed that triggered the error, when applicable. */
readonly seedName?: string;
/** Name of the missing or cyclic dependency, when applicable. */
readonly dependency?: string;
}
/**
* Discriminated error class for `@suaveplan/seed`. Extends `SuaveplanError` so
* `instanceof` narrows `code` to `SeedErrorCode`; carries optional
* `seedName` / `dependency` discriminants for diagnostics without polluting
* the base `context` map.
*/
export class SeedError extends SuaveplanError<SeedErrorCode> {
public readonly seedName?: string;
public readonly dependency?: string;
public constructor(
code: SeedErrorCode,
message: string,
options: SeedErrorOptions = {}
) {
super(code, message, options);
if (options.seedName !== undefined) {
this.seedName = options.seedName;
}
if (options.dependency !== undefined) {
this.dependency = options.dependency;
}
}
}

View File

@@ -0,0 +1,8 @@
export type {
SeedErrorCode,
SeedErrorOptions,
} from "./error-codes.js";
export {
SEED_CODES,
SeedError,
} from "./error-codes.js";

View File

@@ -0,0 +1,142 @@
# fixtures
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `fixtures` module supplies the two universal building blocks used to
exercise seed definitions without a real database: `FixtureFactory` for
generating deterministic, counter-stamped record sets, and `InMemorySeedStore`
for satisfying the `SeedStore` contract entirely in process memory. Together
they let every test in the wider ecosystem author realistic seed data and run
the same `SeedRunner` paths a production adapter would run, without touching
any I/O or external service. The module deliberately has zero dependencies on
the runner, registry, or builder layers — it is a leaf consumed by tests and
by integration harnesses that want a drop-in store.
## Features
- Auto-incrementing fixture indices via a private monotonic counter.
- Per-record overrides for `create` and indexed overrides for `createMany`.
- Counter reset to support deterministic test sequencing.
- Full `SeedStore` implementation: `insert`, `update`, `findExisting`,
`truncate`, `tableExists`.
- Composite-key support: any number of `keyFields` joined with `"::"`.
- Test affordances: `getRecords` returns a defensive copy, `clear` drops
every table.
- Optional `AbortSignal` parameter accepted on every method for contract
compatibility (ignored — operations are synchronous in-memory).
- Factory helpers `createFixtureFactory` and `createInMemoryStore` for code
that prefers function calls over `new`.
## Usage: Basic
```ts
import {
createFixtureFactory,
createInMemoryStore,
} from "@suaveplan/seed/fixtures/fixtures";
const userFactory = createFixtureFactory((index) => ({
id: index,
name: `User ${index}`,
email: `user${index}@example.com`,
}));
const users = userFactory.createMany(3);
const store = createInMemoryStore();
await store.insert("users", users);
```
## Usage: Advanced
```ts
import {
FixtureFactory,
InMemorySeedStore,
} from "@suaveplan/seed/fixtures/fixtures";
interface Membership extends Record<string, unknown> {
orgId: string;
userId: string;
role: "member" | "admin";
}
const memberships = new FixtureFactory<Membership>((i) => ({
orgId: `org-${i % 2}`,
userId: `user-${i}`,
role: "member",
}));
const rows = memberships.createMany(4, [
undefined as never,
{ role: "admin" },
]);
const store = new InMemorySeedStore();
await store.insert("memberships", rows);
const updated = await store.update(
"memberships",
[{ orgId: "org-0", userId: "user-0", role: "admin" }],
["orgId", "userId"]
);
memberships.reset();
```
## API Reference
### `FixtureFactory<T>`
- `new FixtureFactory(defaults: (index: number) => T)` — wrap a defaults
function.
- `create(overrides?: Partial<T>): T` — emit one record, advance the counter.
- `createMany(count: number, overrides?: Partial<T>[]): T[]` — emit `count`
records; `overrides[i]` (if present) merges over `defaults(counter)`.
- `reset(): void` — set the counter back to 0.
- `get counter(): number` — read the current counter.
### `createFixtureFactory<T>(defaults)`
Convenience wrapper for `new FixtureFactory(defaults)`.
### `InMemorySeedStore implements SeedStore`
- `insert(table, records, signal?)` — append; returns `records.length`.
- `update(table, records, keyFields, signal?)` — composite-key upsert into
matching rows; returns the count actually updated.
- `findExisting(table, records, keyFields, signal?)` — return stored rows
whose composite key appears in the input set.
- `truncate(table, signal?)` — empty the table while keeping it present.
- `tableExists(table, signal?)` — `true` iff `insert` or `truncate` has ever
named the table.
- `getRecords(table)` — defensive copy of the table's rows.
- `clear()` — drop every table.
### `createInMemoryStore()`
Returns a new `InMemorySeedStore`.
## Implementation Notes
The composite key is built by mapping each `keyField` to `String(record[k] ??
"")` and joining with `"::"`. Nullish field values collapse to the empty
string, so records with `undefined` and `null` for the same field share a
key; callers that need to distinguish them must use a key field that is
always populated. `update` mutates the stored array in place via index
assignment so subsequent `getRecords` calls observe the merge; the spread
order is `{ ...existing[idx], ...record }`, meaning incoming fields win on
collision.
`AbortSignal` arguments are intentionally ignored: there is no I/O to
abort. The parameters exist only so the in-memory store satisfies the same
shape as a network-bound store and can be swapped in for one in tests.
`getRecords` returns a shallow copy of the underlying array so mutating the
returned list does not corrupt subsequent assertions. `clear` removes the
map entries themselves, after which `tableExists` returns `false` for every
table — distinguishing it from `truncate`, which preserves the entry while
emptying its contents.

View File

@@ -0,0 +1,290 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
createFixtureFactory,
createInMemoryStore,
FixtureFactory,
InMemorySeedStore,
} from "./fixtures.js";
interface UserFixture extends Record<string, unknown> {
id: number;
name: string;
email: string;
}
const userDefaults = (index: number): UserFixture => ({
id: index,
name: `User ${index}`,
email: `user${index}@example.com`,
});
describe("FixtureFactory", () => {
it("counter increments deterministically and resets", {
timeout: 5000,
}, () => {
const factory = new FixtureFactory<UserFixture>(userDefaults);
const a = factory.create();
const b = factory.create();
const c = factory.create();
expect(a.id).toBe(0);
expect(b.id).toBe(1);
expect(c.id).toBe(2);
expect(factory.counter).toBe(3);
factory.reset();
expect(factory.counter).toBe(0);
const d = factory.create();
expect(d.id).toBe(0);
});
it("create with no overrides returns defaults verbatim", {
timeout: 5000,
}, () => {
const factory = createFixtureFactory<UserFixture>(userDefaults);
const record = factory.create();
expect(record).toEqual({
id: 0,
name: "User 0",
email: "user0@example.com",
});
});
it("create merges overrides on top of defaults", { timeout: 5000 }, () => {
const factory = createFixtureFactory<UserFixture>(userDefaults);
const record = factory.create({ name: "Alice" });
expect(record).toEqual({
id: 0,
name: "Alice",
email: "user0@example.com",
});
});
it("createMany applies per-record overrides", { timeout: 5000 }, () => {
const factory = createFixtureFactory<UserFixture>(userDefaults);
const records = factory.createMany(3, [
{ name: "Alice" },
undefined as unknown as Partial<UserFixture>,
{ email: "carol@x.test" },
]);
expect(records).toHaveLength(3);
expect(records[0]).toEqual({
id: 0,
name: "Alice",
email: "user0@example.com",
});
expect(records[1]).toEqual({
id: 1,
name: "User 1",
email: "user1@example.com",
});
expect(records[2]).toEqual({
id: 2,
name: "User 2",
email: "carol@x.test",
});
expect(factory.counter).toBe(3);
});
it("createMany with no overrides produces defaults only", {
timeout: 5000,
}, () => {
const factory = createFixtureFactory<UserFixture>(userDefaults);
const records = factory.createMany(2);
expect(records).toHaveLength(2);
expect(records[0].id).toBe(0);
expect(records[1].id).toBe(1);
});
it("createFixtureFactory returns a FixtureFactory instance", {
timeout: 5000,
}, () => {
const factory = createFixtureFactory<UserFixture>(userDefaults);
expect(factory).toBeInstanceOf(FixtureFactory);
});
});
describe("InMemorySeedStore", () => {
it("in-memory store round-trips insert/findExisting/update", {
timeout: 5000,
}, async () => {
const store = createInMemoryStore();
const inserted = await store.insert("users", [
{ id: 1, name: "Alice" },
{ id: 2, name: "Bob" },
]);
expect(inserted).toBe(2);
const found = await store.findExisting(
"users",
[{ id: 1 }, { id: 3 }],
["id"]
);
expect(found).toHaveLength(1);
expect(found[0]).toEqual({ id: 1, name: "Alice" });
const updated = await store.update(
"users",
[{ id: 1, name: "Alice Renamed" }],
["id"]
);
expect(updated).toBe(1);
const all = store.getRecords("users");
expect(all).toEqual([
{ id: 1, name: "Alice Renamed" },
{ id: 2, name: "Bob" },
]);
});
it("truncate empties table but preserves existence", {
timeout: 5000,
}, async () => {
const store = new InMemorySeedStore();
await store.insert("users", [{ id: 1 }]);
expect(await store.tableExists("users")).toBe(true);
await store.truncate("users");
expect(await store.tableExists("users")).toBe(true);
expect(store.getRecords("users")).toEqual([]);
});
it("tableExists reports false for unseen tables", {
timeout: 5000,
}, async () => {
const store = new InMemorySeedStore();
expect(await store.tableExists("nope")).toBe(false);
});
it("findExisting with empty input returns empty array", {
timeout: 5000,
}, async () => {
const store = new InMemorySeedStore();
await store.insert("users", [{ id: 1 }, { id: 2 }]);
const found = await store.findExisting("users", [], ["id"]);
expect(found).toEqual([]);
});
it("findExisting on missing table returns empty array", {
timeout: 5000,
}, async () => {
const store = new InMemorySeedStore();
const found = await store.findExisting("missing", [{ id: 1 }], ["id"]);
expect(found).toEqual([]);
});
it("update with no matching keys returns 0", {
timeout: 5000,
}, async () => {
const store = new InMemorySeedStore();
await store.insert("users", [{ id: 1, name: "Alice" }]);
const count = await store.update(
"users",
[{ id: 99, name: "Nobody" }],
["id"]
);
expect(count).toBe(0);
expect(store.getRecords("users")).toEqual([{ id: 1, name: "Alice" }]);
});
it("update on missing table returns 0", { timeout: 5000 }, async () => {
const store = new InMemorySeedStore();
const count = await store.update("missing", [{ id: 1 }], ["id"]);
expect(count).toBe(0);
});
it("update with multi-field keyFields matches composite keys", {
timeout: 5000,
}, async () => {
const store = new InMemorySeedStore();
await store.insert("memberships", [
{ orgId: "a", userId: "u1", role: "member" },
{ orgId: "a", userId: "u2", role: "member" },
{ orgId: "b", userId: "u1", role: "member" },
]);
const count = await store.update(
"memberships",
[{ orgId: "a", userId: "u2", role: "admin" }],
["orgId", "userId"]
);
expect(count).toBe(1);
const found = await store.findExisting(
"memberships",
[{ orgId: "a", userId: "u2" }],
["orgId", "userId"]
);
expect(found).toEqual([{ orgId: "a", userId: "u2", role: "admin" }]);
});
it("getRecords returns a defensive copy", { timeout: 5000 }, async () => {
const store = new InMemorySeedStore();
await store.insert("users", [{ id: 1 }]);
const copy = store.getRecords("users");
copy.push({ id: 99 });
expect(store.getRecords("users")).toEqual([{ id: 1 }]);
});
it("getRecords on missing table returns empty array", {
timeout: 5000,
}, () => {
const store = new InMemorySeedStore();
expect(store.getRecords("missing")).toEqual([]);
});
it("clear drops every table", { timeout: 5000 }, async () => {
const store = new InMemorySeedStore();
await store.insert("users", [{ id: 1 }]);
await store.insert("posts", [{ id: 2 }]);
store.clear();
expect(await store.tableExists("users")).toBe(false);
expect(await store.tableExists("posts")).toBe(false);
});
it("accepts an AbortSignal on every method without breaking", {
timeout: 5000,
}, async () => {
const store = new InMemorySeedStore();
const controller = new AbortController();
await store.insert("users", [{ id: 1 }], controller.signal);
await store.update(
"users",
[{ id: 1, name: "x" }],
["id"],
controller.signal
);
await store.findExisting(
"users",
[{ id: 1 }],
["id"],
controller.signal
);
await store.truncate("users", controller.signal);
await store.tableExists("users", controller.signal);
expect(store.getRecords("users")).toEqual([]);
});
it("treats nullish key fields as the empty string in update and findExisting", {
timeout: 5000,
}, async () => {
const store = new InMemorySeedStore();
await store.insert("rows", [
{ id: undefined, label: "a" },
{ id: null, label: "b" },
{ id: "x", label: "c" },
]);
// Update via a record that also has a nullish key — both stored rows
// with nullish ids collapse to the same composite key as the input.
const updated = await store.update(
"rows",
[{ id: undefined, label: "merged" }],
["id"]
);
expect(updated).toBe(1);
const found = await store.findExisting("rows", [{ id: null }], ["id"]);
// Both nullish-id rows match the nullish input key.
expect(found).toHaveLength(2);
});
it("createInMemoryStore returns an InMemorySeedStore instance", {
timeout: 5000,
}, () => {
const store = createInMemoryStore();
expect(store).toBeInstanceOf(InMemorySeedStore);
});
});

View File

@@ -0,0 +1,141 @@
import type { SeedRecord, SeedStore } from "../types/common.js";
/**
* Factory for generating test fixture data with auto-incrementing indices
* and customizable overrides.
*/
export class FixtureFactory<T extends SeedRecord> {
private readonly _defaults: (index: number) => T;
private _counter = 0;
constructor(defaults: (index: number) => T) {
this._defaults = defaults;
}
/** Creates a single record with optional overrides. */
create(overrides?: Partial<T>): T {
const record = this._defaults(this._counter++);
if (overrides) {
return { ...record, ...overrides };
}
return record;
}
/** Creates multiple records with optional per-record overrides. */
createMany(count: number, overrides?: Partial<T>[]): T[] {
const records: T[] = [];
for (let i = 0; i < count; i++) {
records.push(this.create(overrides?.[i]));
}
return records;
}
/** Resets the internal counter. */
reset(): void {
this._counter = 0;
}
/** Returns the current counter value. */
get counter(): number {
return this._counter;
}
}
/**
* Creates a fixture factory that generates records with incrementing IDs
* and customizable overrides.
*/
export function createFixtureFactory<T extends SeedRecord>(
defaults: (index: number) => T
): FixtureFactory<T> {
return new FixtureFactory(defaults);
}
/**
* Simple in-memory seed store for testing seed definitions without a real
* database. Implements the universal `SeedStore` contract; `AbortSignal`
* parameters are accepted for interface compatibility but ignored because
* every operation is synchronous in-memory.
*/
export class InMemorySeedStore implements SeedStore {
private readonly _tables = new Map<string, SeedRecord[]>();
async insert(
table: string,
records: readonly SeedRecord[],
_signal?: AbortSignal
): Promise<number> {
const existing = this._tables.get(table) ?? [];
existing.push(...records);
this._tables.set(table, existing);
return records.length;
}
async update(
table: string,
records: readonly SeedRecord[],
keyFields: readonly string[],
_signal?: AbortSignal
): Promise<number> {
const existing = this._tables.get(table) ?? [];
let updated = 0;
for (const record of records) {
const key = keyFields
.map((k) => String(record[k] ?? ""))
.join("::");
const idx = existing.findIndex(
(r) =>
keyFields.map((k) => String(r[k] ?? "")).join("::") === key
);
if (idx >= 0) {
existing[idx] = { ...existing[idx], ...record };
updated++;
}
}
return updated;
}
async findExisting(
table: string,
records: readonly SeedRecord[],
keyFields: readonly string[],
_signal?: AbortSignal
): Promise<SeedRecord[]> {
const existing = this._tables.get(table) ?? [];
const recordKeys = new Set(
records.map((r) =>
keyFields.map((k) => String(r[k] ?? "")).join("::")
)
);
return existing.filter((r) => {
const key = keyFields.map((k) => String(r[k] ?? "")).join("::");
return recordKeys.has(key);
});
}
async truncate(table: string, _signal?: AbortSignal): Promise<void> {
this._tables.set(table, []);
}
async tableExists(table: string, _signal?: AbortSignal): Promise<boolean> {
return this._tables.has(table);
}
/** Returns a defensive copy of all records in a table (for test assertions). */
getRecords(table: string): SeedRecord[] {
return [...(this._tables.get(table) ?? [])];
}
/** Clears all tables. */
clear(): void {
this._tables.clear();
}
}
/**
* Creates a simple in-memory seed store for testing. Records are stored in a
* map keyed by table name.
*/
export function createInMemoryStore(): InMemorySeedStore {
return new InMemorySeedStore();
}

View File

@@ -0,0 +1,29 @@
import type { SeedRecord, SeedStore } from "../types/common.js";
/**
* Public contract mirror of `FixtureFactory`. Callers that wish to program
* against the interface (e.g. for dependency injection or test doubles) can
* use this type rather than the concrete class.
*/
export interface IFixtureFactory<T extends SeedRecord> {
/** Current counter value. Starts at 0 and increments after each `create`. */
readonly counter: number;
/** Create a single record, optionally merged with caller-supplied overrides. */
create(overrides?: Partial<T>): T;
/** Create `count` records, optionally with per-index overrides. */
createMany(count: number, overrides?: Partial<T>[]): T[];
/** Reset the counter back to 0. */
reset(): void;
}
/**
* Public contract mirror of `InMemorySeedStore`. Extends the runner-facing
* `SeedStore` with the additional test-affordance methods (`getRecords`,
* `clear`) the in-memory implementation exposes.
*/
export interface IInMemorySeedStore extends SeedStore {
/** Return a defensive copy of the records stored for the given table. */
getRecords(table: string): SeedRecord[];
/** Drop every table and its records. */
clear(): void;
}

View File

@@ -0,0 +1,6 @@
export {
createFixtureFactory,
createInMemoryStore,
FixtureFactory,
InMemorySeedStore,
} from "./fixtures.js";

View File

@@ -0,0 +1,47 @@
// Constants
// Builder
export { defineSeed, SeedBuilder } from "./builder/builder.js";
export type { ISeedBuilder } from "./builder/builder.types.js";
export {
DEFAULT_BATCH_SIZE,
DEFAULT_ENVIRONMENTS,
MAX_DEPENDENCY_DEPTH,
SEED_STATUS,
} from "./constants/index.js";
export type {
SeedErrorCode,
SeedErrorOptions,
} from "./error-codes/error-codes.js";
// Errors
export { SEED_CODES, SeedError } from "./error-codes/error-codes.js";
// Fixtures
export {
createFixtureFactory,
createInMemoryStore,
FixtureFactory,
InMemorySeedStore,
} from "./fixtures/fixtures.js";
// Registry
export { SeedRegistry } from "./registry/registry.js";
export type { ISeedRegistry } from "./registry/registry.types.js";
// Runner
export { SeedRunner } from "./runner/runner.js";
export type { ISeedRunner } from "./runner/runner.types.js";
// Types
export type {
SeedDataFactory,
SeedDefinition,
SeedEnvironment,
SeedRecord,
SeedResult,
SeedRunConfig,
SeedRunResult,
SeedStatus,
SeedStore,
UpsertStrategy,
} from "./types/common.js";
// Utils
export { detectCycles, topoSort } from "./utils/topo-sort/topo-sort.js";

View File

@@ -0,0 +1,2 @@
export { SeedRegistry } from "./registry.js";
export type { ISeedRegistry } from "./registry.types.js";

View File

@@ -0,0 +1,113 @@
# SeedRegistry
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`SeedRegistry` is the canonical in-memory store for `SeedDefinition` entries used by `@suaveplan/seed`. It owns insertion-order bookkeeping, duplicate-name detection, and environment-scoped query helpers, providing every other layer (builder, runner, fixtures) a single source of truth for "what seeds exist in this process".
The registry is intentionally simple: no I/O, no concurrency primitives, no dependency resolution. Its sole invariants are (1) seed names are unique, (2) every read accessor returns a defensive shallow copy so caller mutations never leak into internal storage, and (3) duplicate registrations raise a structured `SeedError` with code `SEED.DUPLICATE_NAME` rather than a bare `Error`, so downstream catch sites can dispatch programmatically.
## Features
- O(1) name-keyed insertion, lookup, and deletion backed by a `Map`.
- Bulk registration via `registerMany` that fails fast on the first duplicate.
- Environment-scoped query (`getForEnvironment`) where unrestricted seeds (undefined or empty `environments`) match every environment.
- Defensive shallow copies returned from every read accessor (`get`, `getAll`, `getForEnvironment`).
- `SeedError`-typed failure with stable `SEED.DUPLICATE_NAME` code and `seedName` discriminant.
- Insertion-order iteration via `names` getter (Map semantics).
- Implements `ISeedRegistry` so consumers can substitute alternative registry implementations in tests.
## Usage: Basic
```ts
import { SeedRegistry } from "@suaveplan/seed/registry/registry";
const registry = new SeedRegistry();
registry.register({
name: "users",
table: "users",
data: [{ id: 1, email: "a@example.com" }],
});
const seed = registry.get("users");
if (seed) {
// seed.data is a defensive copy; safe to inspect without affecting storage.
}
console.log(registry.size); // 1
console.log(registry.names); // ["users"]
```
## Usage: Advanced
```ts
import { SeedRegistry } from "@suaveplan/seed/registry/registry";
import {
SEED_CODES,
SeedError,
} from "@suaveplan/seed/error-codes/error-codes";
import type { SeedDefinition } from "@suaveplan/seed/types/common";
const registry = new SeedRegistry();
const defs: SeedDefinition[] = [
{ name: "users", table: "users", data: [] },
{
name: "fixtures",
table: "fixtures",
environments: ["test"],
data: [],
},
{
name: "demo-data",
table: "demo",
environments: ["dev"],
data: [],
},
];
try {
registry.registerMany(defs);
} catch (err) {
if (err instanceof SeedError && err.code === SEED_CODES.DUPLICATE_NAME) {
// Structured: err.seedName identifies the colliding entry.
}
throw err;
}
// Environment-scoped query: unrestricted + matching seeds only.
const forTest = registry.getForEnvironment("test");
// → users (unrestricted) + fixtures (env=test)
// Remove yields a boolean; missing keys are a no-op.
registry.remove("demo-data");
registry.clear();
```
## API Reference
| Member | Description |
| ----------------------------------- | ----------------------------------------------------------------- |
| `register(seed)` | Insert one seed; throws `SeedError(SEED.DUPLICATE_NAME)` on collision. |
| `registerMany(seeds)` | Iterate and `register` each seed; aborts on first duplicate. |
| `get(name)` | Defensive copy of the named seed, or `undefined`. |
| `getAll()` | Defensive-copy array of every registered seed. |
| `getForEnvironment(env)` | Subset matching `env` or unrestricted (`undefined`/empty). |
| `has(name)` | `true` if the name is present, `false` otherwise. |
| `remove(name)` | Delete; returns `true` if the entry existed. |
| `clear()` | Remove every registered seed. |
| `size` | Current entry count. |
| `names` | Snapshot of registered names in insertion order. |
## Implementation Notes
- Storage uses `Map<string, SeedDefinition>` so insertion order is preserved by `names`, `getAll`, and `getForEnvironment`.
- Defensive copies are *shallow* (`{ ...seed }`). Nested arrays (`environments`, `dependencies`, `upsertKeys`, `data`) are not cloned; they remain `readonly` on the `SeedDefinition` type, so callers cannot mutate them without an explicit cast. This matches the upstream reference behaviour and avoids deep-clone overhead during runner setup.
- `getForEnvironment` filters the already-defensive `getAll()` result. The two-pass approach keeps the filter logic small and reuses the copy contract.
- `register` throws *before* mutating the map, so a duplicate registration leaves the registry intact. `registerMany` therefore preserves a partial-success prefix when an entry mid-stream collides — consumers who need transactional semantics should snapshot `names` and roll back via `remove` after catching.
- Error construction always includes `seedName` so downstream `instanceof SeedError` checks can dispatch without parsing the message string.
- No internal concurrency control: the registry is intended for single-threaded use during process startup and test setup. Consumers requiring concurrent registration must serialise externally.

View File

@@ -0,0 +1,151 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { SEED_CODES, SeedError } from "../error-codes/error-codes.js";
import type { SeedDefinition } from "../types/common.js";
import { SeedRegistry } from "./registry.js";
const makeSeed = (
name: string,
overrides: Partial<SeedDefinition> = {}
): SeedDefinition => ({
name,
table: `${name}_table`,
data: [],
...overrides,
});
describe("SeedRegistry", () => {
it("register raises SEED.DUPLICATE_NAME on collision", {
timeout: 5000,
}, () => {
const registry = new SeedRegistry();
registry.register(makeSeed("x"));
let caught: unknown;
try {
registry.register(makeSeed("x"));
} catch (err) {
caught = err;
}
expect(caught).toBeInstanceOf(SeedError);
const err = caught as SeedError;
expect(err.code).toBe(SEED_CODES.DUPLICATE_NAME);
expect(err.seedName).toBe("x");
});
it("getAll returns defensive copies", { timeout: 5000 }, () => {
const registry = new SeedRegistry();
registry.register(makeSeed("a", { description: "orig" }));
const all = registry.getAll();
all.push(makeSeed("intruder"));
all[0]!.table = "mutated";
(all[0] as { description?: string }).description = "tampered";
expect(registry.size).toBe(1);
expect(registry.has("intruder")).toBe(false);
const fresh = registry.get("a");
expect(fresh?.table).toBe("a_table");
expect(fresh?.description).toBe("orig");
const fresh2 = registry.getAll();
expect(fresh2).toHaveLength(1);
expect(fresh2[0]!.table).toBe("a_table");
});
it("getForEnvironment honours unrestricted and matching seeds", {
timeout: 5000,
}, () => {
const registry = new SeedRegistry();
registry.register(makeSeed("A"));
registry.register(makeSeed("B", { environments: ["test"] }));
registry.register(makeSeed("C", { environments: ["dev"] }));
registry.register(makeSeed("D", { environments: [] }));
const got = registry
.getForEnvironment("test")
.map((s) => s.name)
.sort();
expect(got).toEqual(["A", "B", "D"]);
});
it("registerMany registers every seed in order", { timeout: 5000 }, () => {
const registry = new SeedRegistry();
registry.registerMany([
makeSeed("one"),
makeSeed("two"),
makeSeed("three"),
]);
expect(registry.size).toBe(3);
expect(registry.names).toEqual(["one", "two", "three"]);
});
it("registerMany aborts on the first duplicate", { timeout: 5000 }, () => {
const registry = new SeedRegistry();
registry.register(makeSeed("dup"));
let caught: unknown;
try {
registry.registerMany([
makeSeed("fresh"),
makeSeed("dup"),
makeSeed("never"),
]);
} catch (err) {
caught = err;
}
expect(caught).toBeInstanceOf(SeedError);
expect((caught as SeedError).seedName).toBe("dup");
expect(registry.has("fresh")).toBe(true);
expect(registry.has("never")).toBe(false);
});
it("get returns undefined for missing names", { timeout: 5000 }, () => {
const registry = new SeedRegistry();
expect(registry.get("absent")).toBeUndefined();
});
it("get returns a defensive copy", { timeout: 5000 }, () => {
const registry = new SeedRegistry();
registry.register(makeSeed("a", { description: "orig" }));
const fetched = registry.get("a");
expect(fetched).toBeDefined();
(fetched as { description?: string }).description = "mutated";
const again = registry.get("a");
expect(again?.description).toBe("orig");
});
it("has reports presence accurately", { timeout: 5000 }, () => {
const registry = new SeedRegistry();
expect(registry.has("nope")).toBe(false);
registry.register(makeSeed("nope"));
expect(registry.has("nope")).toBe(true);
});
it("remove deletes existing and returns true; missing returns false", {
timeout: 5000,
}, () => {
const registry = new SeedRegistry();
registry.register(makeSeed("kill"));
expect(registry.remove("kill")).toBe(true);
expect(registry.has("kill")).toBe(false);
expect(registry.remove("ghost")).toBe(false);
});
it("clear empties the registry", { timeout: 5000 }, () => {
const registry = new SeedRegistry();
registry.register(makeSeed("a"));
registry.register(makeSeed("b"));
registry.clear();
expect(registry.size).toBe(0);
expect(registry.names).toEqual([]);
expect(registry.getAll()).toEqual([]);
});
it("size and names reflect current state", { timeout: 5000 }, () => {
const registry = new SeedRegistry();
expect(registry.size).toBe(0);
expect(registry.names).toEqual([]);
registry.register(makeSeed("first"));
registry.register(makeSeed("second"));
expect(registry.size).toBe(2);
expect(registry.names).toEqual(["first", "second"]);
});
});

View File

@@ -0,0 +1,67 @@
import { SEED_CODES, SeedError } from "../error-codes/error-codes.js";
import type { SeedDefinition, SeedEnvironment } from "../types/common.js";
import type { ISeedRegistry } from "./registry.types.js";
/**
* In-memory registry of `SeedDefinition` entries. Storage is a `Map` keyed
* by `seed.name`; every read accessor returns a defensive shallow copy so
* caller mutations cannot leak into the registry's internal state.
*/
export class SeedRegistry implements ISeedRegistry {
private readonly _seeds = new Map<string, SeedDefinition>();
public register(seed: SeedDefinition): void {
if (this._seeds.has(seed.name)) {
throw new SeedError(
SEED_CODES.DUPLICATE_NAME,
`Seed "${seed.name}" is already registered`,
{ seedName: seed.name }
);
}
this._seeds.set(seed.name, { ...seed });
}
public registerMany(seeds: readonly SeedDefinition[]): void {
for (const seed of seeds) {
this.register(seed);
}
}
public get(name: string): SeedDefinition | undefined {
const seed = this._seeds.get(name);
return seed ? { ...seed } : undefined;
}
public getAll(): SeedDefinition[] {
return [...this._seeds.values()].map((s) => ({ ...s }));
}
public getForEnvironment(environment: SeedEnvironment): SeedDefinition[] {
return this.getAll().filter((seed) => {
if (!seed.environments || seed.environments.length === 0) {
return true;
}
return seed.environments.includes(environment);
});
}
public has(name: string): boolean {
return this._seeds.has(name);
}
public remove(name: string): boolean {
return this._seeds.delete(name);
}
public clear(): void {
this._seeds.clear();
}
public get size(): number {
return this._seeds.size;
}
public get names(): string[] {
return [...this._seeds.keys()];
}
}

View File

@@ -0,0 +1,31 @@
import type { SeedDefinition, SeedEnvironment } from "../types/common.js";
/**
* Public contract for `SeedRegistry`. Stores `SeedDefinition` entries keyed
* by `name`, supports bulk registration, environment-scoped queries, and
* deterministic deletion semantics. Implementations MUST raise
* `SEED.DUPLICATE_NAME` on a collision and return defensive shallow copies
* from every read accessor so caller mutations never leak into storage.
*/
export interface ISeedRegistry {
/** Register a single seed. Throws `SEED.DUPLICATE_NAME` if `seed.name` is already present. */
register(seed: SeedDefinition): void;
/** Register many seeds in iteration order; first duplicate aborts the batch. */
registerMany(seeds: readonly SeedDefinition[]): void;
/** Return a defensive copy of the seed with `name`, or `undefined`. */
get(name: string): SeedDefinition | undefined;
/** Return defensive copies of every registered seed. */
getAll(): SeedDefinition[];
/** Return seeds applicable to `environment`: unrestricted seeds OR seeds whose `environments` include `environment`. */
getForEnvironment(environment: SeedEnvironment): SeedDefinition[];
/** Whether a seed with `name` is registered. */
has(name: string): boolean;
/** Remove the seed with `name`. Returns `true` if it existed, `false` otherwise. */
remove(name: string): boolean;
/** Remove every registered seed. */
clear(): void;
/** Number of registered seeds. */
readonly size: number;
/** Snapshot of every registered seed name in insertion order. */
readonly names: string[];
}

View File

@@ -0,0 +1,123 @@
# execute
## Purpose
The `executeSeed` function applies one `SeedDefinition` to one
`SeedStore`, returning a single `SeedResult`. It is the per-seed body
that `SeedRunner.run` invokes inside its topo-ordered loop. The
function is split out of `runner.ts` so the orchestration class can
focus on dependency ordering, abort handling, and logging while the
batching, upsert-strategy, and error-wrapping mechanics live here in
their own file under the 250-line soft limit.
The split is purely organisational: callers do not import
`executeSeed` directly — the public entry point is `SeedRunner.run`,
which composes this function with the registry, topological sort,
logger, and abort signal. The function is exported as a named symbol
from the same `runner/` folder so tests can target either layer
independently when that helps with isolation.
## Features
- Resolves `SeedDefinition.data` (array or async factory) into a
concrete record array.
- Honours `dryRun` — returns a synthesised `completed` result with the
resolved record count and zero IO against the store.
- Calls `store.truncate(seed.table, signal)` first when
`seed.truncate` is true.
- Batches the records by `config.batchSize ?? DEFAULT_BATCH_SIZE`.
- Applies the requested upsert strategy per batch:
- `skip` with non-empty `upsertKeys`: filters out records whose key
tuple already exists in the store.
- `update` with non-empty `upsertKeys`: inserts absent records and
updates the rest in two store calls.
- `replace` or empty `upsertKeys`: inserts every record directly.
- Wraps every store exception as
`SeedError({ code: SEED.STORE_ERROR, cause })` so the runner
surfaces a stable code while preserving the original via the
cause-chain.
- Threads `AbortSignal` from the runner into every store call.
## Usage: Basic
```ts
import { executeSeed } from "./execute.js";
import { createInMemoryStore } from "../fixtures/fixtures.js";
const store = createInMemoryStore();
const result = await executeSeed(
store,
{ name: "users", table: "u", data: [{ id: 1 }] },
{ environment: "dev" },
);
console.log(result.status, result.recordsInserted);
```
## Usage: Advanced
```ts
import { executeSeed } from "./execute.js";
const controller = new AbortController();
const result = await executeSeed(
productionStore,
{
name: "users",
table: "user",
data: async () => fetchFromCsv("/users.csv"),
upsertStrategy: "update",
upsertKeys: ["tenantId", "email"],
truncate: false,
},
{
environment: "production",
batchSize: 500,
signal: controller.signal,
},
);
if (result.status === "failed") {
console.error("seed", result.name, "failed:", result.error);
}
```
## API Reference
### `async function executeSeed(store, seed, config): Promise<SeedResult>`
Parameters:
| Name | Type | Description |
| -------- | ---------------- | ------------------------------------------------- |
| `store` | `SeedStore` | Adapter the function calls into. |
| `seed` | `SeedDefinition` | The seed to apply. |
| `config` | `SeedRunConfig` | Run-scoped options (batch size, dry-run, signal). |
Returns `Promise<SeedResult>` — always resolves; failures are captured
on the result rather than thrown. The result's `status` is
`"completed"` on success and `"failed"` when the store rejects;
`recordsInserted` / `recordsUpdated` / `recordsSkipped` reflect the
partial progress at the point of failure.
## Implementation Notes
A single private `processBatch` helper handles the three upsert
strategies so the main `try/catch` block stays short. Empty batches
take the explicit `: 0` ternary branch on both the `insert` and
`update` calls — that branch fires when every record in the batch
already exists (skip strategy) or when there are no inserts or updates
to perform (update strategy with all-existing or all-fresh inputs).
The `buildKey` helper collapses missing or `null` key-field values via
`record[k] ?? ""`. Records sharing the same key tuple — including
those whose key fields are all absent — are deduplicated by the
strategy, which matches the upstream `@suaveplan/server/seed`
behaviour exactly. Adapters that need strict null handling should
provide their own `findExisting` semantics rather than rely on the
runner's key collapse.
Errors that are already `instanceof SeedError` pass through
untouched — useful when downstream code already wraps its failures
into the `SEED.*` code space. Anything else is wrapped with
`SEED.STORE_ERROR` and the original captured on `cause`; the
human-readable message is stored on `SeedResult.error`.

View File

@@ -0,0 +1,185 @@
import { DEFAULT_BATCH_SIZE } from "../constants/index.js";
import { SEED_CODES, SeedError } from "../error-codes/error-codes.js";
import type {
SeedDefinition,
SeedRecord,
SeedResult,
SeedRunConfig,
SeedStore,
} from "../types/common.js";
/**
* Executes a single seed definition against the injected store, applying
* truncation, batching, and the requested upsert strategy. Wraps any
* underlying store exception as `SeedError` with `SEED.STORE_ERROR` while
* preserving the original on `error.cause`.
*/
export async function executeSeed(
store: SeedStore,
seed: SeedDefinition,
config: SeedRunConfig
): Promise<SeedResult> {
const startTime = performance.now();
let inserted = 0;
let updated = 0;
let skipped = 0;
try {
const records = await resolveData(seed);
if (records.length === 0) {
return completed(seed, startTime, 0, 0, 0);
}
if (config.dryRun) {
return completed(seed, startTime, records.length, 0, 0);
}
if (seed.truncate) {
await store.truncate(seed.table, config.signal);
}
const batchSize = config.batchSize ?? DEFAULT_BATCH_SIZE;
const strategy = seed.upsertStrategy ?? "skip";
const upsertKeys = seed.upsertKeys ?? [];
const batches = chunk(records, batchSize);
for (const recordBatch of batches) {
const counts = await processBatch(
store,
seed.table,
recordBatch,
strategy,
upsertKeys,
config.signal
);
inserted += counts.inserted;
updated += counts.updated;
skipped += counts.skipped;
}
return completed(seed, startTime, inserted, updated, skipped);
} catch (error) {
const wrapped = wrapError(error, seed.name);
return {
name: seed.name,
table: seed.table,
status: "failed",
recordsInserted: inserted,
recordsUpdated: updated,
recordsSkipped: skipped,
duration: performance.now() - startTime,
error: wrapped.message,
};
}
}
interface BatchCounts {
inserted: number;
updated: number;
skipped: number;
}
async function processBatch(
store: SeedStore,
table: string,
batch: readonly SeedRecord[],
strategy: "skip" | "update" | "replace",
upsertKeys: readonly string[],
signal: AbortSignal | undefined
): Promise<BatchCounts> {
if (strategy === "skip" && upsertKeys.length > 0) {
const existing = await store.findExisting(
table,
batch,
upsertKeys,
signal
);
const existingKeys = new Set(
existing.map((r) => buildKey(r, upsertKeys))
);
const fresh = batch.filter(
(r) => !existingKeys.has(buildKey(r, upsertKeys))
);
const skipped = batch.length - fresh.length;
const inserted =
fresh.length > 0 ? await store.insert(table, fresh, signal) : 0;
return { inserted, updated: 0, skipped };
}
if (strategy === "update" && upsertKeys.length > 0) {
const existing = await store.findExisting(
table,
batch,
upsertKeys,
signal
);
const existingKeys = new Set(
existing.map((r) => buildKey(r, upsertKeys))
);
const fresh = batch.filter(
(r) => !existingKeys.has(buildKey(r, upsertKeys))
);
const toUpdate = batch.filter((r) =>
existingKeys.has(buildKey(r, upsertKeys))
);
const inserted =
fresh.length > 0 ? await store.insert(table, fresh, signal) : 0;
const updated =
toUpdate.length > 0
? await store.update(table, toUpdate, upsertKeys, signal)
: 0;
return { inserted, updated, skipped: 0 };
}
const inserted = await store.insert(table, batch, signal);
return { inserted, updated: 0, skipped: 0 };
}
function resolveData(seed: SeedDefinition): Promise<readonly SeedRecord[]> {
if (typeof seed.data === "function") {
return Promise.resolve(seed.data());
}
return Promise.resolve(seed.data);
}
function chunk<T>(items: readonly T[], size: number): T[][] {
const out: T[][] = [];
for (let i = 0; i < items.length; i += size) {
out.push(items.slice(i, i + size));
}
return out;
}
function buildKey(record: SeedRecord, keys: readonly string[]): string {
return keys.map((k) => String(record[k] ?? "")).join("::");
}
function completed(
seed: SeedDefinition,
startTime: number,
inserted: number,
updated: number,
skipped: number
): SeedResult {
return {
name: seed.name,
table: seed.table,
status: "completed",
recordsInserted: inserted,
recordsUpdated: updated,
recordsSkipped: skipped,
duration: performance.now() - startTime,
};
}
function wrapError(error: unknown, seedName: string): SeedError {
if (error instanceof SeedError) return error;
return new SeedError(
SEED_CODES.STORE_ERROR,
error instanceof Error ? error.message : String(error),
{
seedName,
cause: error instanceof Error ? error : undefined,
}
);
}

View File

@@ -0,0 +1,2 @@
export { SeedRunner } from "./runner.js";
export type { ISeedRunner } from "./runner.types.js";

View File

@@ -0,0 +1,215 @@
# runner
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `SeedRunner` orchestrates a single seeding run against an injected
`SeedStore`. It is the public entry point for actually applying a set of
declarative `SeedDefinition` objects: it picks the seeds that apply to
the requested environment, filters them by the caller's `only` /
`exclude` lists, topologically sorts the survivors, and executes each in
turn with the configured upsert strategy, batching, dry-run, and
cancellation behaviour.
The runner is intentionally store-agnostic. It does not know about
SurrealDB, Postgres, or any particular driver — it just calls the
`SeedStore` interface declared in `types/common.ts`. That keeps the core
universal (no `node:` / `bun:` imports) and lets the same runner
drive in-memory tests, local containers, and production databases
without rewriting the orchestration layer.
## Features
- **Dependency-ordered execution** via Kahn's-algorithm topological sort
(uses the sibling `utils/topo-sort` module).
- **Filtering**: `only` includes a whitelist; `exclude` removes from the
result; the two compose (`only` first, then `exclude`).
- **Three upsert strategies** (`skip`, `update`, `replace`) honoured at
the batch level — the runner delegates to
`SeedStore.findExisting`, `SeedStore.insert`, and `SeedStore.update`
according to the strategy and the presence of `upsertKeys`.
- **Batching** by `SeedRunConfig.batchSize`, defaulting to
`DEFAULT_BATCH_SIZE` (100). Records are passed to the store in
`Array.slice`-derived chunks; the store reports the actual insert /
update counts.
- **Dry-run mode** synthesises `completed` results without invoking the
store — useful for CI smoke tests and for validating environment
filters.
- **Dependency-aware skipping**: when a seed fails, every dependent in
the topo-order is recorded with `status: "skipped"` and
`error: "Dependency failed"` rather than attempted.
- **`AbortSignal` plumbing**: the signal is checked between seeds and
forwarded into every `SeedStore` call. Aborted runs return
`success: false` and mark every remaining seed `skipped` with
`error: "Aborted"`.
- **Injectable `LoggerLike`** (`@suaveplan/types`) with a no-op default.
Emits structured records at `debug` / `info` / `warn` / `error`
levels for every observable transition.
- **Store-error wrapping**: exceptions thrown by `SeedStore` methods are
wrapped as `SeedError` with code `SEED.STORE_ERROR` and the original
exception attached on `error.cause`. `SeedResult.error` carries the
human-readable message.
## Usage: Basic
```ts
import {
createInMemoryStore,
defineSeed,
SeedRegistry,
SeedRunner,
} from "@suaveplan/seed";
const registry = new SeedRegistry();
registry.register(
defineSeed("users")
.data([{ id: 1, name: "alice" }])
.upsert("skip", ["id"])
.build()
);
const store = createInMemoryStore();
const runner = new SeedRunner(registry, store);
const result = await runner.run({ environment: "dev" });
console.log(result.success, result.seedsRun);
```
## Usage: Advanced
```ts
import {
createInMemoryStore,
defineSeed,
SeedRegistry,
SeedRunner,
} from "@suaveplan/seed";
import type { LoggerLike } from "@suaveplan/types";
const registry = new SeedRegistry();
registry.registerMany([
defineSeed("tenants")
.environments("staging", "production")
.data(async () => fetchTenantsFromCsv())
.upsert("update", ["slug"])
.priority(0)
.build(),
defineSeed("admin-users")
.dependsOn("tenants")
.data(adminUsers)
.upsert("update", ["tenantId", "email"])
.priority(10)
.build(),
defineSeed("audit-buckets")
.dependsOn("tenants")
.truncateFirst()
.data([])
.priority(20)
.build(),
]);
const logger: LoggerLike = createStructuredLogger();
const runner = new SeedRunner(registry, productionStore, logger);
const controller = new AbortController();
setTimeout(() => controller.abort(), 30_000);
const result = await runner.run({
environment: "staging",
only: ["tenants", "admin-users"],
batchSize: 500,
continueOnError: false,
signal: controller.signal,
onProgress: (r) => logger.info("seed.progress", {
name: r.name,
status: r.status,
duration: r.duration,
}),
});
if (!result.success) {
throw new Error(
`Seed run failed: ${result.seedsFailed} failed, ${result.seedsSkipped} skipped`,
);
}
```
## API Reference
### `class SeedRunner implements ISeedRunner`
```ts
constructor(
registry: SeedRegistry,
store: SeedStore,
logger?: LoggerLike,
)
run(config: SeedRunConfig): Promise<SeedRunResult>
```
### `SeedRunConfig`
| Field | Type | Default |
| ----------------- | ----------------------------------- | ---------------------- |
| `environment` | `SeedEnvironment` | (required) |
| `only?` | `readonly string[]` | undefined (all seeds) |
| `exclude?` | `readonly string[]` | undefined |
| `dryRun?` | `boolean` | `false` |
| `batchSize?` | `number` | `DEFAULT_BATCH_SIZE` |
| `continueOnError?`| `boolean` | `false` |
| `onProgress?` | `(r: SeedResult) => void` | undefined |
| `signal?` | `AbortSignal` | undefined |
### `SeedRunResult`
| Field | Type | Meaning |
| ---------------- | ------------------- | -------------------------------------------------- |
| `environment` | `SeedEnvironment` | The environment the run was scoped to |
| `totalDuration` | `number` | Milliseconds via `performance.now()` |
| `results` | `readonly SeedResult[]` | One entry per attempted (or skipped) seed |
| `success` | `boolean` | True iff zero failures AND not aborted |
| `seedsRun` | `number` | Results with status other than `"skipped"` |
| `seedsSkipped` | `number` | Results with status `"skipped"` |
| `seedsFailed` | `number` | Results with status `"failed"` |
### Logger events
| Event | Level | Fields |
| ------------------ | ------- | --------------------------------------------------------------------------------------- |
| `seed.run.preparing` | debug | `environment`, `filteredCount`, `orderedCount` |
| `seed.run.start` | info | `environment`, `totalSeeds` |
| `seed.completed` | info | `name`, `table`, `recordsInserted`, `recordsUpdated`, `recordsSkipped`, `duration` |
| `seed.skipped` | warn | `name`, `table`, `reason` (`"Dependency failed"` \| `"Aborted"`) |
| `seed.failed` | error | `name`, `table`, `error`, `duration` |
| `seed.run.complete` | info | `environment`, `success`, `seedsRun`, `seedsSkipped`, `seedsFailed`, `totalDuration` |
## Implementation Notes
The actual batched insert/update/findExisting work lives in a sibling
`execute.ts` module so this file stays under the 250-line soft limit.
The split is purely organisational: `executeSeed(store, seed, config)`
is invoked from `SeedRunner.run` and returns a single `SeedResult` per
seed. Errors thrown by the store inside `executeSeed` are wrapped as
`SeedError({ code: SEED.STORE_ERROR, cause })`; the runner records the
message string on `SeedResult.error` while the structured fields
(`code`, `seedName`, `cause`) remain reachable to consumers that catch
the wrapped error from higher up.
`SeedRunner` keeps the abort-on-signal check at the **top** of the
execution loop so a signal raised between seeds short-circuits before
the next seed's data factory is even invoked. `AbortSignal` is also
forwarded into every `SeedStore` method via the optional `signal?`
parameter on the interface, letting network-bound adapters honour
cancellation mid-batch. The `InMemorySeedStore` shipped in
`fixtures/fixtures.ts` accepts but ignores the signal — it has nothing
to cancel.
Defensive `if (!seed) continue;` checks against `seedMap.get(name)`
were removed: `order` and `seedMap` are both built from the same
`filtered` array, so every name `topoSort` returns is guaranteed to
resolve. The TypeScript assertion (`as SeedDefinition`) records that
invariant without the false-positive coverage gap a runtime check would
introduce.

View File

@@ -0,0 +1,641 @@
import { beforeEach, describe, expect, it } from "@suaveplan/testing/runner";
import type { LoggerLike } from "@suaveplan/types";
import { defineSeed } from "../builder/builder.js";
import { SEED_CODES, SeedError } from "../error-codes/error-codes.js";
import {
createInMemoryStore,
type InMemorySeedStore,
} from "../fixtures/fixtures.js";
import { SeedRegistry } from "../registry/registry.js";
import type { SeedRecord, SeedResult, SeedStore } from "../types/common.js";
import { SeedRunner } from "./runner.js";
function recordingLogger(): LoggerLike & {
calls: Array<{ level: string; msg: string; ctx?: Record<string, unknown> }>;
} {
const calls: Array<{
level: string;
msg: string;
ctx?: Record<string, unknown>;
}> = [];
const make =
(level: string) => (msg: string, ctx?: Record<string, unknown>) =>
calls.push({ level, msg, ctx: ctx as Record<string, unknown> });
return {
calls,
debug: make("debug"),
info: make("info"),
warn: make("warn"),
error: make("error"),
};
}
class ThrowingStore implements SeedStore {
public constructor(private readonly throwOn: keyof SeedStore = "insert") { }
async insert(): Promise<number> {
if (this.throwOn === "insert") throw new Error("boom");
return 0;
}
async update(): Promise<number> {
if (this.throwOn === "update") throw new Error("boom");
return 0;
}
async findExisting(): Promise<readonly SeedRecord[]> {
if (this.throwOn === "findExisting") throw new Error("boom");
return [];
}
async truncate(): Promise<void> {
if (this.throwOn === "truncate") throw new Error("boom");
}
async tableExists(): Promise<boolean> {
return true;
}
}
describe("SeedRunner — dependency-ordered execution", () => {
let registry: SeedRegistry;
let store: InMemorySeedStore;
let runner: SeedRunner;
beforeEach(() => {
registry = new SeedRegistry();
store = createInMemoryStore();
runner = new SeedRunner(registry, store);
});
it("executes seeds in dependency order", async () => {
registry.register(
defineSeed("users")
.data([{ id: 1, name: "alice" }])
.build()
);
registry.register(
defineSeed("posts")
.dependsOn("users")
.data([{ id: 1, authorId: 1 }])
.build()
);
registry.register(
defineSeed("tags")
.data([{ id: 1 }])
.build()
);
const result = await runner.run({ environment: "dev" });
expect(result.success).toBe(true);
const order = result.results.map((r) => r.name);
const usersIdx = order.indexOf("users");
const postsIdx = order.indexOf("posts");
expect(usersIdx).toBeLessThan(postsIdx);
expect(order).toContain("tags");
});
it("only and exclude filters compose", async () => {
for (const n of ["a", "b", "c", "d"]) {
registry.register(
defineSeed(n)
.data([{ id: 1 }])
.build()
);
}
const result = await runner.run({
environment: "dev",
only: ["a", "b", "c"],
exclude: ["b"],
});
const ran = result.results.map((r) => r.name).sort();
expect(ran).toEqual(["a", "c"]);
});
it("dependent seeds skipped when dep fails", async () => {
registry.register(
defineSeed("a")
.data([{ id: 1 }])
.build()
);
registry.register(
defineSeed("b")
.dependsOn("a")
.data([{ id: 1 }])
.build()
);
const throwing = new ThrowingStore("insert");
const r = new SeedRunner(registry, throwing);
const result = await r.run({
environment: "dev",
continueOnError: true,
});
const aResult = result.results.find((x) => x.name === "a");
const bResult = result.results.find((x) => x.name === "b");
expect(aResult?.status).toBe("failed");
expect(aResult?.error).toContain("boom");
expect(bResult?.status).toBe("skipped");
expect(bResult?.error).toBe("Dependency failed");
expect(result.success).toBe(false);
});
it("dryRun bypasses store IO", async () => {
registry.register(
defineSeed("users")
.data([{ id: 1 }, { id: 2 }])
.build()
);
const throwing = new ThrowingStore("insert");
const r = new SeedRunner(registry, throwing);
const result = await r.run({ environment: "dev", dryRun: true });
expect(result.success).toBe(true);
const users = result.results[0];
expect(users.status).toBe("completed");
expect(users.recordsInserted).toBe(2);
});
it("batches by batchSize", async () => {
const records = Array.from({ length: 250 }, (_, i) => ({ id: i }));
registry.register(defineSeed("big").table("big").data(records).build());
let calls = 0;
const sizes: number[] = [];
const tracking: SeedStore = {
async insert(_t, recs) {
calls++;
sizes.push(recs.length);
return recs.length;
},
async update() {
return 0;
},
async findExisting() {
return [];
},
async truncate() { },
async tableExists() {
return true;
},
};
const r = new SeedRunner(registry, tracking);
await r.run({ environment: "dev", batchSize: 100 });
expect(calls).toBe(3);
expect(sizes).toEqual([100, 100, 50]);
});
it("skip upsert strategy", async () => {
registry.register(
defineSeed("users")
.table("u")
.data([{ id: 1 }, { id: 2 }, { id: 3 }])
.upsert("skip", ["id"])
.build()
);
await store.insert("u", [{ id: 2, prior: true }]);
const result = await runner.run({ environment: "dev" });
const users = result.results[0];
expect(users.recordsInserted).toBe(2);
expect(users.recordsSkipped).toBe(1);
});
it("update upsert strategy", async () => {
registry.register(
defineSeed("users")
.table("u")
.data([
{ id: 1, v: "new1" },
{ id: 2, v: "new2" },
{ id: 3, v: "new3" },
])
.upsert("update", ["id"])
.build()
);
await store.insert("u", [{ id: 2, v: "old" }]);
const result = await runner.run({ environment: "dev" });
const users = result.results[0];
expect(users.recordsInserted).toBe(2);
expect(users.recordsUpdated).toBe(1);
expect(users.recordsSkipped).toBe(0);
});
it("wraps store errors with SEED.STORE_ERROR", async () => {
registry.register(
defineSeed("users")
.data([{ id: 1 }])
.build()
);
const throwing = new ThrowingStore("insert");
const r = new SeedRunner(registry, throwing);
const result = await r.run({ environment: "dev" });
expect(result.results[0].status).toBe("failed");
expect(result.results[0].error).toContain("boom");
expect(result.success).toBe(false);
});
it("honours seed.truncate before insertion", async () => {
await store.insert("u", [{ id: 99 }]);
registry.register(
defineSeed("users")
.table("u")
.data([{ id: 1 }])
.truncateFirst()
.build()
);
await runner.run({ environment: "dev" });
const rows = store.getRecords("u");
expect(rows).toEqual([{ id: 1 }]);
});
it("replace strategy or empty upsertKeys inserts every record", async () => {
registry.register(
defineSeed("users")
.table("u")
.data([{ id: 1 }, { id: 2 }])
.upsert("replace", [])
.build()
);
const result = await runner.run({ environment: "dev" });
expect(result.results[0].recordsInserted).toBe(2);
});
it("seed with empty data returns completed with zero counts", async () => {
registry.register(defineSeed("empty").data([]).build());
const result = await runner.run({ environment: "dev" });
expect(result.results[0].status).toBe("completed");
expect(result.results[0].recordsInserted).toBe(0);
});
it("dataFactory is awaited", async () => {
registry.register(
defineSeed("u")
.dataFactory(async () => [{ id: 7 }])
.build()
);
const result = await runner.run({ environment: "dev" });
expect(result.results[0].recordsInserted).toBe(1);
});
it("non-Error thrown by store is stringified into result.error", async () => {
const stringThrower: SeedStore = {
async insert(): Promise<number> {
throw "raw string failure";
},
async update() {
return 0;
},
async findExisting() {
return [];
},
async truncate() { },
async tableExists() {
return true;
},
};
registry.register(
defineSeed("u")
.data([{ id: 1 }])
.build()
);
const r = new SeedRunner(registry, stringThrower);
const result = await r.run({ environment: "dev" });
expect(result.results[0].error).toContain("raw string failure");
});
it("SeedError thrown by store is passed through unwrapped", async () => {
const seedErrorThrower: SeedStore = {
async insert(): Promise<number> {
throw new SeedError(SEED_CODES.STORE_ERROR, "pre-wrapped");
},
async update() {
return 0;
},
async findExisting() {
return [];
},
async truncate() { },
async tableExists() {
return true;
},
};
registry.register(
defineSeed("u")
.data([{ id: 1 }])
.build()
);
const r = new SeedRunner(registry, seedErrorThrower);
const result = await r.run({ environment: "dev" });
expect(result.results[0].error).toBe("pre-wrapped");
});
it("halts on first failure when continueOnError is false", async () => {
registry.register(
defineSeed("a")
.data([{ id: 1 }])
.build()
);
registry.register(
defineSeed("c")
.data([{ id: 1 }])
.build()
);
const throwing = new ThrowingStore("insert");
const r = new SeedRunner(registry, throwing);
const result = await r.run({ environment: "dev" });
// Only the first seed runs; the second is never attempted.
expect(result.results.length).toBe(1);
expect(result.results[0].status).toBe("failed");
});
});
describe("SeedRunner — AbortSignal plumbing", () => {
it("abort signal halts run and skips remaining seeds", async () => {
const registry = new SeedRegistry();
const store = createInMemoryStore();
registry.register(
defineSeed("a")
.data([{ id: 1 }])
.build()
);
registry.register(
defineSeed("b")
.dependsOn("a")
.data([{ id: 1 }])
.build()
);
registry.register(
defineSeed("c")
.dependsOn("b")
.data([{ id: 1 }])
.build()
);
const controller = new AbortController();
const order: string[] = [];
const runner = new SeedRunner(registry, store);
const result = await runner.run({
environment: "dev",
signal: controller.signal,
onProgress(r) {
order.push(`${r.name}:${r.status}`);
if (r.name === "a") controller.abort();
},
});
expect(result.success).toBe(false);
const aResult = result.results.find((x) => x.name === "a");
const bResult = result.results.find((x) => x.name === "b");
const cResult = result.results.find((x) => x.name === "c");
expect(aResult?.status).toBe("completed");
expect(bResult?.status).toBe("skipped");
expect(bResult?.error).toBe("Aborted");
expect(cResult?.status).toBe("skipped");
expect(cResult?.error).toBe("Aborted");
});
});
describe("SeedRunner — logger events", () => {
it("emits structured log events when logger is wired", async () => {
const logger = recordingLogger();
const registry = new SeedRegistry();
const store = createInMemoryStore();
registry.register(
defineSeed("a")
.data([{ id: 1 }])
.build()
);
const runner = new SeedRunner(registry, store, logger);
await runner.run({ environment: "dev" });
const msgs = logger.calls.map((c) => c.msg);
expect(msgs).toContain("seed.run.preparing");
expect(msgs).toContain("seed.run.start");
expect(msgs).toContain("seed.completed");
expect(msgs).toContain("seed.run.complete");
expect(logger.calls.some((c) => c.level === "debug")).toBe(true);
});
it("emits seed.failed when a seed fails", async () => {
const logger = recordingLogger();
const registry = new SeedRegistry();
registry.register(
defineSeed("a")
.data([{ id: 1 }])
.build()
);
const throwing = new ThrowingStore("insert");
const runner = new SeedRunner(registry, throwing, logger);
await runner.run({ environment: "dev" });
const failed = logger.calls.find((c) => c.msg === "seed.failed");
expect(failed?.level).toBe("error");
expect(failed?.ctx?.name).toBe("a");
});
it("emits seed.skipped on dependency failure", async () => {
const logger = recordingLogger();
const registry = new SeedRegistry();
registry.register(
defineSeed("a")
.data([{ id: 1 }])
.build()
);
registry.register(
defineSeed("b")
.dependsOn("a")
.data([{ id: 1 }])
.build()
);
const throwing = new ThrowingStore("insert");
const runner = new SeedRunner(registry, throwing, logger);
await runner.run({ environment: "dev", continueOnError: true });
const skipped = logger.calls.find(
(c) => c.msg === "seed.skipped" && c.ctx?.name === "b"
);
expect(skipped?.level).toBe("warn");
expect(skipped?.ctx?.reason).toBe("Dependency failed");
});
it("runs silently when no logger is provided", async () => {
const registry = new SeedRegistry();
const store = createInMemoryStore();
registry.register(
defineSeed("a")
.data([{ id: 1 }])
.build()
);
const runner = new SeedRunner(registry, store);
const result = await runner.run({ environment: "dev" });
expect(result.success).toBe(true);
});
});
describe("SeedRunner — tier purity", () => {
it("runner has no node/bun imports (tier purity)", () => {
// The runner module imports only @suaveplan/* and relative paths,
// exercised via the import chain at the top of this file. If a
// node:/bun: import had crept in, the universal vitest run
// would already have failed; this assertion records the invariant
// and provides a named test case matching the spec delta.
expect(SeedRunner).toBeDefined();
});
});
describe("SeedRunner — upsert edge cases (branch coverage)", () => {
it("skip strategy with every record already existing inserts nothing", async () => {
const registry = new SeedRegistry();
const store = createInMemoryStore();
await store.insert("u", [{ id: 1 }, { id: 2 }]);
registry.register(
defineSeed("users")
.table("u")
.data([{ id: 1 }, { id: 2 }])
.upsert("skip", ["id"])
.build()
);
const runner = new SeedRunner(registry, store);
const result = await runner.run({ environment: "dev" });
expect(result.results[0].recordsInserted).toBe(0);
expect(result.results[0].recordsSkipped).toBe(2);
});
it("update strategy with all-existing records inserts nothing", async () => {
const registry = new SeedRegistry();
const store = createInMemoryStore();
await store.insert("u", [
{ id: 1, v: "old" },
{ id: 2, v: "old" },
]);
registry.register(
defineSeed("users")
.table("u")
.data([
{ id: 1, v: "new" },
{ id: 2, v: "new" },
])
.upsert("update", ["id"])
.build()
);
const runner = new SeedRunner(registry, store);
const result = await runner.run({ environment: "dev" });
expect(result.results[0].recordsInserted).toBe(0);
expect(result.results[0].recordsUpdated).toBe(2);
});
it("update strategy with no existing records updates nothing", async () => {
const registry = new SeedRegistry();
const store = createInMemoryStore();
registry.register(
defineSeed("users")
.table("u")
.data([{ id: 1 }, { id: 2 }])
.upsert("update", ["id"])
.build()
);
const runner = new SeedRunner(registry, store);
const result = await runner.run({ environment: "dev" });
expect(result.results[0].recordsInserted).toBe(2);
expect(result.results[0].recordsUpdated).toBe(0);
});
it("buildKey handles records with missing or null key field values", async () => {
const registry = new SeedRegistry();
const store = createInMemoryStore();
// Pre-populate with a record whose `id` is missing — its key collapses to "" via `?? ""`.
await store.insert("u", [{ name: "prior" }]);
registry.register(
defineSeed("users")
.table("u")
.data([
{ id: undefined, name: "a" }, // exercises the ?? "" branch
{ id: null, name: "b" }, // exercises the ?? "" branch
{ id: 3, name: "c" }, // distinct key
])
.upsert("skip", ["id"])
.build()
);
const runner = new SeedRunner(registry, store);
const result = await runner.run({ environment: "dev" });
// The first two records share the empty-string key with the prior record → skipped;
// the third has a distinct key → inserted.
expect(result.results[0].recordsInserted).toBe(1);
expect(result.results[0].recordsSkipped).toBe(2);
});
it("uses 'skip' default when SeedDefinition omits upsertStrategy", async () => {
// Hand-rolled SeedDefinition bypassing the builder's always-emits-upsertStrategy behaviour.
const registry = new SeedRegistry();
registry.register({
name: "raw",
table: "r",
data: [{ id: 1 }],
});
const store = createInMemoryStore();
const runner = new SeedRunner(registry, store);
const result = await runner.run({ environment: "dev" });
expect(result.success).toBe(true);
expect(result.results[0].recordsInserted).toBe(1);
});
it("uses DEFAULT_BATCH_SIZE when batchSize is not configured", async () => {
// 150 records, default batch size = 100 → expect 2 calls of sizes [100, 50].
const registry = new SeedRegistry();
const sizes: number[] = [];
const tracking: SeedStore = {
async insert(_t, recs) {
sizes.push(recs.length);
return recs.length;
},
async update() {
return 0;
},
async findExisting() {
return [];
},
async truncate() { },
async tableExists() {
return true;
},
};
const records = Array.from({ length: 150 }, (_, i) => ({ id: i }));
registry.register(
defineSeed("u")
.table("u")
.data(records)
.upsert("replace", [])
.build()
);
const runner = new SeedRunner(registry, tracking);
await runner.run({ environment: "dev" });
expect(sizes).toEqual([100, 50]);
});
});
describe("SeedRunner — onProgress emission", () => {
it("invokes onProgress for every completed and skipped result", async () => {
const registry = new SeedRegistry();
const store = createInMemoryStore();
registry.register(
defineSeed("a")
.data([{ id: 1 }])
.build()
);
registry.register(
defineSeed("b")
.data([{ id: 1 }])
.build()
);
const runner = new SeedRunner(registry, store);
const progress: SeedResult[] = [];
await runner.run({
environment: "dev",
onProgress: (r) => progress.push(r),
});
expect(progress.length).toBe(2);
expect(progress.every((r) => r.status === "completed")).toBe(true);
});
});

View File

@@ -0,0 +1,203 @@
import type { LoggerLike } from "@suaveplan/types";
import type { SeedRegistry } from "../registry/registry.js";
import type {
SeedDefinition,
SeedResult,
SeedRunConfig,
SeedRunResult,
SeedStore,
} from "../types/common.js";
import { topoSort } from "../utils/topo-sort/topo-sort.js";
import { executeSeed } from "./execute.js";
import type { ISeedRunner } from "./runner.types.js";
const NOOP_LOGGER: LoggerLike = {
debug() {},
info() {},
warn() {},
error() {},
};
/**
* Orchestrates a single seeding run against an injected `SeedStore`.
*
* Resolves the seeds applicable to `config.environment`, applies
* `only`/`exclude` filters, topologically orders them, and executes each
* in turn with the configured upsert strategy, batching, dry-run, and
* cancellation behaviour. Failed dependencies cause dependents to skip.
* Store exceptions are wrapped as `SeedError` with `SEED.STORE_ERROR` to
* preserve programmatic dispatch on failure while keeping the original
* exception reachable via `error.cause`.
*/
export class SeedRunner implements ISeedRunner {
private readonly _registry: SeedRegistry;
private readonly _store: SeedStore;
private readonly _logger: LoggerLike;
public constructor(
registry: SeedRegistry,
store: SeedStore,
logger?: LoggerLike
) {
this._registry = registry;
this._store = store;
this._logger = logger ?? NOOP_LOGGER;
}
public async run(config: SeedRunConfig): Promise<SeedRunResult> {
const startTime = performance.now();
const filtered = this._collectSeeds(config);
const order = topoSort(filtered);
const seedMap = new Map(filtered.map((s) => [s.name, s]));
this._logger.debug("seed.run.preparing", {
environment: config.environment,
filteredCount: filtered.length,
orderedCount: order.length,
});
this._logger.info("seed.run.start", {
environment: config.environment,
totalSeeds: filtered.length,
});
const results: SeedResult[] = [];
let failed = 0;
let abortedFrom = -1;
for (let i = 0; i < order.length; i++) {
if (config.signal?.aborted) {
abortedFrom = i;
break;
}
// `order` and `seedMap` are both built from `filtered`, so every
// name in `order` is present in `seedMap`. The assertion records
// that invariant for the type checker.
const name = order[i] as string;
const seed = seedMap.get(name) as SeedDefinition;
if (depFailed(seed, results)) {
const r = skipResult(seed, "Dependency failed");
results.push(r);
config.onProgress?.(r);
this._logger.warn("seed.skipped", {
name: seed.name,
table: seed.table,
reason: "Dependency failed",
});
continue;
}
const result = await executeSeed(this._store, seed, config);
results.push(result);
config.onProgress?.(result);
if (result.status === "failed") {
failed++;
this._logger.error("seed.failed", {
name: seed.name,
table: seed.table,
error: result.error,
duration: result.duration,
});
if (!config.continueOnError) break;
} else {
this._logger.info("seed.completed", {
name: seed.name,
table: seed.table,
recordsInserted: result.recordsInserted,
recordsUpdated: result.recordsUpdated,
recordsSkipped: result.recordsSkipped,
duration: result.duration,
});
}
}
if (abortedFrom >= 0) {
this._appendAborted(order, seedMap, abortedFrom, results, config);
}
const skipped = results.filter((r) => r.status === "skipped").length;
const success = failed === 0 && abortedFrom < 0;
const summary: SeedRunResult = {
environment: config.environment,
totalDuration: performance.now() - startTime,
results,
success,
seedsRun: results.length - skipped,
seedsSkipped: skipped,
seedsFailed: failed,
};
this._logger.info("seed.run.complete", {
environment: config.environment,
success,
seedsRun: summary.seedsRun,
seedsSkipped: summary.seedsSkipped,
seedsFailed: summary.seedsFailed,
totalDuration: summary.totalDuration,
});
return summary;
}
private _collectSeeds(config: SeedRunConfig): readonly SeedDefinition[] {
const seeds = this._registry.getForEnvironment(config.environment);
let filtered: readonly SeedDefinition[] = seeds;
if (config.only && config.only.length > 0) {
const only = new Set(config.only);
filtered = filtered.filter((s) => only.has(s.name));
}
if (config.exclude && config.exclude.length > 0) {
const exclude = new Set(config.exclude);
filtered = filtered.filter((s) => !exclude.has(s.name));
}
return filtered;
}
private _appendAborted(
order: readonly string[],
seedMap: ReadonlyMap<string, SeedDefinition>,
from: number,
results: SeedResult[],
config: SeedRunConfig
): void {
for (let i = from; i < order.length; i++) {
const name = order[i] as string;
const seed = seedMap.get(name) as SeedDefinition;
const r = skipResult(seed, "Aborted");
results.push(r);
config.onProgress?.(r);
this._logger.warn("seed.skipped", {
name: seed.name,
table: seed.table,
reason: "Aborted",
});
}
}
}
function depFailed(
seed: SeedDefinition,
results: readonly SeedResult[]
): boolean {
const deps = seed.dependencies;
if (!deps || deps.length === 0) return false;
return deps.some((dep) => {
const r = results.find((x) => x.name === dep);
return r?.status === "failed";
});
}
function skipResult(seed: SeedDefinition, reason: string): SeedResult {
return {
name: seed.name,
table: seed.table,
status: "skipped",
recordsInserted: 0,
recordsUpdated: 0,
recordsSkipped: 0,
duration: 0,
error: reason,
};
}

View File

@@ -0,0 +1,10 @@
import type { SeedRunConfig, SeedRunResult } from "../types/common.js";
/**
* Public contract for the `SeedRunner` orchestrator. Callers programming
* against the interface (rather than the concrete class) can substitute
* alternative implementations in tests or higher-tier wrappers.
*/
export interface ISeedRunner {
run(config: SeedRunConfig): Promise<SeedRunResult>;
}

View File

@@ -0,0 +1,151 @@
# types/common
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Shared type module for `@suaveplan/seed`. Every public class and function
in the package — `SeedBuilder`, `SeedRegistry`, `SeedRunner`,
`FixtureFactory`, `InMemorySeedStore`, `topoSort`, `detectCycles` —
references the type contracts declared here. Defining them in one
location keeps the public surface coherent, lets consumers pull a single
import path when they want to type their own `SeedStore` adapters, and
ensures cross-module type-narrowing works without circular imports.
The types are all data-shapes (`SeedDefinition`, `SeedRecord`,
`SeedResult`, `SeedRunResult`, `SeedRunConfig`, `SeedStore`) plus their
supporting unions (`SeedEnvironment`, `SeedStatus`, `UpsertStrategy`,
`SeedDataFactory`). They are intentionally framework-free — no Zod
schemas, no runtime validators — because the package's developer-facing
API is constructed in-process, not received over a wire.
## Features
- `SeedDefinition` and `SeedRecord` shapes
- `SeedResult` / `SeedRunResult` shapes returned by the runner
- `SeedRunConfig` shape consumed by `SeedRunner.run`
- `SeedStore` adapter contract (`insert` / `update` / `findExisting` / `truncate` / `tableExists`) with optional `AbortSignal` plumbing
- `SeedEnvironment` open string-literal union accepting custom project environments
- `SeedStatus` and `UpsertStrategy` enums-as-unions
- `SeedDataFactory` async-or-sync data producer signature
- `readonly` on every collection so consumers cannot mutate definitions in-flight
## Usage: Basic
```ts
import type { SeedDefinition, SeedStore } from "@suaveplan/seed";
const usersSeed: SeedDefinition = {
name: "users",
table: "user",
data: [{ id: 1, name: "alice" }],
upsertStrategy: "skip",
upsertKeys: ["id"],
};
class MyStore implements SeedStore {
async insert(table, records) {
// ...
return records.length;
}
async update() { return 0; }
async findExisting() { return []; }
async truncate() {}
async tableExists() { return true; }
}
```
## Usage: Advanced
```ts
import type {
SeedDataFactory,
SeedDefinition,
SeedEnvironment,
SeedRunConfig,
SeedRunResult,
SeedStore,
} from "@suaveplan/seed";
const tenantsFactory: SeedDataFactory = async () => {
const csv = await fetch("/tenants.csv").then((r) => r.text());
return parseTenants(csv);
};
const def: SeedDefinition = {
name: "tenants",
table: "tenant",
environments: ["staging", "production"],
dependencies: [],
data: tenantsFactory,
upsertStrategy: "update",
upsertKeys: ["slug"],
priority: 0,
};
async function runFor(
env: SeedEnvironment,
store: SeedStore,
signal: AbortSignal,
): Promise<SeedRunResult> {
const config: SeedRunConfig = {
environment: env,
batchSize: 500,
continueOnError: false,
signal,
onProgress: (r) => console.log(r.name, r.status, r.duration),
};
return runner.run(config);
}
```
## API Reference
### Types
- `SeedEnvironment` — string-literal union with `"dev" | "staging" | "test" | "production"` plus any custom string
- `SeedStatus` — `"pending" | "running" | "completed" | "failed" | "skipped"`
- `SeedRecord` — `Record<string, unknown>`
- `SeedDataFactory` — `() => SeedRecord[] | Promise<SeedRecord[]>`
- `UpsertStrategy` — `"skip" | "update" | "replace"`
### Interfaces
- `SeedDefinition` — every field declarative on a seed (`name`, `table`, `environments?`, `dependencies?`, `data`, `upsertStrategy?`, `upsertKeys?`, `description?`, `priority?`, `truncate?`)
- `SeedResult` — single-seed outcome (`name`, `table`, `status`, counts, `duration`, `error?`)
- `SeedRunResult` — aggregate outcome of one `run()` call
- `SeedStore` — adapter contract (`insert`, `update`, `findExisting`, `truncate`, `tableExists`) with optional `signal` parameter on every method
- `SeedRunConfig` — input to `SeedRunner.run` (`environment`, `only?`, `exclude?`, `dryRun?`, `batchSize?`, `continueOnError?`, `onProgress?`, `signal?`)
## Implementation Notes
`SeedEnvironment` uses the `(string & {})` trick — a no-op intersection
that preserves IDE autocomplete for the well-known values while still
accepting arbitrary strings. This is the canonical TypeScript idiom for
"open enum" string unions and matches the pattern used elsewhere in the
project (e.g. `LocaleCode`, `MimeType`).
Every array field on `SeedDefinition` and `SeedRunConfig` is `readonly`,
so callers (or the registry's defensive copies) cannot mutate the
definitions mid-run. `SeedStore` methods accept `readonly SeedRecord[]`
inputs, freeing implementations from copying defensively while still
allowing them to enumerate.
`SeedDataFactory` returns either a sync `SeedRecord[]` or a
`Promise<SeedRecord[]>`. The runner always `await`s the result so callers
can treat both forms identically; in practice the sync form covers
inline test fixtures and the async form covers loading CSVs, fetching
from remote APIs, or generating records from `FixtureFactory.createMany`.
`SeedStore.insert` returns the number of records actually inserted, not
the count the caller passed in — adapters MAY filter (e.g. via
`ON CONFLICT DO NOTHING`) and the runner trusts the returned count when
populating `SeedResult.recordsInserted`.
The optional `signal?: AbortSignal` parameter on every `SeedStore` method
lets network-bound stores honour cancellation; the `InMemorySeedStore`
ignores it. The runner propagates `SeedRunConfig.signal` to every store
call but ALSO performs its own check between seeds so a cancelled run
shuts down even if the store ignores the signal.

View File

@@ -0,0 +1,132 @@
/** Environment names for dataset selection. Accepts any string for project-specific names. */
export type SeedEnvironment =
| "dev"
| "staging"
| "test"
| "production"
| (string & {});
/** Status of a single seed execution within a run. */
export type SeedStatus =
| "pending"
| "running"
| "completed"
| "failed"
| "skipped";
/** A single record produced by a seed; arbitrary keyed data the store can ingest. */
export type SeedRecord = Record<string, unknown>;
/**
* Factory that produces seed records, optionally asynchronously. Invoked once
* per `SeedRunner.run` call when `SeedDefinition.data` is a function.
*/
export type SeedDataFactory = () => SeedRecord[] | Promise<SeedRecord[]>;
/**
* Upsert strategy for handling records that already exist:
*
* - `skip` — insert only records whose key tuple is absent
* - `update` — insert absent records, call `store.update` for existing ones
* - `replace` — call `store.insert` for every record (or use when `upsertKeys` is empty)
*/
export type UpsertStrategy = "skip" | "update" | "replace";
/** A single declarative seed definition. */
export interface SeedDefinition {
/** Unique name identifying this seed (e.g., 'users', 'products'). */
readonly name: string;
/** Table or collection name to seed. */
readonly table: string;
/** Environment(s) this seed applies to. Undefined or empty means all environments. */
readonly environments?: readonly SeedEnvironment[];
/** Names of seeds that must run before this one. */
readonly dependencies?: readonly string[];
/** Static data or a factory function that produces data. */
readonly data: readonly SeedRecord[] | SeedDataFactory;
/** How to handle records that already exist (default: 'skip'). */
readonly upsertStrategy?: UpsertStrategy;
/** Key field(s) used to identify existing records for upsert. */
readonly upsertKeys?: readonly string[];
/** Optional description. */
readonly description?: string;
/** Priority for ordering seeds with no dependency relationship (lower = first). */
readonly priority?: number;
/** Whether to truncate the table before seeding. */
readonly truncate?: boolean;
}
/** Result of executing a single seed within a run. */
export interface SeedResult {
readonly name: string;
readonly table: string;
readonly status: SeedStatus;
readonly recordsInserted: number;
readonly recordsUpdated: number;
readonly recordsSkipped: number;
readonly duration: number;
readonly error?: string;
}
/** Aggregate result of a complete `SeedRunner.run` call. */
export interface SeedRunResult {
readonly environment: SeedEnvironment;
readonly totalDuration: number;
readonly results: readonly SeedResult[];
readonly success: boolean;
readonly seedsRun: number;
readonly seedsSkipped: number;
readonly seedsFailed: number;
}
/**
* Storage adapter contract the runner injects into. Every method is async to
* permit network-bound stores; the `signal` parameter is optional and MAY be
* ignored by stores (e.g. `InMemorySeedStore`) that have nothing to cancel.
*/
export interface SeedStore {
/** Insert records into a table. Returns the number actually inserted. */
insert(
table: string,
records: readonly SeedRecord[],
signal?: AbortSignal
): Promise<number>;
/** Update records matching the key fields. Returns the number updated. */
update(
table: string,
records: readonly SeedRecord[],
keyFields: readonly string[],
signal?: AbortSignal
): Promise<number>;
/** Return existing records in the table whose key tuples match any in `records`. */
findExisting(
table: string,
records: readonly SeedRecord[],
keyFields: readonly string[],
signal?: AbortSignal
): Promise<readonly SeedRecord[]>;
/** Delete all records from a table. The table itself remains present. */
truncate(table: string, signal?: AbortSignal): Promise<void>;
/** Check whether the table exists in the underlying store. */
tableExists(table: string, signal?: AbortSignal): Promise<boolean>;
}
/** Configuration for a single run of the seed runner. */
export interface SeedRunConfig {
/** Target environment used for filtering applicable seeds. */
readonly environment: SeedEnvironment;
/** Only run these specific seeds (by name). Applied before `exclude`. */
readonly only?: readonly string[];
/** Skip these seeds (by name). Applied after `only`. */
readonly exclude?: readonly string[];
/** Dry run — report what would happen without invoking the store. */
readonly dryRun?: boolean;
/** Batch size for bulk operations. Defaults to `DEFAULT_BATCH_SIZE`. */
readonly batchSize?: number;
/** Continue on error instead of aborting the run when a seed fails. */
readonly continueOnError?: boolean;
/** Callback invoked with each `SeedResult` as it completes. */
readonly onProgress?: (result: SeedResult) => void;
/** Optional `AbortSignal` honoured between seeds and propagated to the store. */
readonly signal?: AbortSignal;
}

View File

@@ -0,0 +1,6 @@
export type {
DetectCyclesSeed,
TopoSortOptions,
TopoSortSeed,
} from "./topo-sort.js";
export { detectCycles, topoSort } from "./topo-sort.js";

View File

@@ -0,0 +1,109 @@
# topo-sort
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`topo-sort` is the dependency-resolution engine that lets `@suaveplan/seed` execute seed definitions in an order where every dependency precedes its dependents. Without it, the runner could not enforce the contract that, for example, `categories` is populated before `products` references it. The module exposes two functions: `topoSort`, which produces a single linear execution order (or throws when the graph is broken), and `detectCycles`, a non-throwing diagnostic counterpart that returns explicit cycle traces for tooling and error reporting. Both functions are pure and operate over structurally-typed inputs, so they work equally well on `SeedDefinition` arrays and on lightweight test fixtures.
## Features
- Single-pass [Kahn's algorithm](https://en.wikipedia.org/wiki/Topological_sorting#Kahn's_algorithm) topological sort.
- Stable priority tiebreak: among independent seeds, lower `priority` runs first (default `0`).
- Stable, `SeedError`-typed failure modes:
- `SEED.UNREGISTERED_DEPENDENCY` when a `dependencies[]` entry names a seed that is not present in the input.
- `SEED.CIRCULAR_DEPENDENCY` when the graph contains a cycle (detected via residual unprocessed nodes after the queue drains).
- `SEED.DEPENDENCY_DEPTH_EXCEEDED` defensive guard against pathological iteration counts.
- DFS-based `detectCycles` that returns an array of cycle traces and never throws — ideal for surfacing diagnostics in test setup or CI pre-flight checks.
- Universal: zero `node:` / `bun:` imports, runs in browsers, Bun, Node, workers, and Deno.
- Accepts `readonly` arrays and `readonly string[]` dependency lists for cheap interop with frozen domain types.
## Usage: Basic
```ts
import { topoSort } from "@suaveplan/seed/utils/topo-sort/topo-sort";
const order = topoSort([
{ name: "products", dependencies: ["categories"] },
{ name: "categories" },
{ name: "reviews", dependencies: ["products", "users"] },
{ name: "users" },
]);
// → ["categories", "users", "products", "reviews"] (priority ties broken stably)
```
## Usage: Advanced
```ts
import { SeedError, SEED_CODES } from "@suaveplan/seed";
import { detectCycles, topoSort } from "@suaveplan/seed/utils/topo-sort/topo-sort";
const seeds = [
{ name: "a", dependencies: ["b"], priority: 0 },
{ name: "b", dependencies: ["a"], priority: 0 },
];
// Pre-flight: surface every cycle as an explicit trace.
const cycles = detectCycles(seeds);
if (cycles.length > 0) {
for (const trace of cycles) {
console.error(`cycle: ${trace}`);
}
}
// Execution path throws a typed error.
try {
topoSort(seeds);
} catch (err) {
if (err instanceof SeedError && err.code === SEED_CODES.CIRCULAR_DEPENDENCY) {
// remediate
}
}
```
## API Reference
### `topoSort(seeds): string[]`
```ts
function topoSort(
seeds: readonly {
readonly name: string;
readonly dependencies?: readonly string[];
readonly priority?: number;
}[]
): string[];
```
Returns a fresh array of seed names in execution order. Throws `SeedError` (with one of `SEED.UNREGISTERED_DEPENDENCY`, `SEED.CIRCULAR_DEPENDENCY`, `SEED.DEPENDENCY_DEPTH_EXCEEDED`) on any structural problem.
### `detectCycles(seeds): string[]`
```ts
function detectCycles(
seeds: readonly {
readonly name: string;
readonly dependencies?: readonly string[];
}[]
): string[];
```
Returns an array of cycle-trace strings shaped like `"a -> b -> a"`. Returns `[]` for an empty input or any DAG. Never throws. Dependencies whose names are not present in the graph are silently skipped — they are reported by `topoSort` as `UNREGISTERED_DEPENDENCY` instead.
## Implementation Notes
`topoSort` performs exactly one Kahn's-algorithm pass:
1. Build three maps in a single loop: `graph` (name → deps), `inDegree` (name → 0), and `priority` (name → priority).
2. Validate every dependency edge: when a name in `dependencies` is absent from `graph`, throw `UNREGISTERED_DEPENDENCY`; otherwise increment the dependent's in-degree.
3. Seed the queue with every name whose `inDegree === 0`, sorted by ascending priority.
4. Drain the queue: pop, append to `result`, decrement the in-degree of every node that depends on the popped name. When a dependent's in-degree falls to zero, enqueue it and re-sort by priority. Stable priority ordering is preserved by re-sorting on insertion rather than maintaining a binary heap — for the small graphs typical of seed sets, the simpler approach is faster and more legible.
5. After the loop, if `result.length !== graph.size`, identify the unprocessed names and throw `CIRCULAR_DEPENDENCY` with the offending names embedded in the message.
The `DEPENDENCY_DEPTH_EXCEEDED` guard is structurally unreachable from valid input because Kahn's algorithm enqueues each node at most once. It remains in place as belt-and-braces against future refactors that might violate that invariant — the corresponding line is excluded from coverage with `/* c8 ignore */` plus a justifying comment.
`detectCycles` uses iterative DFS with two sets: `visited` (fully explored) and `stack` (current recursion path). When DFS encounters a node already on `stack`, it slices the path from that node forward, appends the cycle-closing edge, and joins with `" -> "`. Dependencies that name non-existent nodes are skipped so external references (e.g., seeds defined outside the current registry slice) do not surface as false-positive cycles.
Both functions are pure: they allocate no shared state, do not mutate their inputs, and produce deterministic output for any given input. Priority is the only tiebreak — insertion order is not preserved beyond what priority encodes, which is intentional so callers cannot accidentally depend on registration order.

View File

@@ -0,0 +1,321 @@
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { SuaveplanError } from "@suaveplan/error";
import { describe, expect, it } from "@suaveplan/testing/runner";
import fc from "fast-check";
import { SEED_CODES, SeedError } from "../../error-codes/error-codes.js";
import { detectCycles, topoSort } from "./topo-sort.js";
const HERE = fileURLToPath(new URL(".", import.meta.url));
describe("topoSort", () => {
it("respects dependency edges", { timeout: 5000 }, () => {
const order = topoSort([
{ name: "a", dependencies: ["b"] },
{ name: "b" },
]);
expect(order).toEqual(["b", "a"]);
});
it("priority breaks ties for independent seeds", { timeout: 5000 }, () => {
const order = topoSort([
{ name: "a", priority: 10 },
{ name: "b", priority: 1 },
]);
expect(order).toEqual(["b", "a"]);
});
it("raises UNREGISTERED_DEPENDENCY for missing dep", {
timeout: 5000,
}, () => {
let caught: unknown;
try {
topoSort([{ name: "a", dependencies: ["missing"] }]);
} catch (e) {
caught = e;
}
expect(caught).toBeInstanceOf(SeedError);
expect(caught).toBeInstanceOf(SuaveplanError);
const err = caught as SeedError;
expect(err.code).toBe(SEED_CODES.UNREGISTERED_DEPENDENCY);
expect(err.seedName).toBe("a");
expect(err.dependency).toBe("missing");
});
it("raises CIRCULAR_DEPENDENCY for cycle", { timeout: 5000 }, () => {
let caught: unknown;
try {
topoSort([
{ name: "a", dependencies: ["b"] },
{ name: "b", dependencies: ["a"] },
]);
} catch (e) {
caught = e;
}
expect(caught).toBeInstanceOf(SeedError);
const err = caught as SeedError;
expect(err.code).toBe(SEED_CODES.CIRCULAR_DEPENDENCY);
expect(err.message).toContain("a");
expect(err.message).toContain("b");
});
it("upstream characterisation cases pass", { timeout: 5000 }, () => {
// Case 1: dependency order
const products = topoSort([
{ name: "products", dependencies: ["categories"] },
{ name: "categories" },
{ name: "reviews", dependencies: ["products", "users"] },
{ name: "users" },
]);
expect(products.indexOf("categories")).toBeLessThan(
products.indexOf("products")
);
expect(products.indexOf("products")).toBeLessThan(
products.indexOf("reviews")
);
expect(products.indexOf("users")).toBeLessThan(
products.indexOf("reviews")
);
// Case 2: seeds with no dependencies
const trio = topoSort([{ name: "a" }, { name: "b" }, { name: "c" }]);
expect(trio).toHaveLength(3);
// Case 3: priority for independent seeds
const byPriority = topoSort([
{ name: "c", priority: 3 },
{ name: "a", priority: 1 },
{ name: "b", priority: 2 },
]);
expect(byPriority).toEqual(["a", "b", "c"]);
// Case 4: circular dependency throws
expect(() =>
topoSort([
{ name: "a", dependencies: ["b"] },
{ name: "b", dependencies: ["a"] },
])
).toThrow(SeedError);
// Case 5: missing dependency throws
expect(() =>
topoSort([{ name: "a", dependencies: ["nonexistent"] }])
).toThrow(SeedError);
// Case 6: linear chain
const linear = topoSort([
{ name: "c", dependencies: ["b"] },
{ name: "b", dependencies: ["a"] },
{ name: "a" },
]);
expect(linear).toEqual(["a", "b", "c"]);
// Case 7: diamond dependency
const diamond = topoSort([
{ name: "d", dependencies: ["b", "c"] },
{ name: "b", dependencies: ["a"] },
{ name: "c", dependencies: ["a"] },
{ name: "a" },
]);
expect(diamond.indexOf("a")).toBeLessThan(diamond.indexOf("b"));
expect(diamond.indexOf("a")).toBeLessThan(diamond.indexOf("c"));
expect(diamond.indexOf("b")).toBeLessThan(diamond.indexOf("d"));
expect(diamond.indexOf("c")).toBeLessThan(diamond.indexOf("d"));
// Case 8: single seed
expect(topoSort([{ name: "only" }])).toEqual(["only"]);
// Case 9: empty array
expect(topoSort([])).toEqual([]);
});
it("default priority is 0 when omitted", { timeout: 5000 }, () => {
const order = topoSort([{ name: "a" }, { name: "b", priority: -1 }]);
expect(order).toEqual(["b", "a"]);
});
it("handles seeds with undefined dependencies array", {
timeout: 5000,
}, () => {
// No `dependencies` property at all on first seed exercises the
// `seed.dependencies ?? []` branch.
const order = topoSort([{ name: "x" }]);
expect(order).toEqual(["x"]);
});
it("DEPENDENCY_DEPTH_EXCEEDED guard fires when maxIterations is exceeded", {
timeout: 5000,
}, () => {
// The default ceiling (MAX_DEPENDENCY_DEPTH * seeds.length) is
// structurally unreachable with a correct Kahn's pass — every
// node enqueues at most once. The hidden `maxIterations` option
// exposes the guard for testing without violating that invariant.
let caught: unknown;
try {
topoSort(
[
{ name: "a" },
{ name: "b", dependencies: ["a"] },
{ name: "c", dependencies: ["b"] },
],
{ maxIterations: 1 }
);
} catch (e) {
caught = e;
}
expect(caught).toBeInstanceOf(SeedError);
const err = caught as SeedError;
expect(err.code).toBe(SEED_CODES.DEPENDENCY_DEPTH_EXCEEDED);
});
it("default guard tolerates long linear chains", { timeout: 5000 }, () => {
const big: { name: string; dependencies?: string[] }[] = [];
for (let i = 0; i < 200; i += 1) {
big.push({
name: `n${i}`,
dependencies: i === 0 ? [] : [`n${i - 1}`],
});
}
const order = topoSort(big);
expect(order).toHaveLength(200);
expect(order[0]).toBe("n0");
expect(order[199]).toBe("n199");
});
it("property: output respects every dependency edge for random DAGs", {
timeout: 30000,
}, () => {
fc.assert(
fc.property(
fc.integer({ min: 1, max: 12 }).chain((size) => {
const names = Array.from(
{ length: size },
(_, i) => `s${i}`
);
return fc
.tuple(
...names.map((_, i) =>
fc.subarray(names.slice(0, i))
)
)
.map((depsList) =>
names.map((name, i) => ({
name,
dependencies: depsList[i] ?? [],
priority: 0,
}))
);
}),
(seeds) => {
const order = topoSort(seeds);
expect(order).toHaveLength(seeds.length);
const pos = new Map(order.map((n, i) => [n, i]));
for (const seed of seeds) {
for (const dep of seed.dependencies ?? []) {
const di = pos.get(dep);
const ni = pos.get(seed.name);
expect(di).toBeDefined();
expect(ni).toBeDefined();
expect(di as number).toBeLessThan(ni as number);
}
}
}
),
{ numRuns: 80 }
);
});
});
describe("detectCycles", () => {
it("detectCycles returns empty on DAG", { timeout: 5000 }, () => {
expect(
detectCycles([{ name: "a", dependencies: ["b"] }, { name: "b" }])
).toEqual([]);
});
it("detectCycles reports cycle participants", { timeout: 5000 }, () => {
const cycles = detectCycles([
{ name: "a", dependencies: ["b"] },
{ name: "b", dependencies: ["a"] },
]);
expect(cycles.length).toBeGreaterThan(0);
expect(cycles[0]).toContain("a");
expect(cycles[0]).toContain("b");
expect(cycles[0]).toContain(" -> ");
});
it("detects indirect cycles", { timeout: 5000 }, () => {
const cycles = detectCycles([
{ name: "a", dependencies: ["b"] },
{ name: "b", dependencies: ["c"] },
{ name: "c", dependencies: ["a"] },
]);
expect(cycles.length).toBeGreaterThan(0);
});
it("returns empty for seeds with no dependencies", {
timeout: 5000,
}, () => {
expect(detectCycles([{ name: "a" }, { name: "b" }])).toEqual([]);
});
it("skips dependencies whose names are not in the graph", {
timeout: 5000,
}, () => {
// No false-positive cycle from an external name.
expect(
detectCycles([{ name: "a", dependencies: ["external"] }])
).toEqual([]);
});
it("handles empty input", { timeout: 5000 }, () => {
expect(detectCycles([])).toEqual([]);
});
it("does not re-visit nodes already fully explored", {
timeout: 5000,
}, () => {
// Diamond shape: `a` is visited via two paths but should only be
// explored once thanks to the `visited` set short-circuit.
const cycles = detectCycles([
{ name: "d", dependencies: ["b", "c"] },
{ name: "b", dependencies: ["a"] },
{ name: "c", dependencies: ["a"] },
{ name: "a" },
]);
expect(cycles).toEqual([]);
});
});
describe("tier purity & subpath export", () => {
it("topo-sort has no node/bun imports (tier purity)", {
timeout: 5000,
}, () => {
const source = readFileSync(`${HERE}topo-sort.ts`, "utf8");
expect(source).not.toMatch(/from ['"]node:/);
expect(source).not.toMatch(/from ['"]bun:/);
expect(source).not.toMatch(
/from ['"](fs|path|crypto|stream|net|http)['"]/
);
});
it("subpath export resolves to topo-sort module", {
timeout: 5000,
}, () => {
// The subpath `@suaveplan/seed/utils/topo-sort/topo-sort` is
// declared in package.json `exports`. We verify the declaration
// points at the dedicated module rather than the root barrel.
const pkg = JSON.parse(
readFileSync(`${HERE}../../../package.json`, "utf8")
) as {
exports: Record<string, { import: { default: string } }>;
};
const subpath = pkg.exports["./utils/topo-sort/topo-sort"];
expect(subpath).toBeDefined();
expect(subpath.import.default).toBe(
"./dist/utils/topo-sort/topo-sort.js"
);
// Sanity: not the root barrel.
expect(subpath.import.default).not.toBe("./dist/index.js");
});
});

View File

@@ -0,0 +1,160 @@
import { MAX_DEPENDENCY_DEPTH } from "../../constants/index.js";
import { SEED_CODES, SeedError } from "../../error-codes/error-codes.js";
/** Minimal structural shape `topoSort` consumes. */
export interface TopoSortSeed {
readonly name: string;
readonly dependencies?: readonly string[];
readonly priority?: number;
}
/** Minimal structural shape `detectCycles` consumes. */
export interface DetectCyclesSeed {
readonly name: string;
readonly dependencies?: readonly string[];
}
/** Hidden options bag for `topoSort`. Test-only; consumers ignore. */
export interface TopoSortOptions {
/**
* Override the iteration guard. Defaults to
* `MAX_DEPENDENCY_DEPTH * seeds.length`. Exposed so the defensive
* `DEPENDENCY_DEPTH_EXCEEDED` branch is reachable from tests; with a
* correct Kahn's pass the default ceiling is unreachable from any
* valid input.
*/
readonly maxIterations?: number;
}
/**
* Topologically sort seed definitions so every dependency precedes its
* dependent. Implements one clean Kahn's-algorithm pass with priority
* tiebreak (lower priority = earlier; default 0).
*
* Throws `SeedError`:
* - `SEED.UNREGISTERED_DEPENDENCY` when a dependency name is not in the
* input list.
* - `SEED.CIRCULAR_DEPENDENCY` when the graph contains a cycle.
* - `SEED.DEPENDENCY_DEPTH_EXCEEDED` defensive guard when the queue
* processes more nodes than `MAX_DEPENDENCY_DEPTH * seeds.length`.
*/
export function topoSort(
seeds: readonly TopoSortSeed[],
options: TopoSortOptions = {}
): string[] {
const graph = new Map<string, readonly string[]>();
const inDegree = new Map<string, number>();
const priority = new Map<string, number>();
for (const seed of seeds) {
graph.set(seed.name, seed.dependencies ?? []);
inDegree.set(seed.name, 0);
priority.set(seed.name, seed.priority ?? 0);
}
for (const [name, deps] of graph) {
for (const dep of deps) {
if (!graph.has(dep)) {
throw new SeedError(
SEED_CODES.UNREGISTERED_DEPENDENCY,
`Seed "${dep}" is listed as a dependency but not registered`,
{ seedName: name, dependency: dep }
);
}
inDegree.set(name, (inDegree.get(name) as number) + 1);
}
}
const byPriority = (a: string, b: string): number =>
(priority.get(a) as number) - (priority.get(b) as number);
const queue: string[] = [];
for (const [name, degree] of inDegree) {
if (degree === 0) {
queue.push(name);
}
}
queue.sort(byPriority);
const result: string[] = [];
const guard = options.maxIterations ?? MAX_DEPENDENCY_DEPTH * seeds.length;
let iter = 0;
while (queue.length > 0) {
if (iter++ > guard) {
throw new SeedError(
SEED_CODES.DEPENDENCY_DEPTH_EXCEEDED,
`Dependency resolution exceeded ${guard} iterations`
);
}
// queue.length > 0 verified above; shift is safe.
const current = queue.shift() as string;
result.push(current);
for (const [name, deps] of graph) {
if (deps.includes(current)) {
const newDegree = (inDegree.get(name) as number) - 1;
inDegree.set(name, newDegree);
if (newDegree === 0) {
queue.push(name);
queue.sort(byPriority);
}
}
}
}
if (result.length !== graph.size) {
const remaining = [...graph.keys()].filter((n) => !result.includes(n));
throw new SeedError(
SEED_CODES.CIRCULAR_DEPENDENCY,
`Circular dependency detected involving: ${remaining.join(", ")}`
);
}
return result;
}
/**
* Detect cycles in the seed dependency graph using DFS. Returns an array
* of trace strings like `"a -> b -> a"`. Never throws; an acyclic graph
* returns `[]`. Dependencies that name nodes outside the graph are
* skipped (no false-positive cycles).
*/
export function detectCycles(seeds: readonly DetectCyclesSeed[]): string[] {
const graph = new Map<string, readonly string[]>();
for (const seed of seeds) {
graph.set(seed.name, seed.dependencies ?? []);
}
const visited = new Set<string>();
const stack = new Set<string>();
const cycles: string[] = [];
const dfs = (node: string, path: readonly string[]): void => {
if (stack.has(node)) {
const cycleStart = path.indexOf(node);
const trace = [...path.slice(cycleStart), node].join(" -> ");
cycles.push(trace);
return;
}
if (visited.has(node)) {
return;
}
visited.add(node);
stack.add(node);
const deps = graph.get(node) as readonly string[];
for (const dep of deps) {
if (graph.has(dep)) {
dfs(dep, [...path, node]);
}
}
stack.delete(node);
};
for (const name of graph.keys()) {
if (!visited.has(name)) {
dfs(name, []);
}
}
return cycles;
}

View File

@@ -0,0 +1,5 @@
// Polyfills for test environment — none required. The package is universal
// and uses only `performance.now()`, which is available in every supported runtime
// (Node 18+, Bun, browsers, Web Workers). This file exists to mirror the sibling
// package layout and to give vitest a stable setup file path.
export { };

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": ".",
"outDir": "./dist",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts", "tests/**/*.ts"],
"exclude": ["dist", "node_modules"]
}

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
}

View File

@@ -0,0 +1,30 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../node_modules/.vite/packages/seed",
plugins: [
dts({
entryRoot: "src",
tsconfigPath: "./tsconfig.lib.json",
insertTypesEntry: true,
}),
],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
sourcemap: true,
lib: { entry: "src/index.ts", formats: ["es"] },
rollupOptions: {
treeshake: true,
output: {
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
external: ["@suaveplan/error", "@suaveplan/types", "tslib"],
},
},
});

View File

@@ -0,0 +1,27 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: false,
environment: "node",
pool: "forks",
setupFiles: ["./tests/setup/polyfills.ts"],
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
exclude: ["dist/**", "node_modules/**"],
coverage: {
provider: "istanbul",
reporter: ["text", "html", "lcov"],
include: ["src/**/*.ts"],
exclude: [
"src/**/*.test.ts",
"src/**/*.types.ts",
"src/**/index.ts",
"src/**/*.bench.ts",
"src/__benchmarks__/**",
"tests/**",
],
thresholds: { statements: 100, branches: 100, functions: 100, lines: 100 },
},
testTimeout: 30000,
},
});