createAbstraction<T>(name)→Abstraction<T>(has.token: symbol).Abstraction.createImplementation({ implementation, dependencies })→ an Implementation class (I & { __abstraction: A }). The Implementation class is NOT an Abstraction at runtime — it has no.token.container.resolve(ImplClass)would fail, butcontainer.register(ImplClass)works (reads abstraction viaMetadata).PipelineBuilderFactory.create({ name, scanner, processors })accepts Implementation classes (not just abstractions). The type system infersTRecord/TContext/TShardfrom the Impl class instance types. At runtime the factory has allProcessorandScannerinstances injected via[Processor, { multiple: true }]/[Scanner, { multiple: true }]dependencies, then finds each right instance byx.constructor === implClass— pipelines carry resolved scanner and processor instances, not tokens. All processors shareSymbol("Core/Processor"); all scanners shareSymbol("Core/Scanner"); constructor identity is the discriminator, not registration order.runner.register(...pipelines: Pipeline<any, any, any>[])widens the parameter type intentionally — TRecord is invariant inPipeline(becauseFilter<TRecord>is contravariant), so a strict signature would force every caller to cast. The runner doesn't introspect type params at this boundary.
For the full @webiny/di guide, see webiny-di-guide.md.
Every feature follows:
src/features/FeatureName/
├── abstractions/
│ ├── FeatureName.ts # Interface + abstraction token + namespace
│ └── index.ts # Only const tokens (no type exports)
├── FeatureName.ts # Class + createImplementation
├── feature.ts # createFeature registers into container
└── index.ts # Public API
Rules that are NOT negotiable:
- Types accessed only via namespace (
FeatureName.Interface), never direct interface exports from abstractions. public/private/protectedon every class member.- Braces always — no single-line
if/for/while. - No
reflect-metadataimports (loaded by@webiny/diinternally). ~/*path alias insrc/; relative paths in__tests__/for test-only infra that lives outsidesrc/.- Named
interface/typefor any structural shape — no inline{ ... }in generic positions. - File names use camelCase (not kebab-case).
- oxfmt (
yarn format:fix) — NOT prettier. yarn— nevernpm.
- Merge group = set of pipelines sharing the same scanner instance (keyed by object identity). Runner iterates one merge group at a time.
- First-match-wins per record: within a merge group, the first pipeline whose filters all pass is the one that runs. Subsequent pipelines skip that record.
- Unmatched records are dropped per design: if NO pipeline in the merge group accepts a record, it's skipped. Each unmatched record emits a
warn-level log line:"unmatched record — TYPE=<type> PK=<pk> SK=<sk>". The shard summary shows a TYPE breakdown:"unmatched 5 (page.page=3, cms.entry=2)"; when TYPE is absent or empty, the key isPK:SKinstead ofunknown. After the run,segment-N-unmatched.logunder.transfer/<runId>/lists every unmatched record (one per line:[TYPE] PK : SK). If users want exhaustive transfer, register a catch-all passthrough pipeline last. - Filter order matters: register more-specific pipelines before catch-alls.
- Blackhole pipelines (
builder.blackhole()): filters + transformers +onEndrun exactly as usual; the fold-to-shard step inrunRecordis what's skipped, so every emitted command is dropped before any processor drains. Use for observe-only pipelines (validation, dry-run-one-pipeline, shadow runs). Snapshot still records source/post-transform/commands — diffing a blackholed pipeline's intended writes is the whole point.PipelineConfig.blackholeis?: boolean;Pipeline.isBlackholegetter normalizes missing →false. - Per-record
onEndhooks replace magic auto-put: after filters + transformers run, the runner invokes each processor'sonEnd?(ctx)sequentially in array order.DdbProcessor.onEndandOsProcessor.onEndcallctx.putRecord(ctx.record)via their slice helpers — so pipelines containing either get the "auto-put" behavior by virtue of the processor.S3Processorhas noonEnd(no derivable per-record default). Pipelines with zero transformers still produce writes as long as a writer processor is in the list. Seesrc/features/PipelineRunner/PipelineRunner.ts:runRecord. - Hooks: per merge group. Before-hooks run (dedup'd by token, in registration order) before any shards. After-hooks run (dedup'd, in REVERSE order) after all shards succeed. After-hooks are SKIPPED on shard failure. Each hook gets
{ runId, mergeGroupId }.
BaseTransformContext.Interface<TRecord> (slim — target-agnostic) exposes:
record: TRecord— mutable, transformers change this.original: Readonly<TRecord>— frozen snapshot of the pre-transform record, always present. Users may consume it for gate-checks, audits, etc. — do NOT remove even if no built-in code uses it.addCommand(cmd: Command)— push a command to the (internal) bag. Canonical primitive; slice helpers are sugar over it.modelProvider,cache,logger,compressionHandler— shared singletons. Usectx.loggerinstead ofconsole.*inside transformers — it's bound to the current worker and respects the configured log level.compressionHandleris used by rich-text and compressed-field transformers.replace(newRecord)— replacesctx.record.blackhole()— marks this record for per-record blackholing. Remaining transformers andonEndhooks still run (side effects preserved), but all commands are discarded at the fold step. Irreversible within the record lifecycle. The runner checksctx.isBlackholedafter transformers +onEnd:if (pipeline.isBlackhole || ctx.isBlackholed) { return Blackholed; }.readonly isBlackholed: boolean— read-only flag set byblackhole(). Defaults tofalse; eachcreate()call produces an independent closure.
Raw commands bag is NOT on the public ctx — addCommand is the only public push path. The bag still exists internally for Processor.execute(commands) at shard end. Commands.unclaimedKeys() tracks keys whose commands nobody drained, used by the runner to warn-once.
Each processor contributes a SLICE of helpers via its extendContext(base) method. The runner spreads all processor slices over the base ctx per-record. Effective ctx = BaseTransformContext ∧ MergeSlices<TProcessors>. Slice key collision → TS rejects at the pipelineBuilderFactory.create({...}) call site via DisjointKeys<...>.
Slice inventory:
DdbProcessorslice:putRecord(record),querySourceRecord<T>(pk, sk?),queryTargetRecord<T>(pk, sk?)→ emits PutRecord targeting DDB primary; source/target table lookups.OsProcessorslice:putRecord(record),querySourceRecord<T>(pk, sk?),queryTargetRecord<T>(pk, sk?)→ emits PutRecord targeting OS DDB table (same slice keys as DdbProcessor → mutually exclusive in one pipeline).S3Processorslice:copyFile(src, tgt),getFile(key)→ PushQueue S3Copy / sync read from source bucket.
Type aliases DdbTransformContext (= Base ∧ DdbProcessorSlice ∧ S3ProcessorSlice) and OsTransformContext (= Base ∧ OsProcessorSlice) are exported for transformer authors who want typed ctx parameters.
Removed from context (do not reintroduce): executePipeline(pipeline, records) — nested-pipeline helper, dropped 2026-04-19.
- Scanner = source iterator. Yields records per shard.
DynamoDbClient.scan<T>is generic so scanners can narrow the raw row type. - Processor = per-command-type unit implementing
Processor.Interface<TBase, TSlice>. Required:checkAccess() → Promise<AccessCheck.Entry[]>(pre-flight access validation),execute(commands) → Promise<void>(drains its command keys). Optional:extendContext(base) → slice(context helpers),onEnd(ctx) → void | Promise<void>(per-record terminal hook, replaces legacy auto-put magic),afterShard({ segment, totalSegments }) → void | Promise<void>(per-shard persistence hook for processors that carry state across the worker→orchestrator boundary — only OsProcessor implements it today, to write<segment>-indexes.json). - Per-record orchestration: filters run first (
pipeline.accepts(record)in the shard loop) → runner builds base ctx → spreads each processor's slice → runs transformers → runs each processor'sonEnd?SEQUENTIALLY IN ARRAY ORDER. - Per-shard orchestration: each processor's
execute()runs SEQUENTIALLY IN ARRAY ORDER. After all processors drain, runner checksCommands.unclaimedKeys()and warns once per unmatched key ("transformer pushed X but no processor drained X"). DdbExecutoris a SHARED primitive (not a Processor) —batchPutagainst a target DDB table. BothDdbProcessor.executeandOsProcessor.executecompose it. OS adds gzip + ensureIndex preamble before delegating.- "Record carries everything" is a house invariant — do NOT add pre-transform snapshot queues, metadata side-channels, or "executor derives X" logic. If transformers destroyed something a processor needs, users write a transformer that preps it.
Optional tuning section on MigrationConfig:
tuning?: {
flushEvery?: number; // records per shard flush (default 500); bounds peak memory
ddb?: { maxRetries?: number; initialBackoffMs?: number; requestTimeoutMs?: number };
s3?: { concurrency?: number; maxRetries?: number; initialBackoffMs?: number; requestTimeoutMs?: number };
os?: { maxRetries?: number; retryScheduleMs?: number[]; gzipConcurrency?: number };
}Debug options on MigrationConfig:
debug?: {
logLevel?: "debug" | "info" | "warn" | "error"; // default "info"; also overridable via --log-level CLI flag
logFile?: boolean | string; // true → per-process JSONL; string → shared path
snapshot?: boolean | { dir?: string; compress?: boolean };
}Fields flow to the respective client/executor; absent = module-level defaults. BATCH_SIZE = 25 in DDB is AWS-enforced, NOT a user knob.
flushEvery caps peak per-shard memory: at default 500 × 10 KB avg = ~5 MB/shard. For tables with very large records (approaching the 400 KB DDB max) lower this to 100. Set via tuning: { flushEvery: numberFromEnv("FLUSH_EVERY", 500) } in the config.
All AWS-facing code shares one classifier: src/base/isRetryableAwsError.ts (duck-typed, no SDK import). Retry path per client:
- DDB + S3: AWS SDK clients are created with
retryMode: "adaptive"(self-tuning token bucket inside the SDK). The outerexecuteWithRetryloop inDynamoDbClientImpl/S3ClientImpluses the classifier to gate retries: non-retryable errors throw immediately; retryable errors retry up totuning.{ddb,s3}.maxRetrieswith exponential backoff. Loop bounds:attempt <= maxRetries⇒ 1 initial + N retries. - OpenSearch:
opensearch-jsClientreceivesmaxRetriesfromtuning.os.maxRetries(default 3).OsProcessor.withRetryis classifier-gated;ensureIndexfails the transfer on retry-exhaustion (no silent continuation).
No custom token-bucket pacing — the AWS SDK's adaptive mode handles remote-signal-based backoff. See project_rate_limits_todo.md memory for the design history.