@pattern-stack/codegen 0.9.1 → 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.
- package/README.md +5 -0
- package/consumer-skills/bridge/SKILL.md +265 -0
- package/consumer-skills/codegen/SKILL.md +115 -0
- package/consumer-skills/entities/SKILL.md +111 -0
- package/consumer-skills/entities/families-and-queries.md +82 -0
- package/consumer-skills/entities/yaml-reference.md +118 -0
- package/consumer-skills/events/SKILL.md +71 -0
- package/consumer-skills/events/authoring-events.md +164 -0
- package/consumer-skills/events/typed-bus-and-outbox.md +163 -0
- package/consumer-skills/jobs/SKILL.md +66 -0
- package/consumer-skills/jobs/handler-authoring.md +236 -0
- package/consumer-skills/jobs/pools-and-ordering.md +161 -0
- package/consumer-skills/subsystems/SKILL.md +105 -0
- package/consumer-skills/subsystems/wiring-and-order.md +120 -0
- package/consumer-skills/sync/SKILL.md +134 -0
- package/consumer-skills/sync/audit-and-detection.md +302 -0
- package/consumer-skills/sync/change-sources-and-sinks.md +442 -0
- package/dist/runtime/subsystems/bridge/bridge.module.js +3 -0
- package/dist/runtime/subsystems/bridge/bridge.module.js.map +1 -1
- package/dist/runtime/subsystems/bridge/index.js +3 -0
- package/dist/runtime/subsystems/bridge/index.js.map +1 -1
- package/dist/runtime/subsystems/index.js +3 -0
- package/dist/runtime/subsystems/index.js.map +1 -1
- package/dist/runtime/subsystems/jobs/index.js +3 -0
- package/dist/runtime/subsystems/jobs/index.js.map +1 -1
- package/dist/runtime/subsystems/jobs/job-run-keyset-cursor.js +3 -0
- package/dist/runtime/subsystems/jobs/job-run-keyset-cursor.js.map +1 -1
- package/dist/runtime/subsystems/jobs/job-run-service.drizzle-backend.js +3 -0
- package/dist/runtime/subsystems/jobs/job-run-service.drizzle-backend.js.map +1 -1
- package/dist/runtime/subsystems/jobs/job-run-service.memory-backend.js +3 -0
- package/dist/runtime/subsystems/jobs/job-run-service.memory-backend.js.map +1 -1
- package/dist/runtime/subsystems/jobs/job-run-service.protocol.d.ts +9 -1
- package/dist/runtime/subsystems/jobs/job-worker.module.js +3 -0
- package/dist/runtime/subsystems/jobs/job-worker.module.js.map +1 -1
- package/dist/runtime/subsystems/jobs/jobs-domain.module.js +3 -0
- package/dist/runtime/subsystems/jobs/jobs-domain.module.js.map +1 -1
- package/dist/src/cli/index.js +913 -405
- package/dist/src/cli/index.js.map +1 -1
- package/dist/src/index.js +26 -4
- package/dist/src/index.js.map +1 -1
- package/package.json +2 -1
- package/runtime/subsystems/jobs/job-run-keyset-cursor.ts +3 -0
- package/runtime/subsystems/jobs/job-run-service.protocol.ts +9 -1
|
@@ -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.
|