@pattern-stack/codegen 0.4.0 → 0.4.2
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/CHANGELOG.md +14 -0
- package/dist/src/cli/index.js +1616 -1070
- package/dist/src/cli/index.js.map +1 -1
- package/package.json +3 -1
- package/runtime/analytics/index.ts +31 -0
- package/runtime/analytics/metrics.ts +85 -0
- package/runtime/analytics/packs/crm-entity-measures.ts +20 -0
- package/runtime/analytics/packs/index.ts +5 -0
- package/runtime/analytics/packs/monetary-measures.ts +20 -0
- package/runtime/analytics/specs.ts +54 -0
- package/runtime/analytics/types.ts +105 -0
- package/runtime/base-classes/activity-entity-repository.ts +50 -0
- package/runtime/base-classes/activity-entity-service.ts +48 -0
- package/runtime/base-classes/base-read-use-cases.ts +88 -0
- package/runtime/base-classes/base-repository.ts +289 -0
- package/runtime/base-classes/base-service.ts +183 -0
- package/runtime/base-classes/index.ts +38 -0
- package/runtime/base-classes/knowledge-entity-repository.ts +12 -0
- package/runtime/base-classes/knowledge-entity-service.ts +14 -0
- package/runtime/base-classes/lifecycle-events.ts +152 -0
- package/runtime/base-classes/metadata-entity-repository.ts +80 -0
- package/runtime/base-classes/metadata-entity-service.ts +48 -0
- package/runtime/base-classes/synced-entity-repository.ts +57 -0
- package/runtime/base-classes/synced-entity-service.ts +50 -0
- package/runtime/base-classes/with-analytics.ts +22 -0
- package/runtime/constants/tokens.ts +29 -0
- package/runtime/eav-helpers.ts +74 -0
- package/runtime/pipes/zod-validation.pipe.ts +64 -0
- package/runtime/shared/openapi/error-response.dto.ts +24 -0
- package/runtime/shared/openapi/errors.ts +39 -0
- package/runtime/shared/openapi/index.ts +20 -0
- package/runtime/shared/openapi/registry.tokens.ts +13 -0
- package/runtime/shared/openapi/registry.ts +151 -0
- package/runtime/subsystems/analytics/analytics-query.protocol.ts +37 -0
- package/runtime/subsystems/analytics/analytics.module.ts +64 -0
- package/runtime/subsystems/analytics/analytics.tokens.ts +24 -0
- package/runtime/subsystems/analytics/cube-backend.ts +75 -0
- package/runtime/subsystems/analytics/index.ts +15 -0
- package/runtime/subsystems/analytics/noop-backend.ts +27 -0
- package/runtime/subsystems/auth/auth.module.ts +91 -0
- package/runtime/subsystems/auth/auth.tokens.ts +27 -0
- package/runtime/subsystems/auth/backends/encryption-key/env.ts +76 -0
- package/runtime/subsystems/auth/backends/oauth-state-store/in-memory.ts +42 -0
- package/runtime/subsystems/auth/index.ts +77 -0
- package/runtime/subsystems/auth/protocols/auth-strategy.ts +46 -0
- package/runtime/subsystems/auth/protocols/encryption-key.ts +21 -0
- package/runtime/subsystems/auth/protocols/integration-store.ts +66 -0
- package/runtime/subsystems/auth/protocols/oauth-state-store.ts +16 -0
- package/runtime/subsystems/auth/runtime/integration-broken.error.ts +21 -0
- package/runtime/subsystems/auth/runtime/oauth2-refresh.strategy.ts +189 -0
- package/runtime/subsystems/auth/runtime/session-expired.error.ts +39 -0
- package/runtime/subsystems/auth/runtime/with-auth-retry.ts +50 -0
- package/runtime/subsystems/bridge/assert-tenant-id.ts +57 -0
- package/runtime/subsystems/bridge/bridge-delivery-handler.ts +220 -0
- package/runtime/subsystems/bridge/bridge-delivery.drizzle-backend.ts +149 -0
- package/runtime/subsystems/bridge/bridge-delivery.memory-backend.ts +140 -0
- package/runtime/subsystems/bridge/bridge-delivery.schema.ts +142 -0
- package/runtime/subsystems/bridge/bridge-errors.ts +112 -0
- package/runtime/subsystems/bridge/bridge-outbox-drain-hook.ts +175 -0
- package/runtime/subsystems/bridge/bridge.module.ts +160 -0
- package/runtime/subsystems/bridge/bridge.protocol.ts +351 -0
- package/runtime/subsystems/bridge/bridge.tokens.ts +68 -0
- package/runtime/subsystems/bridge/event-flow.service.ts +175 -0
- package/runtime/subsystems/bridge/generated/.gitkeep +0 -0
- package/runtime/subsystems/bridge/generated/registry.ts +6 -0
- package/runtime/subsystems/bridge/index.ts +84 -0
- package/runtime/subsystems/bridge/reserved-pools.ts +36 -0
- package/runtime/subsystems/cache/cache.drizzle-backend.ts +150 -0
- package/runtime/subsystems/cache/cache.memory-backend.ts +116 -0
- package/runtime/subsystems/cache/cache.module.ts +115 -0
- package/runtime/subsystems/cache/cache.protocol.ts +45 -0
- package/runtime/subsystems/cache/cache.schema.ts +27 -0
- package/runtime/subsystems/cache/cache.tokens.ts +17 -0
- package/runtime/subsystems/cache/index.ts +22 -0
- package/runtime/subsystems/events/domain-events.schema.ts +77 -0
- package/runtime/subsystems/events/event-bus.drizzle-backend.ts +327 -0
- package/runtime/subsystems/events/event-bus.memory-backend.ts +142 -0
- package/runtime/subsystems/events/event-bus.protocol.ts +86 -0
- package/runtime/subsystems/events/event-bus.redis-backend.ts +304 -0
- package/runtime/subsystems/events/events-errors.ts +30 -0
- package/runtime/subsystems/events/events.module.ts +230 -0
- package/runtime/subsystems/events/events.tokens.ts +62 -0
- package/runtime/subsystems/events/generated/bus.ts +103 -0
- package/runtime/subsystems/events/generated/index.ts +7 -0
- package/runtime/subsystems/events/generated/registry.ts +84 -0
- package/runtime/subsystems/events/generated/schemas.ts +59 -0
- package/runtime/subsystems/events/generated/types.ts +94 -0
- package/runtime/subsystems/events/index.ts +21 -0
- package/runtime/subsystems/index.ts +63 -0
- package/runtime/subsystems/jobs/generated/job-orchestration.schema.multi-tenant.ts +217 -0
- package/runtime/subsystems/jobs/generated/job-orchestration.schema.single-tenant.ts +217 -0
- package/runtime/subsystems/jobs/generated/scope-entity-type.ts +10 -0
- package/runtime/subsystems/jobs/index.ts +120 -0
- package/runtime/subsystems/jobs/job-handler.base.ts +206 -0
- package/runtime/subsystems/jobs/job-orchestration.schema.ts +217 -0
- package/runtime/subsystems/jobs/job-orchestrator.drizzle-backend.ts +536 -0
- package/runtime/subsystems/jobs/job-orchestrator.memory-backend.ts +850 -0
- package/runtime/subsystems/jobs/job-orchestrator.protocol.ts +179 -0
- package/runtime/subsystems/jobs/job-run-service.drizzle-backend.ts +171 -0
- package/runtime/subsystems/jobs/job-run-service.memory-backend.ts +165 -0
- package/runtime/subsystems/jobs/job-run-service.protocol.ts +79 -0
- package/runtime/subsystems/jobs/job-step-service.drizzle-backend.ts +66 -0
- package/runtime/subsystems/jobs/job-step-service.memory-backend.ts +119 -0
- package/runtime/subsystems/jobs/job-step-service.protocol.ts +53 -0
- package/runtime/subsystems/jobs/job-worker.module.ts +302 -0
- package/runtime/subsystems/jobs/job-worker.ts +615 -0
- package/runtime/subsystems/jobs/jobs-domain.module.ts +119 -0
- package/runtime/subsystems/jobs/jobs-domain.tokens.ts +30 -0
- package/runtime/subsystems/jobs/jobs-errors.ts +150 -0
- package/runtime/subsystems/jobs/memory-job-store.ts +35 -0
- package/runtime/subsystems/jobs/pool-config.loader.ts +218 -0
- package/runtime/subsystems/storage/index.ts +18 -0
- package/runtime/subsystems/storage/storage.local-backend.ts +113 -0
- package/runtime/subsystems/storage/storage.memory-backend.ts +78 -0
- package/runtime/subsystems/storage/storage.module.ts +60 -0
- package/runtime/subsystems/storage/storage.protocol.ts +78 -0
- package/runtime/subsystems/storage/storage.tokens.ts +9 -0
- package/runtime/subsystems/storage/storage.utils.ts +20 -0
- package/runtime/subsystems/sync/deep-equal.differ.ts +198 -0
- package/runtime/subsystems/sync/execute-sync.use-case.ts +334 -0
- package/runtime/subsystems/sync/index.ts +98 -0
- package/runtime/subsystems/sync/sync-audit.schema.ts +300 -0
- package/runtime/subsystems/sync/sync-change-source.protocol.ts +99 -0
- package/runtime/subsystems/sync/sync-cursor-store.drizzle-backend.ts +104 -0
- package/runtime/subsystems/sync/sync-cursor-store.memory-backend.ts +64 -0
- package/runtime/subsystems/sync/sync-cursor-store.protocol.ts +53 -0
- package/runtime/subsystems/sync/sync-errors.ts +54 -0
- package/runtime/subsystems/sync/sync-field-diff.protocol.ts +61 -0
- package/runtime/subsystems/sync/sync-loopback.protocol.ts +33 -0
- package/runtime/subsystems/sync/sync-run-recorder.drizzle-backend.ts +123 -0
- package/runtime/subsystems/sync/sync-run-recorder.memory-backend.ts +143 -0
- package/runtime/subsystems/sync/sync-run-recorder.protocol.ts +86 -0
- package/runtime/subsystems/sync/sync-sink.protocol.ts +55 -0
- package/runtime/subsystems/sync/sync.module.ts +156 -0
- package/runtime/subsystems/sync/sync.tokens.ts +57 -0
- package/runtime/types/drizzle.ts +23 -0
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ExecuteSyncUseCase — the generic sync orchestrator (SYNC-5).
|
|
3
|
+
*
|
|
4
|
+
* One class. Reused across every `(provider, detection-mode, canonical-entity)`
|
|
5
|
+
* tuple. Parameterized over `T` so canonical records stay typed end-to-end.
|
|
6
|
+
*
|
|
7
|
+
* Flow per run:
|
|
8
|
+
*
|
|
9
|
+
* 1. `recorder.startRun(...)` — opens a `sync_runs` row in 'running'.
|
|
10
|
+
* 2. for each change yielded by `source.listChanges(subscription)`:
|
|
11
|
+
* a. if loopback store says "echo of own write" → skip, record
|
|
12
|
+
* as `status: 'skipped'`, `operation: 'noop'`, changedFields `{}`.
|
|
13
|
+
* b. differ.diff(existing, incoming) → 'noop' short-circuits to
|
|
14
|
+
* a noop audit row (no sink write).
|
|
15
|
+
* c. sink.upsertByExternalId / softDeleteByExternalId → records
|
|
16
|
+
* the local id on the audit row.
|
|
17
|
+
* d. per-item try/catch — a failed item increments the failed
|
|
18
|
+
* counter and records `status: 'failed'` with `error`, but
|
|
19
|
+
* does NOT abort the run.
|
|
20
|
+
* e. advance `latestCursor = change.cursor` as the iterator moves.
|
|
21
|
+
* 3. `cursors.put(subscription.id, latestCursor)` when the loop completes
|
|
22
|
+
* AND at least one cursor advance happened. On exceptions from the
|
|
23
|
+
* source iterator (auth expiry, network error), we persist the
|
|
24
|
+
* last-good cursor so the next run resumes from the last known
|
|
25
|
+
* successful position.
|
|
26
|
+
* 4. `finally { recorder.completeRun(...) }` — always terminates the run.
|
|
27
|
+
*
|
|
28
|
+
* ## Generics
|
|
29
|
+
*
|
|
30
|
+
* - `T` = canonical record shape from the adapter side. Same `T` flows
|
|
31
|
+
* through `IChangeSource<T>`, `IFieldDiffer<T>`, `ISyncSink<T>`.
|
|
32
|
+
*
|
|
33
|
+
* ## No CRM bleed
|
|
34
|
+
*
|
|
35
|
+
* Per the SYNC-5 issue's extraction notes (HS-9 finding), this orchestrator
|
|
36
|
+
* is strictly provider-agnostic:
|
|
37
|
+
* - `entityType` is `string` throughout; no `'opportunity' | 'account' | ...`
|
|
38
|
+
* narrowing leaks into the use case
|
|
39
|
+
* - `loopback.isEchoOfOwnWrite` is typed against the same `T`, not a CRM
|
|
40
|
+
* union
|
|
41
|
+
* - dealbrain's `SyncRunRecorderService` class injection replaced with the
|
|
42
|
+
* `ISyncRunRecorder` protocol (backend lands in SYNC-4)
|
|
43
|
+
*/
|
|
44
|
+
import { Inject, Injectable, Logger, Optional } from '@nestjs/common';
|
|
45
|
+
import type { IChangeSource, Change } from './sync-change-source.protocol';
|
|
46
|
+
import type { ICursorStore } from './sync-cursor-store.protocol';
|
|
47
|
+
import type { IFieldDiffer, FieldDiff } from './sync-field-diff.protocol';
|
|
48
|
+
import type { ISyncSink } from './sync-sink.protocol';
|
|
49
|
+
import type { ISyncRunRecorder } from './sync-run-recorder.protocol';
|
|
50
|
+
import type { ILoopbackFingerprintStore } from './sync-loopback.protocol';
|
|
51
|
+
import { assertTenantId } from './sync-errors';
|
|
52
|
+
import {
|
|
53
|
+
SYNC_CHANGE_SOURCE,
|
|
54
|
+
SYNC_CURSOR_STORE,
|
|
55
|
+
SYNC_FIELD_DIFFER,
|
|
56
|
+
SYNC_LOOPBACK_FINGERPRINT_STORE,
|
|
57
|
+
SYNC_MULTI_TENANT,
|
|
58
|
+
SYNC_RUN_RECORDER,
|
|
59
|
+
SYNC_SINK,
|
|
60
|
+
} from './sync.tokens';
|
|
61
|
+
|
|
62
|
+
// ============================================================================
|
|
63
|
+
// Inputs + result
|
|
64
|
+
// ============================================================================
|
|
65
|
+
|
|
66
|
+
export interface ExecuteSyncInput<T> {
|
|
67
|
+
/** The subscription whose cursor/identity frames this run. */
|
|
68
|
+
readonly subscription: {
|
|
69
|
+
readonly id: string;
|
|
70
|
+
readonly domain: string; // entityType — used on audit rows
|
|
71
|
+
readonly externalRef?: string | null;
|
|
72
|
+
};
|
|
73
|
+
/** Per-run user context; threaded through sink writes. */
|
|
74
|
+
readonly userId: string;
|
|
75
|
+
/** Provider label persisted on saved rows, e.g. `'salesforce-crm'`. */
|
|
76
|
+
readonly provider: string;
|
|
77
|
+
/** Run direction — almost always `'inbound'`. Reserved for writeback. */
|
|
78
|
+
readonly direction: 'inbound' | 'outbound';
|
|
79
|
+
/** Detection mode — maps 1:1 to `sync_runs.action`. */
|
|
80
|
+
readonly action: 'poll' | 'cdc' | 'webhook' | 'manual' | 'writeback';
|
|
81
|
+
/** Multi-tenant deployments pass the tenant id through. */
|
|
82
|
+
readonly tenantId?: string | null;
|
|
83
|
+
/**
|
|
84
|
+
* Optional override — inject a specific change source for this run when
|
|
85
|
+
* the DI-bound source is not the one to use (e.g. manual backfill with
|
|
86
|
+
* a custom cursor). Defaults to the DI-resolved `SYNC_CHANGE_SOURCE`.
|
|
87
|
+
*/
|
|
88
|
+
readonly sourceOverride?: IChangeSource<T>;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface ExecuteSyncResult {
|
|
92
|
+
readonly runId: string;
|
|
93
|
+
readonly status: 'success' | 'no_changes' | 'failed';
|
|
94
|
+
readonly recordsFound: number;
|
|
95
|
+
readonly recordsProcessed: number;
|
|
96
|
+
readonly recordsFailed: number;
|
|
97
|
+
readonly cursorBefore: unknown | null;
|
|
98
|
+
readonly cursorAfter: unknown | null;
|
|
99
|
+
readonly durationMs: number;
|
|
100
|
+
readonly error?: string | null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ============================================================================
|
|
104
|
+
// ExecuteSyncUseCase
|
|
105
|
+
// ============================================================================
|
|
106
|
+
|
|
107
|
+
@Injectable()
|
|
108
|
+
export class ExecuteSyncUseCase<T extends Record<string, unknown>> {
|
|
109
|
+
private readonly logger = new Logger(ExecuteSyncUseCase.name);
|
|
110
|
+
|
|
111
|
+
constructor(
|
|
112
|
+
@Inject(SYNC_CHANGE_SOURCE) private readonly source: IChangeSource<T>,
|
|
113
|
+
@Inject(SYNC_CURSOR_STORE) private readonly cursors: ICursorStore,
|
|
114
|
+
@Inject(SYNC_FIELD_DIFFER) private readonly differ: IFieldDiffer<T>,
|
|
115
|
+
@Inject(SYNC_SINK) private readonly sink: ISyncSink<T>,
|
|
116
|
+
@Inject(SYNC_RUN_RECORDER) private readonly recorder: ISyncRunRecorder,
|
|
117
|
+
@Optional()
|
|
118
|
+
@Inject(SYNC_LOOPBACK_FINGERPRINT_STORE)
|
|
119
|
+
private readonly loopback?: ILoopbackFingerprintStore<T>,
|
|
120
|
+
@Optional()
|
|
121
|
+
@Inject(SYNC_MULTI_TENANT)
|
|
122
|
+
private readonly multiTenant: boolean = false,
|
|
123
|
+
) {}
|
|
124
|
+
|
|
125
|
+
async execute(input: ExecuteSyncInput<T>): Promise<ExecuteSyncResult> {
|
|
126
|
+
// Defense-in-depth tenancy guard — fire BEFORE startRun so a rejected
|
|
127
|
+
// input never leaves a dangling `status=running` row. Backends also
|
|
128
|
+
// enforce (SYNC-4), but failing fast at the orchestrator boundary is
|
|
129
|
+
// cheaper for observability, metrics, and manual cleanup.
|
|
130
|
+
assertTenantId(input.tenantId, {
|
|
131
|
+
multiTenant: this.multiTenant,
|
|
132
|
+
operation: 'execute',
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
const source = input.sourceOverride ?? this.source;
|
|
136
|
+
const startedAt = Date.now();
|
|
137
|
+
const cursorBefore = await this.cursors.get(input.subscription.id, input.tenantId);
|
|
138
|
+
|
|
139
|
+
const { id: runId } = await this.recorder.startRun({
|
|
140
|
+
subscriptionId: input.subscription.id,
|
|
141
|
+
direction: input.direction,
|
|
142
|
+
action: input.action,
|
|
143
|
+
cursorBefore,
|
|
144
|
+
tenantId: input.tenantId,
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
let recordsFound = 0;
|
|
148
|
+
let recordsProcessed = 0;
|
|
149
|
+
let recordsFailed = 0;
|
|
150
|
+
let latestCursor: unknown | null = cursorBefore;
|
|
151
|
+
let cursorAdvanced = false;
|
|
152
|
+
let runError: string | null = null;
|
|
153
|
+
let status: 'success' | 'no_changes' | 'failed' = 'no_changes';
|
|
154
|
+
|
|
155
|
+
try {
|
|
156
|
+
for await (const change of source.listChanges(input.subscription)) {
|
|
157
|
+
recordsFound++;
|
|
158
|
+
latestCursor = change.cursor;
|
|
159
|
+
cursorAdvanced = true;
|
|
160
|
+
|
|
161
|
+
try {
|
|
162
|
+
await this.processChange(runId, input, change);
|
|
163
|
+
recordsProcessed++;
|
|
164
|
+
} catch (err) {
|
|
165
|
+
recordsFailed++;
|
|
166
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
167
|
+
this.logger.warn(
|
|
168
|
+
`sync item failed: subscription=${input.subscription.id} externalId=${change.externalId}: ${message}`,
|
|
169
|
+
);
|
|
170
|
+
await this.recorder.recordItem({
|
|
171
|
+
syncRunId: runId,
|
|
172
|
+
entityType: input.subscription.domain,
|
|
173
|
+
externalId: change.externalId,
|
|
174
|
+
operation: change.operation === 'deleted' ? 'deleted' : 'updated',
|
|
175
|
+
status: 'failed',
|
|
176
|
+
changedFields: {},
|
|
177
|
+
error: message,
|
|
178
|
+
tenantId: input.tenantId,
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (recordsFailed > 0 && recordsProcessed === 0 && recordsFound > 0) {
|
|
184
|
+
// Every record we saw failed — call the run a failure, not a
|
|
185
|
+
// success. Partial success (some processed, some failed) still
|
|
186
|
+
// counts as 'success' so the cursor advances.
|
|
187
|
+
status = 'failed';
|
|
188
|
+
runError = `all ${recordsFailed} records failed`;
|
|
189
|
+
} else if (recordsFound === 0) {
|
|
190
|
+
status = 'no_changes';
|
|
191
|
+
} else {
|
|
192
|
+
status = 'success';
|
|
193
|
+
}
|
|
194
|
+
} catch (err) {
|
|
195
|
+
// Source iterator itself threw — cursor DOES NOT advance past the
|
|
196
|
+
// last-successful cursor. `latestCursor` still holds the last
|
|
197
|
+
// `change.cursor` we observed, which is the furthest we know to
|
|
198
|
+
// have delivered. Persist it (below) so next run resumes there.
|
|
199
|
+
status = 'failed';
|
|
200
|
+
runError = err instanceof Error ? err.message : String(err);
|
|
201
|
+
this.logger.error(
|
|
202
|
+
`sync source failed: subscription=${input.subscription.id}: ${runError}`,
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Persist cursor advance only when something actually moved. Never
|
|
207
|
+
// overwrite a valid cursor with `null` on a no-change run.
|
|
208
|
+
if (cursorAdvanced && latestCursor !== null && latestCursor !== undefined) {
|
|
209
|
+
try {
|
|
210
|
+
await this.cursors.put(input.subscription.id, latestCursor, input.tenantId);
|
|
211
|
+
} catch (err) {
|
|
212
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
213
|
+
this.logger.error(
|
|
214
|
+
`cursor put failed: subscription=${input.subscription.id}: ${message}`,
|
|
215
|
+
);
|
|
216
|
+
if (status !== 'failed') {
|
|
217
|
+
status = 'failed';
|
|
218
|
+
runError = `cursor put failed: ${message}`;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const durationMs = Date.now() - startedAt;
|
|
224
|
+
|
|
225
|
+
await this.recorder.completeRun(runId, {
|
|
226
|
+
status,
|
|
227
|
+
recordsFound,
|
|
228
|
+
recordsProcessed,
|
|
229
|
+
cursorAfter: cursorAdvanced ? latestCursor : cursorBefore,
|
|
230
|
+
durationMs,
|
|
231
|
+
error: runError,
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
return {
|
|
235
|
+
runId,
|
|
236
|
+
status,
|
|
237
|
+
recordsFound,
|
|
238
|
+
recordsProcessed,
|
|
239
|
+
recordsFailed,
|
|
240
|
+
cursorBefore,
|
|
241
|
+
cursorAfter: cursorAdvanced ? latestCursor : cursorBefore,
|
|
242
|
+
durationMs,
|
|
243
|
+
error: runError,
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
private async processChange(
|
|
248
|
+
runId: string,
|
|
249
|
+
input: ExecuteSyncInput<T>,
|
|
250
|
+
change: Change<T>,
|
|
251
|
+
): Promise<void> {
|
|
252
|
+
// Loopback filter — skip echoes of our own outbound writes.
|
|
253
|
+
if (this.loopback) {
|
|
254
|
+
const isEcho = await this.loopback.isEchoOfOwnWrite(
|
|
255
|
+
input.subscription.domain,
|
|
256
|
+
change.externalId,
|
|
257
|
+
change.record,
|
|
258
|
+
);
|
|
259
|
+
if (isEcho) {
|
|
260
|
+
await this.recorder.recordItem({
|
|
261
|
+
syncRunId: runId,
|
|
262
|
+
entityType: input.subscription.domain,
|
|
263
|
+
externalId: change.externalId,
|
|
264
|
+
operation: 'noop',
|
|
265
|
+
status: 'skipped',
|
|
266
|
+
changedFields: {},
|
|
267
|
+
tenantId: input.tenantId,
|
|
268
|
+
});
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// Deletion branch — no diff, no upsert; soft-delete via sink.
|
|
274
|
+
if (change.operation === 'deleted') {
|
|
275
|
+
const result = await this.sink.softDeleteByExternalId(
|
|
276
|
+
input.userId,
|
|
277
|
+
change.externalId,
|
|
278
|
+
);
|
|
279
|
+
await this.recorder.recordItem({
|
|
280
|
+
syncRunId: runId,
|
|
281
|
+
entityType: input.subscription.domain,
|
|
282
|
+
externalId: change.externalId,
|
|
283
|
+
localId: result?.id ?? null,
|
|
284
|
+
operation: result ? 'deleted' : 'noop',
|
|
285
|
+
status: 'success',
|
|
286
|
+
changedFields: {},
|
|
287
|
+
tenantId: input.tenantId,
|
|
288
|
+
});
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// Create/update path — diff against local state, short-circuit on noop.
|
|
293
|
+
const existing = await this.sink.findByExternalId(
|
|
294
|
+
input.userId,
|
|
295
|
+
change.externalId,
|
|
296
|
+
);
|
|
297
|
+
const diff = this.differ.diff(
|
|
298
|
+
existing,
|
|
299
|
+
change.record,
|
|
300
|
+
change.providerChangedFields,
|
|
301
|
+
);
|
|
302
|
+
|
|
303
|
+
if (diff === 'noop') {
|
|
304
|
+
await this.recorder.recordItem({
|
|
305
|
+
syncRunId: runId,
|
|
306
|
+
entityType: input.subscription.domain,
|
|
307
|
+
externalId: change.externalId,
|
|
308
|
+
localId: null,
|
|
309
|
+
operation: 'noop',
|
|
310
|
+
status: 'success',
|
|
311
|
+
changedFields: {},
|
|
312
|
+
tenantId: input.tenantId,
|
|
313
|
+
});
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const { id: localId } = await this.sink.upsertByExternalId(
|
|
318
|
+
input.userId,
|
|
319
|
+
change.record,
|
|
320
|
+
input.provider,
|
|
321
|
+
);
|
|
322
|
+
|
|
323
|
+
await this.recorder.recordItem({
|
|
324
|
+
syncRunId: runId,
|
|
325
|
+
entityType: input.subscription.domain,
|
|
326
|
+
externalId: change.externalId,
|
|
327
|
+
localId,
|
|
328
|
+
operation: existing === null ? 'created' : 'updated',
|
|
329
|
+
status: 'success',
|
|
330
|
+
changedFields: diff as FieldDiff,
|
|
331
|
+
tenantId: input.tenantId,
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sync subsystem — public API
|
|
3
|
+
*
|
|
4
|
+
* Slices landed so far:
|
|
5
|
+
* - SYNC-2 — protocols + DI tokens (#134)
|
|
6
|
+
* - SYNC-1 — Drizzle audit-table schemas (#148)
|
|
7
|
+
* - SYNC-3 — MemoryCursorStore (#149)
|
|
8
|
+
* - SYNC-5 — ExecuteSyncUseCase + DeepEqualDiffer + recorder/loopback protocols (#150)
|
|
9
|
+
* - SYNC-4 — Drizzle backends (#151)
|
|
10
|
+
* - SYNC-6 — SyncModule + MemoryRunRecorder + multi-tenancy opt-in (this slice)
|
|
11
|
+
*
|
|
12
|
+
* Scaffold templates (SYNC-7) and docs/skills (SYNC-8) land in their own
|
|
13
|
+
* PRs. See epic #60.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
// Protocols
|
|
17
|
+
export type {
|
|
18
|
+
Change,
|
|
19
|
+
ChangeSource,
|
|
20
|
+
IChangeSource,
|
|
21
|
+
SyncSubscriptionView,
|
|
22
|
+
} from './sync-change-source.protocol';
|
|
23
|
+
export type { ICursorStore } from './sync-cursor-store.protocol';
|
|
24
|
+
export type {
|
|
25
|
+
DiffResult,
|
|
26
|
+
FieldDiff,
|
|
27
|
+
FieldDiffValue,
|
|
28
|
+
IFieldDiffer,
|
|
29
|
+
} from './sync-field-diff.protocol';
|
|
30
|
+
export {
|
|
31
|
+
FieldDiffSchema,
|
|
32
|
+
FieldDiffValueSchema,
|
|
33
|
+
} from './sync-field-diff.protocol';
|
|
34
|
+
export type { ISyncSink } from './sync-sink.protocol';
|
|
35
|
+
export type {
|
|
36
|
+
CompleteRunInput,
|
|
37
|
+
ISyncRunRecorder,
|
|
38
|
+
RecordItemInput,
|
|
39
|
+
StartRunInput,
|
|
40
|
+
} from './sync-run-recorder.protocol';
|
|
41
|
+
export type { ILoopbackFingerprintStore } from './sync-loopback.protocol';
|
|
42
|
+
|
|
43
|
+
// Tokens
|
|
44
|
+
export {
|
|
45
|
+
SYNC_CHANGE_SOURCE,
|
|
46
|
+
SYNC_CURSOR_STORE,
|
|
47
|
+
SYNC_FIELD_DIFFER,
|
|
48
|
+
SYNC_LOOPBACK_FINGERPRINT_STORE,
|
|
49
|
+
SYNC_MODULE_OPTIONS,
|
|
50
|
+
SYNC_MULTI_TENANT,
|
|
51
|
+
SYNC_RUN_RECORDER,
|
|
52
|
+
SYNC_SINK,
|
|
53
|
+
} from './sync.tokens';
|
|
54
|
+
|
|
55
|
+
// Errors + shared guards
|
|
56
|
+
export { MissingTenantIdError, assertTenantId } from './sync-errors';
|
|
57
|
+
|
|
58
|
+
// Audit schemas (SYNC-1) — Drizzle pgTable declarations + row types + enums
|
|
59
|
+
export {
|
|
60
|
+
syncSubscriptions,
|
|
61
|
+
syncRuns,
|
|
62
|
+
syncRunItems,
|
|
63
|
+
syncRunDirectionEnum,
|
|
64
|
+
syncRunActionEnum,
|
|
65
|
+
syncRunStatusEnum,
|
|
66
|
+
syncRunItemOperationEnum,
|
|
67
|
+
syncRunItemStatusEnum,
|
|
68
|
+
} from './sync-audit.schema';
|
|
69
|
+
export type {
|
|
70
|
+
SyncSubscriptionRow,
|
|
71
|
+
SyncRunRow,
|
|
72
|
+
SyncRunItemRow,
|
|
73
|
+
} from './sync-audit.schema';
|
|
74
|
+
|
|
75
|
+
// Memory backends (SYNC-3, SYNC-6) — test doubles
|
|
76
|
+
export { MemoryCursorStore } from './sync-cursor-store.memory-backend';
|
|
77
|
+
export {
|
|
78
|
+
MemoryRunRecorder,
|
|
79
|
+
type MemoryRunRecord,
|
|
80
|
+
} from './sync-run-recorder.memory-backend';
|
|
81
|
+
|
|
82
|
+
// Runtime (SYNC-5) — orchestrator + default differ
|
|
83
|
+
export {
|
|
84
|
+
DeepEqualDiffer,
|
|
85
|
+
type DeepEqualDifferOptions,
|
|
86
|
+
} from './deep-equal.differ';
|
|
87
|
+
export {
|
|
88
|
+
ExecuteSyncUseCase,
|
|
89
|
+
type ExecuteSyncInput,
|
|
90
|
+
type ExecuteSyncResult,
|
|
91
|
+
} from './execute-sync.use-case';
|
|
92
|
+
|
|
93
|
+
// Drizzle backends (SYNC-4)
|
|
94
|
+
export { PostgresCursorStore } from './sync-cursor-store.drizzle-backend';
|
|
95
|
+
export { DrizzleSyncRunRecorder } from './sync-run-recorder.drizzle-backend';
|
|
96
|
+
|
|
97
|
+
// Module (SYNC-6)
|
|
98
|
+
export { SyncModule, type SyncModuleOptions } from './sync.module';
|