10 KiB
ETL completion
Package: @suaveplan/etl
Status: PROPOSED
Type: spec-conformance + feature
Why
The genesis port of @suaveplan/etl ships the core stage primitives (arraySource, mapTransform, BatchSink, etc.) and a Pipeline builder, but the surface around it is materially thinner than the upstream package at ../suaveplan/packages/universal/etl. Five concrete gaps make consumers either rebuild missing pieces in-app or abandon the package altogether:
- No
PipelineContext— upstream exposes a mutable execution context (data, errors, stepResults, hooks, metadata K/V) that travels through every step. Genesis stages get isolatedTransformContextsnapshots, so cross-step state requires bolting on a separate map. - No batch concurrency / timeout config — upstream's
BatchConfigshipssize,concurrency, and (via pipeline) per-batch timeouts. Genesis only honourssizeviaBatchSink, so consumers can't opt into parallel batches or per-item timeout enforcement. - No
EtlHookMaplifecycle bus — upstream emitspipeline:start,pipeline:end,step:start,step:end,step:error,batch:start,batch:end,batch:errorthrough a typed hook map. Genesis has no observability surface beyond raw error returns. - Missing utility primitives —
applyMappings,getNestedValue,setNestedValue,parallelMap,toBatches,withTimeoutare all absent. These are the shared building blocks every non-trivial ETL pipeline needs and they belong in the package, not the call site. - Missing
FieldMappingtype — upstream shipsFieldMapping({ from, to, transform? }) plusapplyMappings(record, mappings). Genesis has no canonical mapping shape, so each consumer hand-rolls one.
This change closes the five gaps in one coordinated pass so downstream services (@suaveplan/packer import pipelines, future analytics ETLs) can rely on a single coherent ETL surface.
What changes
- ADDED:
core/context/contextmodule — exportsclass PipelineContextandfunction createContext(options: PipelineContextOptions)returning a typed shared-state object withdata,errors,stepResults,metadatamap, hook registration, and an injectableLoggerLike/Clock/AbortSignal. The context threads through every step. - ADDED:
BatchConfiginterface —{ size: number; concurrency?: number; perItemTimeoutMs?: number; batchTimeoutMs?: number; continueOnError?: boolean }. Pipeline config growsbatch?: BatchConfig. The pipeline runner SHALL honour all four fields. - ADDED:
types/etl-hooksmodule — exportsEtlHookMapextendingHookMapfrom@suaveplan/events::hooks.types, with keys"pipeline:started","pipeline:completed","stage:started","stage:completed","stage:failed","batch:started","batch:completed","batch:failed". PlusEtlTransformHookDataandEtlTransformingHandleraliases for the most common subscriber shape. - ADDED:
utils/utilsmodule — exportsapplyMappings(record, mappings),getNestedValue(obj, path),setNestedValue(obj, path, value),parallelMap(items, fn, concurrency),toBatches(items, size),withTimeout(promise, ms). - ADDED:
FieldMappingtype —{ from: string; to: string; transform?: (value: unknown) => unknown }.mapTransformand anyTransformConfigSHALL acceptmappings?: readonly FieldMapping[]applied before the transform function. - MODIFIED:
PipelineandRunnablePipeline— accept and emitEtlHookMapevents through an injectableHookBus(defaults to a fresh internal bus when omitted). ExistingerrorHandlerAPI preserved. - ADDED: per-module sibling
.mdfor every new file.
Out of scope
- Connectors for specific databases (e.g. SurrealDB source, Postgres sink, S3 source) — separate adapter packages so each can pull in its native client and integration test fixture independently. This proposal only stabilises the runner contract.
- CDC stream support — change-data-capture / event-source ingestion belongs to a streaming-etl change paired with
@suaveplan/eventsinterceptors; this proposal stays batch-pipeline-only. - Schema inference / auto-mapping — synthesising
FieldMapping[]from a sample record is a separate research item. - Web Streams runner — the existing pipeline is in-memory and uses async iterators; a
ReadableStreamrunner is its own change. - Distributed pipeline orchestration — multi-worker pipelines (queue-backed work items) require an
@suaveplan/idempotencyintegration that isn't in scope here.
Source mapping
This change derives from the upstream package at ../suaveplan/packages/universal/etl/src and the upstream enterprise audit at ../suaveplan/enterprise/etl.md.
| Requirement | Source file | Source lines | Notes |
|---|---|---|---|
PipelineContext class with mutable shared state |
packages/universal/etl/src/core/context/context.ts |
L1-L120 | Verbatim port; thread through Clock / LoggerLike from @suaveplan/types per genesis convention |
createContext(options) factory |
packages/universal/etl/src/core/context/context.ts |
(export at file end) | Verbatim |
BatchConfig interface (size, concurrency, continueOnError) |
packages/universal/etl/src/types/etl.types.ts |
L98-L106 | Verbatim port; extend with perItemTimeoutMs and batchTimeoutMs (genesis additions, see "no source line" below) |
EtlHookMap typed event bus |
packages/universal/etl/src/types/etl-hooks.ts |
L1-L29 | Verbatim port; expand from one upstream key (etl:transforming) to the eight lifecycle keys upstream emits via PipelineEventPayload |
EtlTransformHookData / EtlTransformingHandler |
packages/universal/etl/src/types/etl-hooks.ts |
L9-L29 | Verbatim |
applyMappings(record, mappings) utility |
packages/universal/etl/src/utils/utils.ts |
L48-L80 | Verbatim |
getNestedValue(obj, path) utility |
packages/universal/etl/src/utils/utils.ts |
L7-L23 | Verbatim |
setNestedValue(obj, path, value) utility |
packages/universal/etl/src/utils/utils.ts |
L29-L47 | Verbatim |
parallelMap(items, fn, concurrency) utility |
packages/universal/etl/src/utils/utils.ts |
L82-L120 | Verbatim |
toBatches(items, size) utility |
packages/universal/etl/src/utils/utils.ts |
L122-L140 | Verbatim |
withTimeout(promise, ms) utility |
packages/universal/etl/src/utils/utils.ts |
L142-L151 | Verbatim |
FieldMapping type |
packages/universal/etl/src/types/etl.types.ts |
L64-L72 | Verbatim |
pipeline:started / pipeline:completed / stage:* / batch:* lifecycle events |
packages/universal/etl/src/types/etl.types.ts |
L106-L130 | Renamed from upstream pipeline:start / step:start etc. to lifecycle-named (started/completed/failed) per genesis convention; payload shapes preserved |
Requirements with no source line
| Requirement | Justification |
|---|---|
BatchConfig.perItemTimeoutMs and batchTimeoutMs fields |
Upstream models pipeline-wide timeoutMs only. Genesis enforces granular timeouts because @suaveplan/retry::withTimeout is already in the dep graph and per-item enforcement is a common production requirement (slow rows shouldn't stall the whole batch). |
Hook keys renamed to lifecycle-named pattern (*:started / *:completed / *:failed) |
Genesis-wide naming convention applied across @suaveplan/migration, @suaveplan/network, @suaveplan/cache (see lifecycle keys in those packages). Upstream's *:start / *:end / *:error style was itself inconsistent across packages. |
EtlHookMap built on HookMap from @suaveplan/events::hooks.types rather than upstream's bespoke listener registry |
Genesis-wide event bus convention (CLAUDE.md @suaveplan/events listing). |
ETL_CODES registered idempotently via registerEtlErrorCodes() |
Genesis-wide error registry rule (CLAUDE.md §Workflow rule 2). |
Verification approach
Each spec delta requirement carries a **Verified by:** line. The test files that will exist after this change ships:
packages/platform/etl/src/core/context/context.test.ts— set / get / delete metadata, hook register / off, errors / stepResults appended, AbortSignal propagationpackages/platform/etl/src/core/context/context.spec-conformance.test.ts— pins type-levelPipelineContextOptionsshape; injectedClockdeterministicpackages/platform/etl/src/core/pipeline/pipeline.test.ts(extended) —BatchConfighonoured:sizechunks input,concurrencyruns N batches in parallel,perItemTimeoutMsrejects slow items withEtlError(BATCH_ITEM_TIMEOUT),batchTimeoutMsrejects slow batches,continueOnErrorskips failed batchpackages/platform/etl/src/types/etl-hooks/etl-hooks.test.ts— every key inEtlHookMapfires once per lifecycle transition with the correct payloadpackages/platform/etl/src/utils/utils.test.ts—applyMappings/getNestedValue/setNestedValue/parallelMap/toBatches/withTimeouthappy-path + edge cases (deep path missing, concurrency 0, batch size 0, timeout 0)packages/platform/etl/src/utils/utils.spec-conformance.test.ts—parallelMaphonours concurrency limit empirically;withTimeoutrejects withEtlError(TIMEOUT)packages/platform/etl/src/types/etl-hooks/etl-hooks.spec-conformance.test.ts— pins lifecycle key strings viaexpectTypeOf; pins payload shapespackages/platform/etl/src/errors/etl-error.spec-conformance.test.ts—ETL_CODESregistered after import;registerEtlErrorCodesidempotent
Impact
Affected dependents
Run bunx turbo ls --filter=...@suaveplan/etl to enumerate. Today grep -rl "@suaveplan/etl" packages/ --include="package.json" returns zero matches outside packages/platform/etl/package.json itself, so this change is purely additive.
@suaveplan/etl(self) — [x] tests run / [x] docs reviewed / [ ] no action needed- (no in-repo dependents) — verified by
bunx turbo ls --filter=...@suaveplan/etlreturning a single package.
If tests run is unchecked for any dependent, the change MUST NOT be folded into the canonical spec — finish that work first or split the proposal.
Migration impact
Pipelineconsumers — no break. Existing constructors continue to work;PipelineContext,BatchConfig, and the hook bus are all opt-in additive.mapTransformconsumers — no break. Newmappings?: readonly FieldMapping[]argument is optional.PipelineOptionsconsumers — no break. Newbatch,hooks, andcontextfields are optional and default to no-op behavior.SKIPconsumers — no break. TheSKIPsymbol export is preserved.