@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,300 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Drizzle schema for the sync subsystem audit/observability tables (SYNC-1).
|
|
3
|
+
*
|
|
4
|
+
* Three tables model end-to-end sync observability, keyed by the single port
|
|
5
|
+
* every sync adapter implements (`IChangeSource<T>` from SYNC-2):
|
|
6
|
+
*
|
|
7
|
+
* - `sync_subscriptions` — owns the cursor per
|
|
8
|
+
* `(integration_id, adapter, domain, external_ref)` tuple. Addressed
|
|
9
|
+
* by id by `ICursorStore` (SYNC-3/SYNC-4).
|
|
10
|
+
* - `sync_runs` — per-run audit log: start/complete, status,
|
|
11
|
+
* cursor before/after, counts, direction (inbound|outbound),
|
|
12
|
+
* action (poll|cdc|webhook|manual|writeback).
|
|
13
|
+
* - `sync_run_items` — per-record change log with structured
|
|
14
|
+
* `changed_fields` jsonb enforced by the Zod `FieldDiffSchema`
|
|
15
|
+
* contract (ADR-0003; protocol lives in SYNC-2's
|
|
16
|
+
* sync-field-diff.protocol.ts).
|
|
17
|
+
*
|
|
18
|
+
* Design calls (vs. issue #126 open questions):
|
|
19
|
+
*
|
|
20
|
+
* - `sync_subscriptions` ships in the subsystem (not consumer-owned).
|
|
21
|
+
* Rationale: SYNC-4's `PostgresCursorStore` needs to read/write this
|
|
22
|
+
* table directly; making it consumer-owned would require consumers to
|
|
23
|
+
* hand-wire a shape the backend already knows. The row is addressable
|
|
24
|
+
* by id and scoped by the uniqueness tuple; consumers can still
|
|
25
|
+
* query/list it freely. Same stance as `job_run` being subsystem-
|
|
26
|
+
* owned while remaining consumer-queryable.
|
|
27
|
+
*
|
|
28
|
+
* - `tenant_id` is always emitted on the three tables as nullable text.
|
|
29
|
+
* The `SYNC_MULTI_TENANT` DI flag (SYNC-6) is what enforces the
|
|
30
|
+
* non-null + cross-tenant-isolation contract at the service/orchestrator
|
|
31
|
+
* boundary. This mirrors JOB-1/JOB-8's final shape — runtime guard, not
|
|
32
|
+
* a scaffold-time conditional column. Keeps the schema file uniform
|
|
33
|
+
* across single-tenant and multi-tenant deployments.
|
|
34
|
+
*
|
|
35
|
+
* - `changed_fields` on `sync_run_items` is typed via the Zod-inferred
|
|
36
|
+
* `FieldDiff` shape from SYNC-2 (`{ [fieldName]: { from, to } }`). The
|
|
37
|
+
* recorder service (SYNC-5) validates every write against
|
|
38
|
+
* `FieldDiffSchema.parse` so consumers can rely on the shape.
|
|
39
|
+
*/
|
|
40
|
+
import {
|
|
41
|
+
pgEnum,
|
|
42
|
+
pgTable,
|
|
43
|
+
uuid,
|
|
44
|
+
text,
|
|
45
|
+
jsonb,
|
|
46
|
+
integer,
|
|
47
|
+
boolean,
|
|
48
|
+
timestamp,
|
|
49
|
+
index,
|
|
50
|
+
uniqueIndex,
|
|
51
|
+
} from 'drizzle-orm/pg-core';
|
|
52
|
+
import type { InferSelectModel } from 'drizzle-orm';
|
|
53
|
+
|
|
54
|
+
import type { FieldDiff } from './sync-field-diff.protocol';
|
|
55
|
+
|
|
56
|
+
// ─── Enums ──────────────────────────────────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Direction of a sync run relative to local state.
|
|
60
|
+
*
|
|
61
|
+
* - `inbound` — external → local (the common case: SFDC poll → local DB).
|
|
62
|
+
* - `outbound` — local → external (writeback; deferred per epic but the
|
|
63
|
+
* column shape is reserved so future writeback runs share the audit log).
|
|
64
|
+
*/
|
|
65
|
+
export const syncRunDirectionEnum = pgEnum('sync_run_direction', [
|
|
66
|
+
'inbound',
|
|
67
|
+
'outbound',
|
|
68
|
+
]);
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* How the run detected upstream changes. Maps 1:1 to the `Change.source`
|
|
72
|
+
* provenance on inbound runs; `manual` captures operator-triggered re-syncs
|
|
73
|
+
* and `writeback` captures outbound runs.
|
|
74
|
+
*/
|
|
75
|
+
export const syncRunActionEnum = pgEnum('sync_run_action', [
|
|
76
|
+
'poll',
|
|
77
|
+
'cdc',
|
|
78
|
+
'webhook',
|
|
79
|
+
'manual',
|
|
80
|
+
'writeback',
|
|
81
|
+
]);
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Lifecycle status of a sync run.
|
|
85
|
+
*
|
|
86
|
+
* - `running` — in-flight; recorder has started but not completed.
|
|
87
|
+
* - `success` — completed with at least one change processed.
|
|
88
|
+
* - `no_changes` — completed cleanly, no upstream changes found.
|
|
89
|
+
* - `failed` — errored before completion; `error` column carries the
|
|
90
|
+
* message. `records_processed` may be non-zero (partial progress).
|
|
91
|
+
*/
|
|
92
|
+
export const syncRunStatusEnum = pgEnum('sync_run_status', [
|
|
93
|
+
'running',
|
|
94
|
+
'success',
|
|
95
|
+
'no_changes',
|
|
96
|
+
'failed',
|
|
97
|
+
]);
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Operation applied per record. Mirrors `Change<T>.operation` from SYNC-2,
|
|
101
|
+
* plus the recorder's own `'noop'` for changes that matched existing state.
|
|
102
|
+
*/
|
|
103
|
+
export const syncRunItemOperationEnum = pgEnum('sync_run_item_operation', [
|
|
104
|
+
'created',
|
|
105
|
+
'updated',
|
|
106
|
+
'deleted',
|
|
107
|
+
'noop',
|
|
108
|
+
]);
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Per-record status within a run. `skipped` captures loopback-detected echoes
|
|
112
|
+
* of the local system's own writes (see `ILoopbackFingerprintStore` in the
|
|
113
|
+
* epic), which record the external_id but intentionally do not touch local
|
|
114
|
+
* state.
|
|
115
|
+
*/
|
|
116
|
+
export const syncRunItemStatusEnum = pgEnum('sync_run_item_status', [
|
|
117
|
+
'success',
|
|
118
|
+
'failed',
|
|
119
|
+
'skipped',
|
|
120
|
+
]);
|
|
121
|
+
|
|
122
|
+
// ─── sync_subscriptions ─────────────────────────────────────────────────────
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* One cursor owner per (integration, adapter, domain, external_ref).
|
|
126
|
+
*
|
|
127
|
+
* - `integration_id` — opaque id of the connected account/instance. E.g.
|
|
128
|
+
* the SFDC org id for polling strategies, the GitHub installation id
|
|
129
|
+
* for webhook strategies.
|
|
130
|
+
* - `adapter` — short adapter label, e.g. `'salesforce'`, `'hubspot'`.
|
|
131
|
+
* - `domain` — canonical entity domain this subscription tracks,
|
|
132
|
+
* e.g. `'opportunity'`, `'contact'`.
|
|
133
|
+
* - `external_ref` — optional upstream scope (e.g. a filter id, a
|
|
134
|
+
* webhook subscription id). NULL when the subscription covers the
|
|
135
|
+
* entire domain.
|
|
136
|
+
*
|
|
137
|
+
* The cursor shape is opaque jsonb — strategies type it internally (poll:
|
|
138
|
+
* `{ systemModstamp }`, cdc: `{ replayId }`, webhook: `{ ts }`). Overwritten
|
|
139
|
+
* by `ICursorStore.put(id, cursor)`.
|
|
140
|
+
*/
|
|
141
|
+
export const syncSubscriptions = pgTable(
|
|
142
|
+
'sync_subscriptions',
|
|
143
|
+
{
|
|
144
|
+
id: uuid('id').primaryKey().defaultRandom(),
|
|
145
|
+
integrationId: text('integration_id').notNull(),
|
|
146
|
+
adapter: text('adapter').notNull(),
|
|
147
|
+
domain: text('domain').notNull(),
|
|
148
|
+
externalRef: text('external_ref'),
|
|
149
|
+
enabled: boolean('enabled').notNull().default(true),
|
|
150
|
+
/**
|
|
151
|
+
* Per-subscription configuration bag. Strategies type it internally;
|
|
152
|
+
* e.g. polling strategies stash `{ batchSize, highWatermark }` here.
|
|
153
|
+
*/
|
|
154
|
+
config: jsonb('config').notNull().default({}).$type<Record<string, unknown>>(),
|
|
155
|
+
/**
|
|
156
|
+
* Opaque cursor persisted by `ICursorStore.put()`. NULL until the first
|
|
157
|
+
* successful run advances it.
|
|
158
|
+
*/
|
|
159
|
+
cursor: jsonb('cursor').$type<unknown>(),
|
|
160
|
+
lastSyncAt: timestamp('last_sync_at', { withTimezone: true }),
|
|
161
|
+
/** Runtime-enforced when `SYNC_MULTI_TENANT` is true; see SYNC-6. */
|
|
162
|
+
tenantId: text('tenant_id'),
|
|
163
|
+
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
|
164
|
+
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
|
165
|
+
},
|
|
166
|
+
(t) => ({
|
|
167
|
+
/**
|
|
168
|
+
* Composite uniqueness per the epic shape. `external_ref` is nullable;
|
|
169
|
+
* Postgres treats NULLs as distinct in a UNIQUE constraint, which means
|
|
170
|
+
* two rows with the same `(integration_id, adapter, domain)` and NULL
|
|
171
|
+
* external_ref are allowed. That's intentional — a subscription with
|
|
172
|
+
* NULL external_ref covers the full domain, and duplicates there would
|
|
173
|
+
* be a consumer-layer modeling issue, not a schema concern.
|
|
174
|
+
*/
|
|
175
|
+
uqSyncSubscriptionTuple: uniqueIndex('uq_sync_subscriptions_tuple').on(
|
|
176
|
+
t.integrationId,
|
|
177
|
+
t.adapter,
|
|
178
|
+
t.domain,
|
|
179
|
+
t.externalRef,
|
|
180
|
+
),
|
|
181
|
+
/** Scheduling query: list enabled subscriptions ordered by staleness. */
|
|
182
|
+
idxSyncSubscriptionsEnabledLastSync: index(
|
|
183
|
+
'idx_sync_subscriptions_enabled_last_sync',
|
|
184
|
+
).on(t.enabled, t.lastSyncAt),
|
|
185
|
+
}),
|
|
186
|
+
);
|
|
187
|
+
|
|
188
|
+
export type SyncSubscriptionRow = InferSelectModel<typeof syncSubscriptions>;
|
|
189
|
+
|
|
190
|
+
// ─── sync_runs ──────────────────────────────────────────────────────────────
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* One row per invocation of `ExecuteSyncUseCase`. `started_at` is set when
|
|
194
|
+
* the recorder opens the run; `completed_at`, `status`, `records_*`,
|
|
195
|
+
* `cursor_after`, and `duration_ms` are filled on completion.
|
|
196
|
+
*
|
|
197
|
+
* `cursor_before` / `cursor_after` carry the opaque cursor snapshots so the
|
|
198
|
+
* run log is fully self-describing — given a run id, an operator can reason
|
|
199
|
+
* about exactly what window was scanned without cross-referencing another
|
|
200
|
+
* table.
|
|
201
|
+
*/
|
|
202
|
+
export const syncRuns = pgTable(
|
|
203
|
+
'sync_runs',
|
|
204
|
+
{
|
|
205
|
+
id: uuid('id').primaryKey().defaultRandom(),
|
|
206
|
+
subscriptionId: uuid('subscription_id')
|
|
207
|
+
.notNull()
|
|
208
|
+
.references(() => syncSubscriptions.id, { onDelete: 'cascade' }),
|
|
209
|
+
direction: syncRunDirectionEnum('direction').notNull(),
|
|
210
|
+
action: syncRunActionEnum('action').notNull(),
|
|
211
|
+
status: syncRunStatusEnum('status').notNull().default('running'),
|
|
212
|
+
recordsFound: integer('records_found').notNull().default(0),
|
|
213
|
+
recordsProcessed: integer('records_processed').notNull().default(0),
|
|
214
|
+
cursorBefore: jsonb('cursor_before').$type<unknown>(),
|
|
215
|
+
cursorAfter: jsonb('cursor_after').$type<unknown>(),
|
|
216
|
+
durationMs: integer('duration_ms'),
|
|
217
|
+
error: text('error'),
|
|
218
|
+
startedAt: timestamp('started_at', { withTimezone: true })
|
|
219
|
+
.notNull()
|
|
220
|
+
.defaultNow(),
|
|
221
|
+
completedAt: timestamp('completed_at', { withTimezone: true }),
|
|
222
|
+
/** Runtime-enforced when `SYNC_MULTI_TENANT` is true; see SYNC-6. */
|
|
223
|
+
tenantId: text('tenant_id'),
|
|
224
|
+
},
|
|
225
|
+
(t) => ({
|
|
226
|
+
/** Timeline read: "most recent runs for this subscription". */
|
|
227
|
+
idxSyncRunsSubscriptionStartedAt: index(
|
|
228
|
+
'idx_sync_runs_subscription_started_at',
|
|
229
|
+
).on(t.subscriptionId, t.startedAt),
|
|
230
|
+
/** Stale-run sweeper: "runs that started > N minutes ago and are still running". */
|
|
231
|
+
idxSyncRunsStatusStartedAt: index('idx_sync_runs_status_started_at').on(
|
|
232
|
+
t.status,
|
|
233
|
+
t.startedAt,
|
|
234
|
+
),
|
|
235
|
+
}),
|
|
236
|
+
);
|
|
237
|
+
|
|
238
|
+
export type SyncRunRow = InferSelectModel<typeof syncRuns>;
|
|
239
|
+
|
|
240
|
+
// ─── sync_run_items ─────────────────────────────────────────────────────────
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* One row per upstream change processed within a run. Captures the canonical
|
|
244
|
+
* decision the orchestrator made (`operation` + `status`), the structured
|
|
245
|
+
* per-field diff (`changed_fields`, ADR-0003), and the local row id
|
|
246
|
+
* (`local_id`) for drill-down joins.
|
|
247
|
+
*
|
|
248
|
+
* `changed_fields` is validated at the recorder layer via `FieldDiffSchema`
|
|
249
|
+
* (SYNC-2) — the $type<FieldDiff> annotation here only documents the shape
|
|
250
|
+
* for Drizzle consumers. The runtime enforcement is non-negotiable: downstream
|
|
251
|
+
* drift-detection queries rely on the `{from, to}` shape per field.
|
|
252
|
+
*
|
|
253
|
+
* `title` is an optional human-readable label captured at write time (e.g.
|
|
254
|
+
* `"Pinnacle opportunity"`) so run-log UIs don't need to re-hydrate the
|
|
255
|
+
* canonical record.
|
|
256
|
+
*/
|
|
257
|
+
export const syncRunItems = pgTable(
|
|
258
|
+
'sync_run_items',
|
|
259
|
+
{
|
|
260
|
+
id: uuid('id').primaryKey().defaultRandom(),
|
|
261
|
+
syncRunId: uuid('sync_run_id')
|
|
262
|
+
.notNull()
|
|
263
|
+
.references(() => syncRuns.id, { onDelete: 'cascade' }),
|
|
264
|
+
entityType: text('entity_type').notNull(),
|
|
265
|
+
externalId: text('external_id').notNull(),
|
|
266
|
+
localId: text('local_id'),
|
|
267
|
+
operation: syncRunItemOperationEnum('operation').notNull(),
|
|
268
|
+
status: syncRunItemStatusEnum('status').notNull(),
|
|
269
|
+
/**
|
|
270
|
+
* Structured per-field diff — ADR-0003 shape enforced by
|
|
271
|
+
* `FieldDiffSchema.parse` at the recorder service layer.
|
|
272
|
+
*
|
|
273
|
+
* Shape: `{ [fieldName]: { from: unknown, to: unknown } }`.
|
|
274
|
+
* Empty `{}` for `noop` items; `{ [field]: { from: null, to: <value> } }`
|
|
275
|
+
* for created items; `{ [field]: { from: <value>, to: null } }` for
|
|
276
|
+
* deleted items.
|
|
277
|
+
*/
|
|
278
|
+
changedFields: jsonb('changed_fields').notNull().default({}).$type<FieldDiff>(),
|
|
279
|
+
title: text('title'),
|
|
280
|
+
error: text('error'),
|
|
281
|
+
createdAt: timestamp('created_at', { withTimezone: true })
|
|
282
|
+
.notNull()
|
|
283
|
+
.defaultNow(),
|
|
284
|
+
/** Runtime-enforced when `SYNC_MULTI_TENANT` is true; see SYNC-6. */
|
|
285
|
+
tenantId: text('tenant_id'),
|
|
286
|
+
},
|
|
287
|
+
(t) => ({
|
|
288
|
+
/** Ordered timeline within a run. */
|
|
289
|
+
idxSyncRunItemsRunCreatedAt: index('idx_sync_run_items_run_created_at').on(
|
|
290
|
+
t.syncRunId,
|
|
291
|
+
t.createdAt,
|
|
292
|
+
),
|
|
293
|
+
/** Per-record history: "every sync that touched opportunity/$extId". */
|
|
294
|
+
idxSyncRunItemsEntityExternal: index(
|
|
295
|
+
'idx_sync_run_items_entity_external',
|
|
296
|
+
).on(t.entityType, t.externalId),
|
|
297
|
+
}),
|
|
298
|
+
);
|
|
299
|
+
|
|
300
|
+
export type SyncRunItemRow = InferSelectModel<typeof syncRunItems>;
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sync subsystem — change-source protocol (port)
|
|
3
|
+
*
|
|
4
|
+
* `IChangeSource<T>` is the hexagonal port every sync adapter implements.
|
|
5
|
+
* Use cases inject this interface via `SYNC_CHANGE_SOURCE` token. They never
|
|
6
|
+
* depend on a specific backend implementation.
|
|
7
|
+
*
|
|
8
|
+
* Three detection modes (poll / cdc / webhook) converge on this single port
|
|
9
|
+
* per ADR-0002 (dealbrain-v2). Per-mode differences live in the
|
|
10
|
+
* `Change.source` / `dedupKey` / `providerChangedFields` metadata fields,
|
|
11
|
+
* not in separate ports.
|
|
12
|
+
*
|
|
13
|
+
* See epic #60 (parent) and upstream ADR-008 subsystem architecture.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
// ============================================================================
|
|
17
|
+
// Change provenance + shape
|
|
18
|
+
// ============================================================================
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Provenance of a change record. Maps 1:1 to `sync_runs.action` so run logs
|
|
22
|
+
* self-identify.
|
|
23
|
+
*/
|
|
24
|
+
export type ChangeSource = 'poll' | 'cdc' | 'webhook';
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* One upstream change, normalized.
|
|
28
|
+
*
|
|
29
|
+
* The adapter has already translated provider-specific record shape into a
|
|
30
|
+
* canonical T. Custom fields flow through the `fields` bag on `record` when
|
|
31
|
+
* T supports it (adapters attach it; the sink splits and routes).
|
|
32
|
+
*
|
|
33
|
+
* `dedupKey` — set by CDC (replay_id) and webhook (event_id) paths; absent
|
|
34
|
+
* for polling. Orchestrator uses it for idempotent re-delivery when present,
|
|
35
|
+
* falls back to fingerprint-comparison otherwise.
|
|
36
|
+
*
|
|
37
|
+
* `providerChangedFields` — CDC-only. Lets the differ skip deep-equals when
|
|
38
|
+
* the provider already told us which fields moved; falls back to computed
|
|
39
|
+
* diff when absent.
|
|
40
|
+
*
|
|
41
|
+
* `cursor` — opaque at this seam. Each strategy types it internally (poll:
|
|
42
|
+
* `{ systemModstamp }`, CDC: `{ replayId }`, webhook: `{ ts }`) and the
|
|
43
|
+
* orchestrator persists whatever the strategy last yielded.
|
|
44
|
+
*/
|
|
45
|
+
export interface Change<T> {
|
|
46
|
+
readonly externalId: string;
|
|
47
|
+
readonly operation: 'created' | 'updated' | 'deleted';
|
|
48
|
+
readonly record: T;
|
|
49
|
+
readonly cursor: unknown;
|
|
50
|
+
readonly source: ChangeSource;
|
|
51
|
+
readonly dedupKey?: string;
|
|
52
|
+
readonly providerChangedFields?: string[];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ============================================================================
|
|
56
|
+
// Subscription shape (structural — consumer owns the row)
|
|
57
|
+
// ============================================================================
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Minimal structural view of a sync-subscription row the port needs.
|
|
61
|
+
*
|
|
62
|
+
* The consumer owns the concrete `sync_subscriptions` table (schema lands in
|
|
63
|
+
* SYNC-1). This interface captures only the fields the port itself reads, so
|
|
64
|
+
* adapters can be typed without depending on the consumer's ORM row type.
|
|
65
|
+
*/
|
|
66
|
+
export interface SyncSubscriptionView {
|
|
67
|
+
/** Primary key — addresses the cursor in `ICursorStore`. */
|
|
68
|
+
readonly id: string;
|
|
69
|
+
/** Canonical entity domain, e.g. `'opportunity'`, `'contact'`. */
|
|
70
|
+
readonly domain: string;
|
|
71
|
+
/** Optional external reference — the upstream "scope" for this subscription. */
|
|
72
|
+
readonly externalRef?: string | null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ============================================================================
|
|
76
|
+
// IChangeSource
|
|
77
|
+
// ============================================================================
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The one port every sync adapter implements. Mode-specific concerns
|
|
81
|
+
* (scheduling, rate-limiting, ack contracts, credential refresh) stay in the
|
|
82
|
+
* strategy class that implements this interface — this seam is deliberately
|
|
83
|
+
* minimal.
|
|
84
|
+
*
|
|
85
|
+
* Strategies are per-provider per-mode per-entity — one concrete class per
|
|
86
|
+
* `(provider, detection-mode, canonical-entity)` tuple.
|
|
87
|
+
*/
|
|
88
|
+
export interface IChangeSource<T> {
|
|
89
|
+
/** Human label for run logs — e.g. `'salesforce-poll-opportunity'`. */
|
|
90
|
+
readonly label: string;
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Async-iterate upstream changes, newest cursor last. The orchestrator
|
|
94
|
+
* persists `change.cursor` as it advances; strategies MUST yield at least
|
|
95
|
+
* one change before the async iterable completes if anything changed
|
|
96
|
+
* upstream, otherwise cursor advance is a no-op.
|
|
97
|
+
*/
|
|
98
|
+
listChanges(subscription: SyncSubscriptionView): AsyncIterable<Change<T>>;
|
|
99
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PostgresCursorStore — Drizzle-backed `ICursorStore` (SYNC-4).
|
|
3
|
+
*
|
|
4
|
+
* Reads/writes `sync_subscriptions.cursor` directly — no service
|
|
5
|
+
* composition. Consumers that want a service layer around subscriptions
|
|
6
|
+
* wire it themselves; the port's contract is just cursor persistence.
|
|
7
|
+
*
|
|
8
|
+
* ## What `put` stamps
|
|
9
|
+
*
|
|
10
|
+
* `put` writes three columns in one statement: `cursor`, `last_sync_at`,
|
|
11
|
+
* and `updated_at`. Rationale: SYNC-1's scheduling index
|
|
12
|
+
* `(enabled, last_sync_at)` is useless if `last_sync_at` doesn't advance
|
|
13
|
+
* with every cursor put. Every real consumer needs this stamped, so
|
|
14
|
+
* bundling it here avoids every consumer wrapping the port in a service
|
|
15
|
+
* layer just to stamp a timestamp.
|
|
16
|
+
*
|
|
17
|
+
* ## Multi-tenancy
|
|
18
|
+
*
|
|
19
|
+
* When `SYNC_MULTI_TENANT` is true (SYNC-6):
|
|
20
|
+
* - every read/write is scoped by `AND tenant_id = $tenantId`
|
|
21
|
+
* - a null/missing `tenantId` throws `MissingTenantIdError` via the
|
|
22
|
+
* shared `assertTenantId` helper (one message shape across the
|
|
23
|
+
* orchestrator + both backends, SYNC-6)
|
|
24
|
+
* - explicit `null` also throws — matches JOB-8 / EVT-6 strict-enforcement
|
|
25
|
+
*
|
|
26
|
+
* When the flag is off, `tenantId` is ignored. Cross-tenant isolation is
|
|
27
|
+
* the caller's problem in single-tenant deployments.
|
|
28
|
+
*/
|
|
29
|
+
import { Inject, Injectable, Optional } from '@nestjs/common';
|
|
30
|
+
import { and, eq, type SQL } from 'drizzle-orm';
|
|
31
|
+
import type { DrizzleClient } from '../../types/drizzle';
|
|
32
|
+
import { DRIZZLE } from '../../constants/tokens';
|
|
33
|
+
import type { ICursorStore } from './sync-cursor-store.protocol';
|
|
34
|
+
import { syncSubscriptions } from './sync-audit.schema';
|
|
35
|
+
import { SYNC_MULTI_TENANT } from './sync.tokens';
|
|
36
|
+
import { assertTenantId } from './sync-errors';
|
|
37
|
+
|
|
38
|
+
@Injectable()
|
|
39
|
+
export class PostgresCursorStore implements ICursorStore {
|
|
40
|
+
private readonly multiTenant: boolean;
|
|
41
|
+
|
|
42
|
+
constructor(
|
|
43
|
+
@Inject(DRIZZLE) private readonly db: DrizzleClient,
|
|
44
|
+
@Optional() @Inject(SYNC_MULTI_TENANT) multiTenant?: boolean,
|
|
45
|
+
) {
|
|
46
|
+
this.multiTenant = multiTenant ?? false;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async get(
|
|
50
|
+
subscriptionId: string,
|
|
51
|
+
tenantId?: string | null,
|
|
52
|
+
): Promise<unknown | null> {
|
|
53
|
+
const where = this.buildWhere(subscriptionId, tenantId, 'cursor.get');
|
|
54
|
+
|
|
55
|
+
const rows = await this.db
|
|
56
|
+
.select({ cursor: syncSubscriptions.cursor })
|
|
57
|
+
.from(syncSubscriptions)
|
|
58
|
+
.where(where)
|
|
59
|
+
.limit(1);
|
|
60
|
+
|
|
61
|
+
if (rows.length === 0) return null;
|
|
62
|
+
return rows[0]?.cursor ?? null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async put(
|
|
66
|
+
subscriptionId: string,
|
|
67
|
+
cursor: unknown,
|
|
68
|
+
tenantId?: string | null,
|
|
69
|
+
): Promise<void> {
|
|
70
|
+
const where = this.buildWhere(subscriptionId, tenantId, 'cursor.put');
|
|
71
|
+
|
|
72
|
+
await this.db
|
|
73
|
+
.update(syncSubscriptions)
|
|
74
|
+
.set({
|
|
75
|
+
cursor,
|
|
76
|
+
lastSyncAt: new Date(),
|
|
77
|
+
updatedAt: new Date(),
|
|
78
|
+
})
|
|
79
|
+
.where(where);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Centralized WHERE clause — `get` and `put` share identical semantics.
|
|
84
|
+
* Drift here would let a caller read under one tenancy rule and write
|
|
85
|
+
* under another.
|
|
86
|
+
*/
|
|
87
|
+
private buildWhere(
|
|
88
|
+
subscriptionId: string,
|
|
89
|
+
tenantId: string | null | undefined,
|
|
90
|
+
operation: string,
|
|
91
|
+
): SQL | undefined {
|
|
92
|
+
assertTenantId(tenantId, {
|
|
93
|
+
multiTenant: this.multiTenant,
|
|
94
|
+
operation,
|
|
95
|
+
});
|
|
96
|
+
if (this.multiTenant) {
|
|
97
|
+
return and(
|
|
98
|
+
eq(syncSubscriptions.id, subscriptionId),
|
|
99
|
+
eq(syncSubscriptions.tenantId, tenantId as string),
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
return eq(syncSubscriptions.id, subscriptionId);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MemoryCursorStore — in-memory backend for `ICursorStore` (SYNC-3).
|
|
3
|
+
*
|
|
4
|
+
* Test double that lets consumers exercise `ExecuteSyncUseCase` (SYNC-5) and
|
|
5
|
+
* other cursor-consuming code paths without Postgres. Mirrors the role of
|
|
6
|
+
* `MemoryEventBus` and `MemoryJobStore`: plain keyed state, tests take a
|
|
7
|
+
* direct reference for `beforeEach` resets.
|
|
8
|
+
*
|
|
9
|
+
* Cursor values are stored by reference — the port's `get`/`put` contract
|
|
10
|
+
* treats them as opaque `unknown`. Callers that want durable value-equality
|
|
11
|
+
* semantics should snapshot via JSON before `put` and reparse after `get`;
|
|
12
|
+
* this is what the Drizzle backend (SYNC-4) does implicitly via jsonb
|
|
13
|
+
* round-trip. The memory backend intentionally does not simulate the
|
|
14
|
+
* serialize/deserialize cycle — consumers who care should test against
|
|
15
|
+
* Postgres.
|
|
16
|
+
*
|
|
17
|
+
* ## Multi-tenancy
|
|
18
|
+
*
|
|
19
|
+
* `tenantId` is accepted but ignored. The memory backend's state is
|
|
20
|
+
* process-local — there's no durable storage where a cross-tenant leak
|
|
21
|
+
* could occur. Tests that want to assert per-tenant isolation should
|
|
22
|
+
* target the Drizzle backend.
|
|
23
|
+
*
|
|
24
|
+
* Not shipped in dealbrain-v2; this is a subsystem-first addition for the
|
|
25
|
+
* test surface. Consumed by:
|
|
26
|
+
* - SYNC-5 unit tests (`ExecuteSyncUseCase` against synthetic sources)
|
|
27
|
+
* - SYNC-6 module tests (`SyncModule.forRoot({ backend: 'memory' })`)
|
|
28
|
+
*/
|
|
29
|
+
import { Injectable } from '@nestjs/common';
|
|
30
|
+
import type { ICursorStore } from './sync-cursor-store.protocol';
|
|
31
|
+
|
|
32
|
+
@Injectable()
|
|
33
|
+
export class MemoryCursorStore implements ICursorStore {
|
|
34
|
+
/**
|
|
35
|
+
* Subscription-id → last persisted cursor. Public so tests can inspect
|
|
36
|
+
* or pre-seed state; production callers MUST go through `get`/`put`.
|
|
37
|
+
*/
|
|
38
|
+
readonly cursors: Map<string, unknown> = new Map();
|
|
39
|
+
|
|
40
|
+
async get(
|
|
41
|
+
subscriptionId: string,
|
|
42
|
+
_tenantId?: string | null,
|
|
43
|
+
): Promise<unknown | null> {
|
|
44
|
+
// `Map.get` returns `undefined` for missing keys; the port contract
|
|
45
|
+
// returns `null`. Normalize here so callers can `=== null`-check.
|
|
46
|
+
const value = this.cursors.get(subscriptionId);
|
|
47
|
+
return value === undefined ? null : value;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async put(
|
|
51
|
+
subscriptionId: string,
|
|
52
|
+
cursor: unknown,
|
|
53
|
+
_tenantId?: string | null,
|
|
54
|
+
): Promise<void> {
|
|
55
|
+
// Overwrite semantics — matches the port contract and the Drizzle
|
|
56
|
+
// backend's `ON CONFLICT DO UPDATE` behavior.
|
|
57
|
+
this.cursors.set(subscriptionId, cursor);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Reset state. Tests call this in `beforeEach`. */
|
|
61
|
+
clear(): void {
|
|
62
|
+
this.cursors.clear();
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sync subsystem — cursor-store protocol (port)
|
|
3
|
+
*
|
|
4
|
+
* Subscription-addressed cursor persistence. The subscription row IS the
|
|
5
|
+
* cursor owner — addressable by id, scoped by
|
|
6
|
+
* `(integration, adapter, domain, external_ref)` at the subscription level.
|
|
7
|
+
*
|
|
8
|
+
* Cursor shape is opaque at this seam; strategies type it internally
|
|
9
|
+
* (polling: `{ systemModstamp }`, CDC: `{ replayId }`, webhook: `{ ts }`).
|
|
10
|
+
* The Drizzle backend stores this as `sync_subscriptions.cursor` jsonb.
|
|
11
|
+
*
|
|
12
|
+
* ## Multi-tenancy (SYNC-4)
|
|
13
|
+
*
|
|
14
|
+
* Both methods accept an optional `tenantId`. When `SYNC_MULTI_TENANT` is
|
|
15
|
+
* enabled (SYNC-6), the Drizzle backend MUST scope every read/write by
|
|
16
|
+
* `tenant_id`, and a `null`/missing value throws `MissingTenantIdError` at
|
|
17
|
+
* the module boundary. When the flag is off, `tenantId` is ignored.
|
|
18
|
+
*
|
|
19
|
+
* The in-memory backend ignores `tenantId` unconditionally — its state is
|
|
20
|
+
* process-local; cross-tenant isolation there is not meaningful.
|
|
21
|
+
*
|
|
22
|
+
* Why a signature change instead of a tenant-proxy wrapper: multi-tenant
|
|
23
|
+
* correctness bugs are silent and dangerous (cross-tenant cursor tampering).
|
|
24
|
+
* An explicit signature catches omissions at the type boundary; proxies
|
|
25
|
+
* hide who's enforcing. Matches JOB-8 / EVT-6 precedent — tenant ids flow
|
|
26
|
+
* through input shapes, not through wrapper layers.
|
|
27
|
+
*/
|
|
28
|
+
export interface ICursorStore {
|
|
29
|
+
/**
|
|
30
|
+
* Return the last persisted cursor for `subscriptionId`, or `null`.
|
|
31
|
+
*
|
|
32
|
+
* @param tenantId required when `SYNC_MULTI_TENANT` is on (backend
|
|
33
|
+
* scopes the SELECT by tenant); ignored otherwise.
|
|
34
|
+
*/
|
|
35
|
+
get(subscriptionId: string, tenantId?: string | null): Promise<unknown | null>;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Persist `cursor` for `subscriptionId`. Overwrites.
|
|
39
|
+
*
|
|
40
|
+
* The Drizzle backend also stamps `last_sync_at` + `updated_at` on the
|
|
41
|
+
* same row so the scheduling index `(enabled, last_sync_at)` stays
|
|
42
|
+
* accurate without consumers wrapping the port. The memory backend
|
|
43
|
+
* ignores timestamps.
|
|
44
|
+
*
|
|
45
|
+
* @param tenantId required when `SYNC_MULTI_TENANT` is on (backend
|
|
46
|
+
* scopes the UPDATE by tenant); ignored otherwise.
|
|
47
|
+
*/
|
|
48
|
+
put(
|
|
49
|
+
subscriptionId: string,
|
|
50
|
+
cursor: unknown,
|
|
51
|
+
tenantId?: string | null,
|
|
52
|
+
): Promise<void>;
|
|
53
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typed errors + shared boundary helpers for the sync subsystem.
|
|
3
|
+
*
|
|
4
|
+
* Classes (not bare Error) so consumers can `instanceof` them in catch
|
|
5
|
+
* blocks and exception filters can map them to HTTP codes.
|
|
6
|
+
*
|
|
7
|
+
* Mirrors the shape of `events-errors.ts` and `jobs-errors.ts`.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Thrown by the Drizzle cursor-store / run-recorder backends AND by the
|
|
12
|
+
* orchestrator entry point when `SYNC_MULTI_TENANT` is enabled but the
|
|
13
|
+
* caller did not supply a non-null `tenantId`. Strict enforcement at the
|
|
14
|
+
* boundary — explicit `null` still throws.
|
|
15
|
+
*
|
|
16
|
+
* Disable multi-tenancy on the module (`multiTenant: false`, the default)
|
|
17
|
+
* to opt out of the requirement entirely.
|
|
18
|
+
*
|
|
19
|
+
* `operation` identifies the call site (e.g. `'cursor.put'`,
|
|
20
|
+
* `'startRun'`, `'execute'`) so the stack-trace message points at the
|
|
21
|
+
* specific boundary that rejected the input.
|
|
22
|
+
*/
|
|
23
|
+
export class MissingTenantIdError extends Error {
|
|
24
|
+
override readonly name = 'MissingTenantIdError';
|
|
25
|
+
constructor(operation: string) {
|
|
26
|
+
super(
|
|
27
|
+
`Missing tenantId for sync operation '${operation}'. SyncModule is ` +
|
|
28
|
+
`configured with multiTenant: true — every call must include a ` +
|
|
29
|
+
`non-null tenantId. Either pass the tenantId or disable multi-` +
|
|
30
|
+
`tenancy on the module.`,
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Shared boundary guard — used at the orchestrator entry AND inside the
|
|
37
|
+
* Drizzle backends. Keeping the check in one function guarantees every
|
|
38
|
+
* `MissingTenantIdError` carries the same message shape regardless of the
|
|
39
|
+
* site that raised it, which makes it easier for consumers to pattern-
|
|
40
|
+
* match on the error in logs/metrics.
|
|
41
|
+
*
|
|
42
|
+
* When `multiTenant` is false, the function is a no-op — `tenantId` may
|
|
43
|
+
* be anything (including `undefined`). When true, `undefined` or `null`
|
|
44
|
+
* throws.
|
|
45
|
+
*/
|
|
46
|
+
export function assertTenantId(
|
|
47
|
+
tenantId: string | null | undefined,
|
|
48
|
+
options: { multiTenant: boolean; operation: string },
|
|
49
|
+
): asserts tenantId is string {
|
|
50
|
+
if (!options.multiTenant) return;
|
|
51
|
+
if (tenantId === undefined || tenantId === null) {
|
|
52
|
+
throw new MissingTenantIdError(options.operation);
|
|
53
|
+
}
|
|
54
|
+
}
|