Initial Commit
This commit is contained in:
189
packages/persistence/database/seed/README.md
Normal file
189
packages/persistence/database/seed/README.md
Normal 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.
|
||||
|
||||
141
packages/persistence/database/seed/package.json
Normal file
141
packages/persistence/database/seed/package.json
Normal 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"
|
||||
]
|
||||
}
|
||||
92
packages/persistence/database/seed/src/builder/builder.md
Normal file
92
packages/persistence/database/seed/src/builder/builder.md
Normal 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.
|
||||
190
packages/persistence/database/seed/src/builder/builder.test.ts
Normal file
190
packages/persistence/database/seed/src/builder/builder.test.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
119
packages/persistence/database/seed/src/builder/builder.ts
Normal file
119
packages/persistence/database/seed/src/builder/builder.ts
Normal 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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
2
packages/persistence/database/seed/src/builder/index.ts
Normal file
2
packages/persistence/database/seed/src/builder/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { defineSeed, SeedBuilder } from "./builder.js";
|
||||
export type { ISeedBuilder } from "./builder.types.js";
|
||||
22
packages/persistence/database/seed/src/constants/index.ts
Normal file
22
packages/persistence/database/seed/src/constants/index.ts
Normal 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;
|
||||
@@ -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`.
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export type {
|
||||
SeedErrorCode,
|
||||
SeedErrorOptions,
|
||||
} from "./error-codes.js";
|
||||
export {
|
||||
SEED_CODES,
|
||||
SeedError,
|
||||
} from "./error-codes.js";
|
||||
142
packages/persistence/database/seed/src/fixtures/fixtures.md
Normal file
142
packages/persistence/database/seed/src/fixtures/fixtures.md
Normal 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.
|
||||
290
packages/persistence/database/seed/src/fixtures/fixtures.test.ts
Normal file
290
packages/persistence/database/seed/src/fixtures/fixtures.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
141
packages/persistence/database/seed/src/fixtures/fixtures.ts
Normal file
141
packages/persistence/database/seed/src/fixtures/fixtures.ts
Normal 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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
6
packages/persistence/database/seed/src/fixtures/index.ts
Normal file
6
packages/persistence/database/seed/src/fixtures/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
createFixtureFactory,
|
||||
createInMemoryStore,
|
||||
FixtureFactory,
|
||||
InMemorySeedStore,
|
||||
} from "./fixtures.js";
|
||||
47
packages/persistence/database/seed/src/index.ts
Normal file
47
packages/persistence/database/seed/src/index.ts
Normal 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";
|
||||
2
packages/persistence/database/seed/src/registry/index.ts
Normal file
2
packages/persistence/database/seed/src/registry/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { SeedRegistry } from "./registry.js";
|
||||
export type { ISeedRegistry } from "./registry.types.js";
|
||||
113
packages/persistence/database/seed/src/registry/registry.md
Normal file
113
packages/persistence/database/seed/src/registry/registry.md
Normal 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.
|
||||
151
packages/persistence/database/seed/src/registry/registry.test.ts
Normal file
151
packages/persistence/database/seed/src/registry/registry.test.ts
Normal 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"]);
|
||||
});
|
||||
});
|
||||
67
packages/persistence/database/seed/src/registry/registry.ts
Normal file
67
packages/persistence/database/seed/src/registry/registry.ts
Normal 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()];
|
||||
}
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
123
packages/persistence/database/seed/src/runner/execute.md
Normal file
123
packages/persistence/database/seed/src/runner/execute.md
Normal 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`.
|
||||
185
packages/persistence/database/seed/src/runner/execute.ts
Normal file
185
packages/persistence/database/seed/src/runner/execute.ts
Normal 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,
|
||||
}
|
||||
);
|
||||
}
|
||||
2
packages/persistence/database/seed/src/runner/index.ts
Normal file
2
packages/persistence/database/seed/src/runner/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { SeedRunner } from "./runner.js";
|
||||
export type { ISeedRunner } from "./runner.types.js";
|
||||
215
packages/persistence/database/seed/src/runner/runner.md
Normal file
215
packages/persistence/database/seed/src/runner/runner.md
Normal 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.
|
||||
641
packages/persistence/database/seed/src/runner/runner.test.ts
Normal file
641
packages/persistence/database/seed/src/runner/runner.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
203
packages/persistence/database/seed/src/runner/runner.ts
Normal file
203
packages/persistence/database/seed/src/runner/runner.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
151
packages/persistence/database/seed/src/types/common.md
Normal file
151
packages/persistence/database/seed/src/types/common.md
Normal 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.
|
||||
132
packages/persistence/database/seed/src/types/common.ts
Normal file
132
packages/persistence/database/seed/src/types/common.ts
Normal 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;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export type {
|
||||
DetectCyclesSeed,
|
||||
TopoSortOptions,
|
||||
TopoSortSeed,
|
||||
} from "./topo-sort.js";
|
||||
export { detectCycles, topoSort } from "./topo-sort.js";
|
||||
@@ -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.
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 { };
|
||||
12
packages/persistence/database/seed/tsconfig.json
Normal file
12
packages/persistence/database/seed/tsconfig.json
Normal 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"]
|
||||
}
|
||||
12
packages/persistence/database/seed/tsconfig.lib.json
Normal file
12
packages/persistence/database/seed/tsconfig.lib.json
Normal 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"]
|
||||
}
|
||||
30
packages/persistence/database/seed/vite.config.ts
Normal file
30
packages/persistence/database/seed/vite.config.ts
Normal 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"],
|
||||
},
|
||||
},
|
||||
});
|
||||
27
packages/persistence/database/seed/vitest.config.ts
Normal file
27
packages/persistence/database/seed/vitest.config.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user