@pattern-stack/codegen 0.9.2 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,442 @@
1
+ <!-- managed by @pattern-stack/codegen — re-run `codegen skills install` to refresh. Edit the package source, not this vendored copy. -->
2
+
3
+ # Change sources, sinks, and feature-module wiring
4
+
5
+ How you wire a sync integration end to end: the per-entity feature module, the
6
+ `IChangeSource<T>` adapter, the `ISyncSink<T>` write surface, the entity-YAML
7
+ `detection:` block, triggering runs, multi-tenancy, loopback, and testing.
8
+
9
+ Everything imports from `@shared/subsystems/sync`.
10
+
11
+ ## The per-entity feature module
12
+
13
+ `SyncModule.forRoot(...)` in `AppModule` wires the substrate (cursor store, run
14
+ recorder, field differ, multi-tenant flag). For **each canonical entity you
15
+ sync**, write a feature module that binds:
16
+
17
+ - `SYNC_CHANGE_SOURCE` — your adapter (one per `(provider, detection-mode, entity)`)
18
+ - `SYNC_SINK` — your sink (one per canonical entity)
19
+ - `ExecuteSyncUseCase` — the orchestrator class itself
20
+ - optionally `SYNC_FIELD_DIFFER` (custom diff rules) and/or
21
+ `SYNC_LOOPBACK_FINGERPRINT_STORE`
22
+
23
+ ```ts
24
+ import { Module } from '@nestjs/common';
25
+ import {
26
+ ExecuteSyncUseCase,
27
+ SYNC_CHANGE_SOURCE,
28
+ SYNC_SINK,
29
+ SYNC_FIELD_DIFFER,
30
+ DeepEqualDiffer,
31
+ } from '@shared/subsystems/sync';
32
+
33
+ @Module({
34
+ providers: [
35
+ { provide: SYNC_CHANGE_SOURCE, useClass: SalesforceOpportunityChangeSource },
36
+ { provide: SYNC_SINK, useClass: OpportunitySyncSink },
37
+ // Override the differ per-entity when you need a wider ignore list:
38
+ {
39
+ provide: SYNC_FIELD_DIFFER,
40
+ useValue: new DeepEqualDiffer({ ignore: ['sync_version', 'internal_notes'] }),
41
+ },
42
+ ExecuteSyncUseCase,
43
+ ],
44
+ exports: [ExecuteSyncUseCase],
45
+ })
46
+ export class OpportunitySyncModule {}
47
+ ```
48
+
49
+ **Why `ExecuteSyncUseCase` lives here and not in `SyncModule`:** the
50
+ orchestrator depends on `SYNC_CHANGE_SOURCE` + `SYNC_SINK`, which are
51
+ per-feature. Nest resolves providers at module compile time; putting the
52
+ orchestrator in the global `SyncModule` would require those tokens globally,
53
+ which fails until your feature module is imported.
54
+
55
+ Inject `ExecuteSyncUseCase<CanonicalOpportunity>` wherever you trigger a run —
56
+ a scheduled job, a CLI command, a webhook handler, an operator UI button.
57
+
58
+ ## Writing an `IChangeSource<T>`
59
+
60
+ The one port every adapter implements. The signature is
61
+ `listChanges(subscription, cursor): AsyncIterable<Change<T>>`:
62
+
63
+ ```ts
64
+ interface IChangeSource<T> {
65
+ readonly label: string; // e.g. 'salesforce-poll-opportunity'
66
+ listChanges(
67
+ subscription: SyncSubscriptionView,
68
+ cursor: unknown | null,
69
+ ): AsyncIterable<Change<T>>;
70
+ }
71
+
72
+ interface Change<T> {
73
+ externalId: string;
74
+ operation: 'created' | 'updated' | 'deleted';
75
+ record: T; // canonical shape — provider mapping happens in the adapter
76
+ cursor: unknown; // typed internally; opaque at the seam
77
+ source: 'poll' | 'cdc' | 'webhook'; // provenance for the run-log audit
78
+ dedupKey?: string; // CDC replay_id / webhook event_id when available
79
+ providerChangedFields?: string[]; // CDC-only hint; lets the differ skip untouched fields
80
+ }
81
+ ```
82
+
83
+ A worked poll adapter:
84
+
85
+ ```ts
86
+ import { Injectable } from '@nestjs/common';
87
+ import type { IChangeSource, Change, SyncSubscriptionView } from '@shared/subsystems/sync';
88
+
89
+ @Injectable()
90
+ export class SalesforceOpportunityChangeSource
91
+ implements IChangeSource<CanonicalOpportunity>
92
+ {
93
+ readonly label = 'salesforce-poll-opportunity';
94
+
95
+ constructor(
96
+ private readonly sfdc: SalesforceClient,
97
+ private readonly auth: SalesforceAuthStrategy,
98
+ ) {}
99
+
100
+ async *listChanges(
101
+ sub: SyncSubscriptionView,
102
+ cursor: unknown | null,
103
+ ): AsyncIterable<Change<CanonicalOpportunity>> {
104
+ const typed = cursor as { systemModstamp?: string } | null;
105
+ const since = typed?.systemModstamp ?? '1970-01-01T00:00:00Z';
106
+
107
+ // Auth refresh wraps the upstream call — see rule 3 below.
108
+ const records = await this.auth.withAuthRetry(sub.id, () =>
109
+ this.sfdc.query(
110
+ `SELECT Id, Name, Amount, StageName, SystemModstamp, IsDeleted
111
+ FROM Opportunity
112
+ WHERE SystemModstamp > ${since}
113
+ ORDER BY SystemModstamp ASC`,
114
+ ),
115
+ );
116
+
117
+ for (const r of records) {
118
+ yield {
119
+ externalId: r.Id,
120
+ operation: r.IsDeleted ? 'deleted' : 'updated',
121
+ record: toCanonicalOpportunity(r),
122
+ cursor: { systemModstamp: r.SystemModstamp },
123
+ source: 'poll',
124
+ };
125
+ }
126
+ }
127
+ }
128
+ ```
129
+
130
+ **Three rules for adapters:**
131
+
132
+ 1. **Yield `operation: 'updated'` for existing-row changes.** The orchestrator
133
+ computes `'created'` vs `'updated'` itself, based on whether
134
+ `sink.findByExternalId` returns null. Don't pre-compute it in the adapter —
135
+ you have no cheap way to check local state, and duplicating the check wastes
136
+ DB round-trips. Yield `'deleted'` only for genuine upstream deletions.
137
+
138
+ 2. **The cursor must be strictly increasing per yield.** Order by your cursor
139
+ column ASC. If you yield out of cursor order, a mid-run crash persists the
140
+ cursor of the *last-yielded* (not last-successful) record, and the next run
141
+ skips everything between the crash point and that yield.
142
+
143
+ 3. **Auth refresh belongs in the adapter, not the orchestrator.** The
144
+ orchestrator has no notion of session expiry. Wrap upstream client calls
145
+ with a retry-on-auth-fail layer (if you installed the `auth` subsystem, its
146
+ `withAuthRetry` helper is the canonical pattern).
147
+
148
+ **Cursor shapes are opaque at the seam** — the orchestrator persists
149
+ `change.cursor` and never interprets it. Type it however your provider needs
150
+ (`{ systemModstamp }`, `{ replayId }`, `{ ts }`, …). **Do not add mode-specific
151
+ methods** to `IChangeSource`; if a new mode emerges, add a value to the
152
+ `source` union and a metadata field, not a new port.
153
+
154
+ ## Writing an `ISyncSink<T>`
155
+
156
+ One sink per canonical entity. It speaks the *canonical* shape externally;
157
+ internal mapping (canonical → local columns, EAV dual-write, FK resolution)
158
+ stays inside:
159
+
160
+ ```ts
161
+ interface ISyncSink<TCanonical> {
162
+ findByExternalId(userId: string, externalId: string): Promise<TCanonical | null>;
163
+ upsertByExternalId(userId: string, record: TCanonical, provider: string): Promise<{ id: string; saved: TCanonical }>;
164
+ softDeleteByExternalId(userId: string, externalId: string): Promise<{ id: string } | null>;
165
+ }
166
+ ```
167
+
168
+ ```ts
169
+ @Injectable()
170
+ export class OpportunitySyncSink implements ISyncSink<CanonicalOpportunity> {
171
+ constructor(
172
+ @Inject(DRIZZLE) private readonly db: DrizzleClient,
173
+ private readonly opportunities: OpportunityService,
174
+ private readonly accounts: AccountRepository, // FK resolution
175
+ ) {}
176
+
177
+ async findByExternalId(userId: string, externalId: string) {
178
+ const row = await this.opportunities.findByExternalId(userId, externalId);
179
+ return row ? toCanonical(row) : null; // MUST return canonical shape
180
+ }
181
+
182
+ async upsertByExternalId(userId: string, record: CanonicalOpportunity, provider: string) {
183
+ // One transaction spanning FK resolve + row upsert (+ EAV dual-write if used).
184
+ return this.db.transaction(async (tx) => {
185
+ const accountId = record.accountExternalId
186
+ ? (await this.accounts.findByExternalIdRequired(userId, record.accountExternalId, tx)).id
187
+ : null;
188
+ const { id, saved } = await this.opportunities.upsert(
189
+ userId, { ...record, accountId, provider }, { tx },
190
+ );
191
+ return { id, saved: toCanonical(saved) };
192
+ });
193
+ }
194
+
195
+ async softDeleteByExternalId(userId: string, externalId: string) {
196
+ const result = await this.opportunities.softDeleteByExternalId(userId, externalId);
197
+ return result ? { id: result.id } : null;
198
+ }
199
+ }
200
+ ```
201
+
202
+ **Rules for sinks:**
203
+
204
+ - **`findByExternalId` MUST return canonical.** The differ compares it against
205
+ `change.record` (also canonical). Mixing canonical and local shapes makes
206
+ every row look "changed." Project the local row before returning.
207
+ - **`upsertByExternalId` owns the transactional envelope** — FK resolution, EAV
208
+ dual-write (canonical columns + custom-field rows), `user_id` + `provider`
209
+ stamping all happen inside its transaction. The subsystem never reaches around
210
+ the sink to write local tables. **Return the local id** so the orchestrator
211
+ can record it on `sync_run_items.local_id`.
212
+ - **Re-entry tolerance is the sink's job.** A webhook retry or polling overlap
213
+ can deliver the same record twice — make the upsert idempotent (typically
214
+ `ON CONFLICT (external_id) DO UPDATE` with no-op semantics when nothing
215
+ changed).
216
+
217
+ ## The `detection:` block — provider-keyed codegen factory
218
+
219
+ For poll-mode integrations you can declare detection config in the entity YAML
220
+ instead of hand-writing the adapter. Declare one `DetectionConfig` per
221
+ integration provider:
222
+
223
+ ```yaml
224
+ # entities/opportunity.yaml
225
+ sync:
226
+ providers:
227
+ hubspot-crm: { remote_entity: deal, direction: inbound }
228
+ salesforce-crm: { remote_entity: Opportunity, direction: inbound }
229
+
230
+ detection:
231
+ hubspot-crm:
232
+ mode: poll
233
+ poll: { cursor: { kind: timestamp, field: hs_lastmodifieddate } }
234
+ mapping: [ ... ]
235
+ filters: [ ... ]
236
+ salesforce-crm:
237
+ mode: poll
238
+ poll: { cursor: { kind: systemModstamp, field: SystemModstamp } }
239
+ mapping: [ ... ]
240
+ filters: [ ... ]
241
+ ```
242
+
243
+ Codegen emits exactly one `<entity>-sync-source.module.ts` per entity,
244
+ regardless of provider count. It exports two runtime symbols (plus the module
245
+ class):
246
+
247
+ | Symbol | Type | Who fills it |
248
+ |---|---|---|
249
+ | `OPPORTUNITY_POLL_FETCH_REGISTRY` | `Record<string, PollFetchCallback<Opportunity>>` | you supply the fetch fns |
250
+ | `OPPORTUNITY_CHANGE_SOURCES` | `ReadonlyMap<string, IChangeSource<Opportunity>>` | factory output |
251
+
252
+ The factory iterates the parsed detection configs once and builds one change
253
+ source per provider — there is no per-provider symbol and no
254
+ `isMultiProvider` branch. Adding a provider to YAML changes the configs map's
255
+ contents and nothing in the generated symbol space.
256
+
257
+ Wire your fetch callbacks in a feature module:
258
+
259
+ ```ts
260
+ import {
261
+ OPPORTUNITY_POLL_FETCH_REGISTRY,
262
+ OPPORTUNITY_CHANGE_SOURCES,
263
+ OpportunitySyncSourceModule,
264
+ } from '@modules/opportunity-sync-source.module';
265
+ import type { OpportunityProvider } from '@modules/opportunity-sync-source.providers';
266
+ import { hubspotFetchOpportunities, salesforceFetchOpportunities } from './my-fetches';
267
+
268
+ @Module({
269
+ imports: [OpportunitySyncSourceModule],
270
+ providers: [
271
+ {
272
+ provide: OPPORTUNITY_POLL_FETCH_REGISTRY,
273
+ useValue: {
274
+ 'hubspot-crm': hubspotFetchOpportunities,
275
+ 'salesforce-crm': salesforceFetchOpportunities,
276
+ } satisfies Record<OpportunityProvider, PollFetchCallback<Opportunity>>,
277
+ },
278
+ ],
279
+ })
280
+ export class OpportunitySyncWiringModule {}
281
+ ```
282
+
283
+ The sibling `<entity>-sync-source.providers.ts` artifact exports the
284
+ `<EntityName>Provider` literal-union type — using `Record<OpportunityProvider, …>`
285
+ (or the `satisfies` form above) turns a provider-key typo into a compile error.
286
+
287
+ Your poll fetch callback receives exactly `{ subscription, cursor, filters }`.
288
+ Run-scope identity (`userId`, `tenantId`) is NOT threaded through the port — close
289
+ it over at adapter construction, or resolve it inside the callback via your own
290
+ services.
291
+
292
+ ## Triggering a run
293
+
294
+ `ExecuteSyncUseCase` does not schedule itself. Common triggers:
295
+
296
+ **Scheduled job (typical polling)** — wrap the use case in a normal background
297
+ job on one of *your own* pools (never a reserved `events_*` pool — those belong
298
+ to the event/bridge machinery and throw at boot) and give it a cron trigger
299
+ (see the `jobs` skill for the handler shape + scheduling):
300
+
301
+ ```ts
302
+ @JobHandler<{ subscriptionId: string; tenantId?: string }>('sync_opportunity_poll', {
303
+ pool: 'batch',
304
+ })
305
+ export class SyncOpportunityPollHandler extends JobHandlerBase<{
306
+ subscriptionId: string;
307
+ tenantId?: string;
308
+ }> {
309
+ constructor(private readonly execute: ExecuteSyncUseCase<CanonicalOpportunity>) {
310
+ super();
311
+ }
312
+
313
+ async run(ctx: JobContext<{ subscriptionId: string; tenantId?: string }>) {
314
+ return this.execute.execute({
315
+ subscription: { id: ctx.input.subscriptionId, domain: 'opportunity' },
316
+ userId: 'system',
317
+ provider: 'salesforce-crm',
318
+ direction: 'inbound',
319
+ action: 'poll',
320
+ tenantId: ctx.input.tenantId ?? null,
321
+ });
322
+ }
323
+ }
324
+ ```
325
+
326
+ **Webhook handler** — pass `action: 'webhook'` and, if the payload carries the
327
+ records, a `sourceOverride` adapter that yields them instead of the DI-bound
328
+ source:
329
+
330
+ ```ts
331
+ return this.execute.execute({
332
+ subscription: { id: sub.id, domain: 'opportunity' },
333
+ userId: 'system', provider: 'salesforce-crm',
334
+ direction: 'inbound', action: 'webhook', tenantId: sub.tenantId,
335
+ sourceOverride: new SalesforceWebhookChangeSource(body.records),
336
+ });
337
+ ```
338
+
339
+ **Manual operator re-sync** — `action: 'manual'` distinguishes operator runs
340
+ from scheduled ones in the audit log:
341
+
342
+ ```ts
343
+ await this.execute.execute({
344
+ subscription: { id: subscriptionId, domain: 'opportunity' },
345
+ userId: actor.id, provider: 'salesforce-crm',
346
+ direction: 'inbound', action: 'manual', tenantId: actor.tenantId,
347
+ });
348
+ ```
349
+
350
+ ## Emitting events on successful sync
351
+
352
+ The orchestrator does not emit events — wire `TypedEventBus.publish(...)`
353
+ inside your sink's `upsertByExternalId` transaction, after the row is saved, so
354
+ the event and the write commit (or roll back) together:
355
+
356
+ ```ts
357
+ async upsertByExternalId(userId, record, provider) {
358
+ return this.db.transaction(async (tx) => {
359
+ const { id, saved } = await this.opportunities.upsert(userId, record, { tx });
360
+ await this.events.publish('opportunity_updated', id, {
361
+ opportunityId: id, amount: saved.amount, stageName: saved.stageName, actorUserId: userId,
362
+ }, { tx });
363
+ return { id, saved: toCanonical(saved) };
364
+ });
365
+ }
366
+ ```
367
+
368
+ The change-direction event lands in the `events_change` pool; downstream
369
+ consumers subscribe via their own handlers. See the `events` skill.
370
+
371
+ ## Multi-tenancy
372
+
373
+ Three things change when `multi_tenant: true`:
374
+
375
+ 1. **`SyncModule.forRoot({ backend: 'drizzle', multiTenant: true })`** in
376
+ `AppModule` — binds the multi-tenant flag the orchestrator and Drizzle
377
+ backends inject.
378
+ 2. **Every `execute()` call passes `tenantId`.** Missing/null throws
379
+ `MissingTenantIdError` at entry, *before* a `sync_runs` row is opened (no
380
+ dangling `status=running` rows). The Drizzle backends re-validate at their
381
+ write boundary (defense in depth); all sites share one helper so error
382
+ messages match. Explicit `null` is allowed only for deliberate cross-tenant
383
+ work.
384
+ 3. **Schema gains `tenant_id` columns** on all three sync tables. Flip
385
+ `sync.multi_tenant: true` in `codegen.config.yaml`, re-run
386
+ `subsystem install sync --force --force-config` to re-emit the schema, then
387
+ apply the migration **before** flipping the module flag — otherwise the
388
+ Drizzle backends throw `column "tenant_id" does not exist` on every write.
389
+
390
+ Memory backends (tests) accept `tenantId` and record it but never throw —
391
+ process-local state has no meaningful cross-tenant isolation. Tests that assert
392
+ isolation guarantees must target the Drizzle backends against real Postgres.
393
+
394
+ ## Loopback suppression (optional)
395
+
396
+ Only needed if your system writes *outbound* to the upstream, which then echoes
397
+ the change back on the next inbound poll. Implement and bind a fingerprint
398
+ store:
399
+
400
+ ```ts
401
+ interface ILoopbackFingerprintStore<T = unknown> {
402
+ isEchoOfOwnWrite(entityType: string, externalId: string, record: T): Promise<boolean>;
403
+ }
404
+
405
+ { provide: SYNC_LOOPBACK_FINGERPRINT_STORE, useClass: RedisLoopbackStore }
406
+ ```
407
+
408
+ Record a fingerprint (hash of the canonicalized record, TTL **shorter than the
409
+ poll interval**) on your outbound write path; `isEchoOfOwnWrite` returns true
410
+ when the next inbound change matches. The orchestrator's `@Optional()` inject
411
+ means consumers without a writeback path omit the binding — the check is
412
+ skipped. An echo is recorded as `operation='noop', status='skipped'` so you can
413
+ verify suppression in the audit log; the sink is never called.
414
+
415
+ ## Testing
416
+
417
+ `SyncModule.forRoot({ backend: 'memory' })` plus memory feature-module fakes
418
+ gives an end-to-end test with no Postgres:
419
+
420
+ ```ts
421
+ import { SyncModule, MemoryRunRecorder } from '@shared/subsystems/sync';
422
+
423
+ const moduleRef = await Test.createTestingModule({
424
+ imports: [
425
+ SyncModule.forRoot({ backend: 'memory' }),
426
+ OpportunitySyncTestModule, // same shape as the real feature module, with fakes
427
+ ],
428
+ }).compile();
429
+
430
+ const orch = moduleRef.get(ExecuteSyncUseCase);
431
+ const recorder = moduleRef.get(MemoryRunRecorder);
432
+
433
+ await orch.execute({ /* ... */ });
434
+
435
+ const runs = recorder.getRunsForSubscription('sub-1'); // ergonomic test helpers
436
+ expect(runs[0].status).toBe('success');
437
+ expect(recorder.getItemsForRun(runs[0].id)).toHaveLength(3);
438
+ ```
439
+
440
+ Unit-test sinks against a real test DB (or a transaction-wrapping mock);
441
+ unit-test adapters against an HTTP mock for the upstream API; integration-test
442
+ the full stack against real Postgres for end-to-end coverage.