Imported from pattern-stack/codegen-patterns (
.claude/skills/integration/SKILL.md). Install upstream withnpx skills add pattern-stack/codegen-patterns --skill integration. Copyright stays with the author.
Integration Domain Skill
The integration subsystem is the generic external-system integration engine.
One orchestrator (ExecuteIntegrationUseCase<T>) runs every integration in
the codebase. Per-provider code implements a single port —
IChangeSource<T> — and per-entity code implements a single write
surface — IIntegrationSink<T>. Everything else (cursor persistence, diffing,
per-record audit, run lifecycle) is provided by the subsystem.
This skill covers the Phase 1 runtime (SYNC-1..SYNC-8, epic #60). Phase 2
PollChangeSource emission shipped (provider-keyed): the entity-YAML
detection: block is schema-validated against the canonical
DetectionConfigSchema (ADR-033, #226-6) and emits one
<entity>-integration-source.module.ts per entity (ADR-033.1 c, #251). The
generated module exposes <ENTITY>_POLL_FETCH_REGISTRY (consumer fills)
and <ENTITY>_CHANGE_SOURCES: ReadonlyMap<string, IChangeSource<T>>
(factory output via buildChangeSource). Webhook-side codegen + CDC
streaming emission are deferred.
Track D (provider modules + adapter scaffolds, RFC-0001) shipped in 0.12.0.
It has no command of its own — it runs as a post-step of codegen entity new whenever definitions/providers/*.yaml exist. See
protocols-and-ports.md → "Driving Track D codegen" for the invocation,
output paths, and skip conditions before telling anyone the CLI wiring is
missing.
0.13.0 extends Track D to emit the full integration layer, not just the read side:
- Module assembly (RFC-0002). Per
(surface, provider, entity)codegen now emits the per-entity<entity>-integration.module.ts(bindsINTEGRATION_CHANGE_SOURCE=adapter.changeSources['<entity>']+INTEGRATION_SINK, provides a localExecuteIntegrationUseCase, exports it under a unique<ENTITY>_INTEGRATION_USE_CASE__<PROVIDER>token), an default sink as a two-file seam (Shape C, #491, 2026-06-06):<entity>.sink.generated.ts(@generated, regenerated viawriteIfChanged— two standalone default functions at concrete types +abstract class <Entity>SinkBase<TCanonical>) and<entity>.sink.ts(emit-once subclass —class <Entity>Sink extends <Entity>SinkBasewith two one-line wirings).pattern: Integratedonly — hard-errors otherwise. A surface integration aggregator and a tokens file are also emitted. This is the generated form of swe-brain's hand-rolled*_integrationfeature modules. - Read primitive (RFC-0003). For interaction surfaces (mail/calendar/
transcript) the adapter's
changeSourcesentries are emitted as emit-onceIncrementalReadBase<Canonical<Entity>, ResolvedFilter[]>subclasses — the enumerate/hydrate read-body scaffold. The base owns streaming, filter-before-hydrate, bounded-concurrency hydration, and per-ref cursor emission; the author fills onlyenumerate/hydrate/toCanonical. - Port shape (post-E0). The surface ports declare
readonly changeSources: Record<string, IChangeSource<unknown>>(what the adapter contributes), NOT the oldreadonly sources: IEntityChangeSourceRegistry. The folded, entity-keyed<SURFACE>_ENTITY_SOURCESregistry is the surface aggregator's output (surface-module concern), no longer injected into the adapter.
Mental model
Integration vs. jobs vs. events — three domains, one codebase:
| System | Purpose | Unit |
|---|---|---|
| Events | Immutable facts about what happened | domain_events rows |
| Jobs | Stateful retryable work | job_run rows with status/retry |
| Integration | Detect upstream change → diff → apply → record | integration_runs + integration_run_items pairs |
Integration can trigger events (on successful upsert) and can be triggered by jobs (scheduled polling) or webhooks — but the three are distinct subsystems with different lifecycles. Don't collapse them.
Five-step dance — the invariant every integration repeats:
- detect upstream change
- diff against local state
- apply (upsert or soft-delete)
- record delta
- emit event (consumer-owned wiring)
Steps 2–5 are machinery the subsystem owns. Step 1 is the IChangeSource<T>
port — per-provider, per-entity, per-detection-mode. Three detection
modes (poll / CDC / webhook) converge on the same port; per-mode
differences live in Change<T> metadata (source, dedupKey,
providerChangedFields), not in separate ports. That was a deliberate
compromise rejecting an IPollSource / ICdcSource / IWebhookSource
split — see epic #60's design notes.
Audit model — structured, not freeform:
integration_run_items.changed_fields is { fieldName: { from: unknown, to: unknown } }
jsonb. Enforced at write time by FieldDiffSchema.parse at the recorder
boundary (ADR-0003). This lets drift-detection queries work as one-shot
SQL filters instead of payload-JSON scrapes. Every write path —
Drizzle + Memory — validates identically.
Two enforcement points for multi-tenancy:
When INTEGRATION_MULTI_TENANT=true, the orchestrator throws
MissingTenantIdError at execute() entry BEFORE opening a
integration_runs row (no dangling status=running rows), AND the Drizzle
backends independently re-validate at their write boundary. Both sites
use the shared assertTenantId helper so error messages match. The
memory backends accept tenantId and record it but do not throw —
memory state is process-local; cross-tenant isolation there isn't
meaningful.
Task → L1 routing
| When the task involves… | Read |
|---|---|
Designing a new IChangeSource<T> (signature (subscription, cursor) => AsyncIterable<Change<T>> per ADR-033) / IIntegrationSink<T> / custom IFieldDiffer<T> |
protocols-and-ports.md |
| The orchestrator's run lifecycle, cursor advance, per-item failure | orchestrator-flow.md |
integration_runs / integration_run_items / integration_subscriptions shape, changed_fields (ADR-0003), worked queries |
audit-model.md |
| Writing a feature module, migrating from bespoke integration, multi-tenancy wiring | consumer-patterns.md |
Non-obvious rules (read twice)
-
One port for three modes. Poll, CDC, and webhook adapters ALL implement
IChangeSource<T>—listChanges(subscription, cursor): AsyncIterable<Change<T>>(#226-2 / ADR-033). Per-mode concerns (CDC replay_id, webhook event_id, provider-hinted changed fields) live inChange<T>metadata:source,dedupKey,providerChangedFields. Don't introduce mode-specific ports — we rejected that design explicitly. "CDC" here means cursor-based event endpoints (Stripe-styleevents?starting_after=...) — those map toPollChangeSource<T>withpoll.provenance: 'cdc'(#226-4); the primitive stampsChange<T>.source = 'cdc'and readsdedupKeyfrompoll.cursor.field. Long-lived stream subscriptions (SFDC Pub-Sub gRPC, Debezium/Kafka, Postgres logical replication) are a separate primitive deferred to #226-8 — they need a different substrate (subscribe(onChange, onError), server-paced backpressure, ack-on-yield) and shouldn't be retrofitted here. -
Cursors are opaque at the port seam, and the orchestrator owns the lifecycle.
ICursorStore.get/puttakesunknown. Each strategy types its cursor internally (poll:{ systemModstamp }, CDC:{ replayId }, webhook:{ ts }). The orchestrator is the only reader ofICursorStore— itgets the cursor before the run, passes it by value as the second argument toIChangeSource.listChanges(subscription, cursor)(#226-2 / ADR-033), advanceslatestCursor = change.cursoras the iterator yields, andputs on success. Primitives never injectICursorStore; that would create two readers of the same row. The orchestrator doesn't interpret the cursor shape; it just persists what the iterator last yielded. -
All-failed runs still advance the cursor. If every record in a run fails,
status='failed'is recorded but the cursor still persists as last-yielded. Rationale: the source kept yielding; re-running would not re-deliver those records. Retry semantics (dead-letter replay,action: 'manual') are caller-owned. Document this in consumer runbooks — it's the most common "wait, what?" moment during first-run adoption. -
Created-record diffs include every non-null user field. The default
DeepEqualDifferignores only row metadata (id,createdAt,updatedAt,deletedAt,type,lastModifiedAt,fields,providerMetadata). Domain fields — including identifiers likeexternal_id— are legitimately part of the diff for a newly-created record. If a consumer wants to trim extras, augment vianew DeepEqualDiffer({ ignore: [...] })in their feature module'sINTEGRATION_FIELD_DIFFERbinding. -
IntegrationModuledoes NOT provideExecuteIntegrationUseCase. Providing it there would force Nest to resolveINTEGRATION_CHANGE_SOURCE+INTEGRATION_SINKat module compile time, which fails before the feature module is imported. Consumers registerExecuteIntegrationUseCasein the sameprovidersarray as their source + sink bindings. Documented in theintegration.module.tsheader with a workedOpportunityIntegrationModuleexample. -
DeepEqualDifferis wired viauseValue: new DeepEqualDiffer(). The class constructor's optional options object is reflected as anObjectdependency by Nest's emit-decorator-metadata;useValuesidesteps that. Consumers binding a custom differ override the default via their ownINTEGRATION_FIELD_DIFFERprovider. -
completeRundoes NOT re-check tenancy whenmultiTenant=true. The run id was returned bystartRunwhich already enforced it; run ids are uuids, not guessable cross-tenant. Matches JOB-3's pattern of trusting the run id for downstream mutations. Don't add a guard there without an ADR. -
ADR-0003
FieldDiffSchemais enforced at the recorder boundary, not the column. Thechanged_fieldsjsonb column has$type<FieldDiff>annotation but the runtime gate isFieldDiffSchema.parse(input.changedFields)inrecordItem. Both Drizzle + Memory backends call parse — a memory recorder that skipped the validation would be a silently weaker contract than production. -
integration_subscriptionsis subsystem-owned, not consumer-owned.PostgresCursorStorereads/writes it directly. Consumers can still list/query it freely for admin UIs, but don't ship an entity YAML for it — that would produce redundant repositories/services shadowing the subsystem. Same stance asjob_run. -
DetectionConfigis the canonical filter / mapping shape. The per-entityDetectionConfigZod schema inruntime/subsystems/integration/detection-config.schema.tsis the single source of truth for filter, field-mapping, and cursor-strategy shape across the subsystem. Runtime primitives (PollChangeSource<T>/WebhookChangeSource<T>) parse it at construction; the codegen YAML validator imports the same schema; the per-entity factory module emitted by Phase 2 codegen consumes its parsed value. Primitives and codegen factories must derive their behavior fromDetectionConfigrather than inline literals — drift between the two sites must be a compile error, not a runtime mismatch. See ADR-033 (docs/adrs/ADR-033-config-driven-change-sources.md). -
userId/tenantIdare NOT inPollFetchContext. The poll primitive's adapter callback receives exactly{ subscription, cursor, filters }(decision memo Q5). Run-scope identity (userId,tenantId) is closed over by the consumer at adapter construction (or resolved inside the callback via consumer services) — never threaded through the port seam. Threading it forces port expansion every time run-context grows, and the orchestrator already enforces tenancy atexecute()entry. The same rule applies toWebhookChangeSource<T>when it lands in #226-4. See ADR-033 + decision memo Q5.
Do not
- Do not introduce
IPollSource,ICdcSource, orIWebhookSource. TheIChangeSource<T>union is deliberate. See epic #60 compromise analysis. - Do not treat
changed_fieldsas freeform jsonb. The{ from, to }per-field shape is load-bearing for drift-detection queries and enforced at write. Adding arbitrary keys breaks consumers. - Do not provide
ExecuteIntegrationUseCaseinIntegrationModule. It forces eager resolution of consumer-owned tokens. Feature modules register the orchestrator alongside their source + sink bindings. - Do not bypass
assertTenantIdwhen adding a new write path in a Drizzle backend. Every boundary that acceptstenantIdmust delegate to the shared helper so error messages match. - Do not ship entity YAMLs for
integration_subscriptions/integration_runs/integration_run_items— the subsystem owns the tables directly. This was explicitly resolved during SYNC-7 scaffold design (epic #60 § Phase 2 scopesexamples/integration/YAMLs for later, not now). - Do not create
*.deprecated.ts, parallel shapes, or migration shims. No backwards compat to preserve — replace cleanly. - Do not expand
IChangeSource/IIntegrationSinkprotocols without an ADR. The narrow ports are deliberate; mode-specific richness lives inChange<T>metadata or in extension methods the consumer owns.
Current runtime snapshot
Files that ship to the consumer app (not templates):
runtime/subsystems/integration/integration-change-source.protocol.ts—IChangeSource<T>,Change<T>,ChangeSourcetype,IntegrationSubscriptionViewruntime/subsystems/integration/integration-cursor-store.protocol.ts—ICursorStorewithtenantId?signature (SYNC-4)runtime/subsystems/integration/integration-field-diff.protocol.ts—IFieldDiffer<T>,DiffResult,FieldDiffSchema(Zod — ADR-0003)runtime/subsystems/integration/integration-sink.protocol.ts—IIntegrationSink<T>runtime/subsystems/integration/detection-config.schema.ts—DetectionConfigSchema(Zod): discriminated union overmode: 'poll' | 'webhook'; flat-ANDResolvedFiltertriples (eq | neq | in | nin | gt | gte | lt | lte);CursorStrategytagged union (systemModstamp | replayId | timestamp | eventId);poll.provenance: 'cdc'knob (ADR-033). TheCursorStrategyunion also carries the atomic opaque-token kindshistoryId/syncToken(RFC-0003 R2), with divisibility exposed viaCURSOR_DIVISIBILITY/isDivisibleCursor.runtime/subsystems/integration/incremental-read.ts—IncrementalRead<T, F>/RandomRead<T>/IncrementalReadBase+SourcedRecord/Ref/ReadMode/ReadRequest/mapConcurrent(RFC-0003 R1). The universal enumerate/hydrate read primitive: the base decomposes the read intoenumerate(mode, filter) → AsyncIterable<Ref>+hydrate(ids) → Map<id, raw>and owns drain, filter-before-hydrate, bounded-concurrency hydrate, per-ref cursor emission (gated bycursorDivisiblefor atomic strategies), and thelistChangesadaptation.get(RandomRead) is provided free astoCanonical ∘ hydrate([id]). Exported from@pattern-stack/codegen/subsystems.runtime/subsystems/integration/integration-middleware.protocol.ts—ChangeIterator<T>+ChangeMiddleware<T>types; the universal composition seam consumed by primitives (loopback ships here in #226-5) (ADR-033)runtime/subsystems/integration/poll-change-source.ts—PollChangeSource<T>poll-mode primitive: parameterized by a parsedDetectionConfig+PollFetchCallback<T>; owns filter resolution (flat-AND), field-mapping →externalId, middleware composition, andChange<T>.sourceprovenance ('poll'default;'cdc'opt-in viapoll.provenancefor Stripe-style event endpoints — #226-4) (#226-3 / ADR-033)runtime/subsystems/integration/webhook-change-source.ts—WebhookChangeSource<T>webhook-mode primitive: parameterized by a parsedDetectionConfig(mode: 'webhook') + a consumer-suppliedWebhookFetchCallback<T>that iterates the consumer-owned inbound staging queue, yielding{ record, eventId?, cursor? }. StampsChange<T>.source = 'webhook', derivesdedupKeywith the precedence yieldedeventId>webhook.eventIdFieldrecord extraction > undefined (eventIdFieldis optional — the yield is the right channel for vendor delivery metadata and keeps a record + its same-external_idedit distinct in one drain batch), derivesexternalIdfrom the mapping table'sexternal_idtarget (via itssourcefield), composes middleware via the lockedChangeMiddleware<T>shape. Passive iterator — does NOT drive the orchestrator. Inbound staging-table schema is consumer-owned and deferred per ADR-0002 §Phase 4. (#226-4)runtime/subsystems/integration/integration-run-recorder.protocol.ts—IIntegrationRunRecorder+StartRunInput/RecordItemInput/CompleteRunInputruntime/subsystems/integration/integration-loopback.protocol.ts— optionalILoopbackFingerprintStore<T>runtime/subsystems/integration/integration-audit.schema.ts— 3 pgTables + 5 pgEnums (scaffold-timetenant_idconditional owned by the Hygen template, not by this runtime source — matches JOB-6 / EVT-8 pattern)runtime/subsystems/integration/integration-cursor-store.drizzle-backend.ts—PostgresCursorStore;put()stampscursor+last_integration_at+updated_atin one statement so the scheduling index stays accurateruntime/subsystems/integration/integration-cursor-store.memory-backend.ts—MemoryCursorStoretest double (SYNC-3)runtime/subsystems/integration/integration-run-recorder.drizzle-backend.ts—DrizzleIntegrationRunRecorder; validateschangedFieldsviaFieldDiffSchema.parseBEFORE insertruntime/subsystems/integration/integration-run-recorder.memory-backend.ts—MemoryRunRecorderwith ergonomic helpers (getRunsForSubscription,getItemsForRun) for tests (SYNC-6)runtime/subsystems/integration/deep-equal.differ.ts— defaultDeepEqualDiffer<T>with canonical ignore list;providerChangedFieldsCDC hint; Date → ISO string + decimal-string ↔ number normalizationsruntime/subsystems/integration/execute-integration.use-case.ts— the generic orchestrator.@Optional() INTEGRATION_MULTI_TENANT. Entry-pointassertTenantIdguard. Loopback suppression is composed into theIChangeSource's middleware chain viacreateLoopbackMiddleware(#226-5 / ADR-033) — no orchestrator-side branch.runtime/subsystems/integration/loopback.middleware.ts—createLoopbackMiddleware(store)factory; the canonicalChangeMiddleware<T>consumers compose into their primitive's middleware chain when they need to suppress echoes of their own outbound writes (#226-5 / ADR-033)runtime/subsystems/integration/integration.module.ts—IntegrationModule.forRoot({ backend, multiTenant? });global: trueruntime/subsystems/integration/integration.tokens.ts— string-valued tokensruntime/subsystems/integration/integration-errors.ts—MissingTenantIdErrorclass +assertTenantId(tenantId, { multiTenant, operation })shared helper
Generator pieces:
templates/entity/new/backend/modules/core/integration-source.ejs.t— per-entity Phase 2 factory module emission (ADR-033.1 c, #251). One<entity>-integration-source.module.tsper entity, regardless of provider count; exports<ENTITY>_POLL_FETCH_REGISTRY+<ENTITY>_CHANGE_SOURCES: ReadonlyMap<string, IChangeSource<T>>.templates/entity/new/backend/modules/core/integration-source.providers.ejs.t— sibling typed-provider artifact (ADR-033.2): const tuple + literal-union type for compile-time consumer-registry checks.templates/subsystem/integration/— main scaffold (prompt.js,integration-audit.schema.ejs.t) — emitted onsubsystem install integrationtemplates/subsystem/integration-config/— config-block scaffold — emitted on first install;--forcealone preserves an existing block,--force-configopts into regeneration (F13 pattern)src/cli/shared/integration-scaffold-locals.ts— resolves Hygen locals (appName, multiTenant, configPath, schemaPath — NO generatedKeepPath)
Cross-links
- Events SKILL.md — integration can
TypedEventBus.publish(...)after each successful upsert (consumer wires this; the subsystem doesn't). - Jobs SKILL.md — integration is typically triggered by a scheduled job
(polling) or on-demand via
action: 'manual'from a CLI / operator action. docs/adrs/ADR-008-subsystem-architecture.md— Protocol → Backend → Factory pattern the integration subsystem follows.docs/CONSUMER-SETUP.md#integration-subsystem— fresh-install walkthrough.docs/guides/integration-migration.md— migrating from a bespoke integration pipeline.- Epic #60 — authoritative decision record;
IChangeSource<T>compromise analysis, ADR-0003 audit model rationale, dealbrain-v2 extraction verdict.