@pattern-stack/codegen 0.4.1 → 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.
Files changed (133) hide show
  1. package/package.json +2 -1
  2. package/runtime/analytics/index.ts +31 -0
  3. package/runtime/analytics/metrics.ts +85 -0
  4. package/runtime/analytics/packs/crm-entity-measures.ts +20 -0
  5. package/runtime/analytics/packs/index.ts +5 -0
  6. package/runtime/analytics/packs/monetary-measures.ts +20 -0
  7. package/runtime/analytics/specs.ts +54 -0
  8. package/runtime/analytics/types.ts +105 -0
  9. package/runtime/base-classes/activity-entity-repository.ts +50 -0
  10. package/runtime/base-classes/activity-entity-service.ts +48 -0
  11. package/runtime/base-classes/base-read-use-cases.ts +88 -0
  12. package/runtime/base-classes/base-repository.ts +289 -0
  13. package/runtime/base-classes/base-service.ts +183 -0
  14. package/runtime/base-classes/index.ts +38 -0
  15. package/runtime/base-classes/knowledge-entity-repository.ts +12 -0
  16. package/runtime/base-classes/knowledge-entity-service.ts +14 -0
  17. package/runtime/base-classes/lifecycle-events.ts +152 -0
  18. package/runtime/base-classes/metadata-entity-repository.ts +80 -0
  19. package/runtime/base-classes/metadata-entity-service.ts +48 -0
  20. package/runtime/base-classes/synced-entity-repository.ts +57 -0
  21. package/runtime/base-classes/synced-entity-service.ts +50 -0
  22. package/runtime/base-classes/with-analytics.ts +22 -0
  23. package/runtime/constants/tokens.ts +29 -0
  24. package/runtime/eav-helpers.ts +74 -0
  25. package/runtime/pipes/zod-validation.pipe.ts +64 -0
  26. package/runtime/shared/openapi/error-response.dto.ts +24 -0
  27. package/runtime/shared/openapi/errors.ts +39 -0
  28. package/runtime/shared/openapi/index.ts +20 -0
  29. package/runtime/shared/openapi/registry.tokens.ts +13 -0
  30. package/runtime/shared/openapi/registry.ts +151 -0
  31. package/runtime/subsystems/analytics/analytics-query.protocol.ts +37 -0
  32. package/runtime/subsystems/analytics/analytics.module.ts +64 -0
  33. package/runtime/subsystems/analytics/analytics.tokens.ts +24 -0
  34. package/runtime/subsystems/analytics/cube-backend.ts +75 -0
  35. package/runtime/subsystems/analytics/index.ts +15 -0
  36. package/runtime/subsystems/analytics/noop-backend.ts +27 -0
  37. package/runtime/subsystems/auth/auth.module.ts +91 -0
  38. package/runtime/subsystems/auth/auth.tokens.ts +27 -0
  39. package/runtime/subsystems/auth/backends/encryption-key/env.ts +76 -0
  40. package/runtime/subsystems/auth/backends/oauth-state-store/in-memory.ts +42 -0
  41. package/runtime/subsystems/auth/index.ts +77 -0
  42. package/runtime/subsystems/auth/protocols/auth-strategy.ts +46 -0
  43. package/runtime/subsystems/auth/protocols/encryption-key.ts +21 -0
  44. package/runtime/subsystems/auth/protocols/integration-store.ts +66 -0
  45. package/runtime/subsystems/auth/protocols/oauth-state-store.ts +16 -0
  46. package/runtime/subsystems/auth/runtime/integration-broken.error.ts +21 -0
  47. package/runtime/subsystems/auth/runtime/oauth2-refresh.strategy.ts +189 -0
  48. package/runtime/subsystems/auth/runtime/session-expired.error.ts +39 -0
  49. package/runtime/subsystems/auth/runtime/with-auth-retry.ts +50 -0
  50. package/runtime/subsystems/bridge/assert-tenant-id.ts +57 -0
  51. package/runtime/subsystems/bridge/bridge-delivery-handler.ts +220 -0
  52. package/runtime/subsystems/bridge/bridge-delivery.drizzle-backend.ts +149 -0
  53. package/runtime/subsystems/bridge/bridge-delivery.memory-backend.ts +140 -0
  54. package/runtime/subsystems/bridge/bridge-delivery.schema.ts +142 -0
  55. package/runtime/subsystems/bridge/bridge-errors.ts +112 -0
  56. package/runtime/subsystems/bridge/bridge-outbox-drain-hook.ts +175 -0
  57. package/runtime/subsystems/bridge/bridge.module.ts +160 -0
  58. package/runtime/subsystems/bridge/bridge.protocol.ts +351 -0
  59. package/runtime/subsystems/bridge/bridge.tokens.ts +68 -0
  60. package/runtime/subsystems/bridge/event-flow.service.ts +175 -0
  61. package/runtime/subsystems/bridge/generated/.gitkeep +0 -0
  62. package/runtime/subsystems/bridge/generated/registry.ts +6 -0
  63. package/runtime/subsystems/bridge/index.ts +84 -0
  64. package/runtime/subsystems/bridge/reserved-pools.ts +36 -0
  65. package/runtime/subsystems/cache/cache.drizzle-backend.ts +150 -0
  66. package/runtime/subsystems/cache/cache.memory-backend.ts +116 -0
  67. package/runtime/subsystems/cache/cache.module.ts +115 -0
  68. package/runtime/subsystems/cache/cache.protocol.ts +45 -0
  69. package/runtime/subsystems/cache/cache.schema.ts +27 -0
  70. package/runtime/subsystems/cache/cache.tokens.ts +17 -0
  71. package/runtime/subsystems/cache/index.ts +22 -0
  72. package/runtime/subsystems/events/domain-events.schema.ts +77 -0
  73. package/runtime/subsystems/events/event-bus.drizzle-backend.ts +327 -0
  74. package/runtime/subsystems/events/event-bus.memory-backend.ts +142 -0
  75. package/runtime/subsystems/events/event-bus.protocol.ts +86 -0
  76. package/runtime/subsystems/events/event-bus.redis-backend.ts +304 -0
  77. package/runtime/subsystems/events/events-errors.ts +30 -0
  78. package/runtime/subsystems/events/events.module.ts +230 -0
  79. package/runtime/subsystems/events/events.tokens.ts +62 -0
  80. package/runtime/subsystems/events/generated/bus.ts +103 -0
  81. package/runtime/subsystems/events/generated/index.ts +7 -0
  82. package/runtime/subsystems/events/generated/registry.ts +84 -0
  83. package/runtime/subsystems/events/generated/schemas.ts +59 -0
  84. package/runtime/subsystems/events/generated/types.ts +94 -0
  85. package/runtime/subsystems/events/index.ts +21 -0
  86. package/runtime/subsystems/index.ts +63 -0
  87. package/runtime/subsystems/jobs/generated/job-orchestration.schema.multi-tenant.ts +217 -0
  88. package/runtime/subsystems/jobs/generated/job-orchestration.schema.single-tenant.ts +217 -0
  89. package/runtime/subsystems/jobs/generated/scope-entity-type.ts +10 -0
  90. package/runtime/subsystems/jobs/index.ts +120 -0
  91. package/runtime/subsystems/jobs/job-handler.base.ts +206 -0
  92. package/runtime/subsystems/jobs/job-orchestration.schema.ts +217 -0
  93. package/runtime/subsystems/jobs/job-orchestrator.drizzle-backend.ts +536 -0
  94. package/runtime/subsystems/jobs/job-orchestrator.memory-backend.ts +850 -0
  95. package/runtime/subsystems/jobs/job-orchestrator.protocol.ts +179 -0
  96. package/runtime/subsystems/jobs/job-run-service.drizzle-backend.ts +171 -0
  97. package/runtime/subsystems/jobs/job-run-service.memory-backend.ts +165 -0
  98. package/runtime/subsystems/jobs/job-run-service.protocol.ts +79 -0
  99. package/runtime/subsystems/jobs/job-step-service.drizzle-backend.ts +66 -0
  100. package/runtime/subsystems/jobs/job-step-service.memory-backend.ts +119 -0
  101. package/runtime/subsystems/jobs/job-step-service.protocol.ts +53 -0
  102. package/runtime/subsystems/jobs/job-worker.module.ts +302 -0
  103. package/runtime/subsystems/jobs/job-worker.ts +615 -0
  104. package/runtime/subsystems/jobs/jobs-domain.module.ts +119 -0
  105. package/runtime/subsystems/jobs/jobs-domain.tokens.ts +30 -0
  106. package/runtime/subsystems/jobs/jobs-errors.ts +150 -0
  107. package/runtime/subsystems/jobs/memory-job-store.ts +35 -0
  108. package/runtime/subsystems/jobs/pool-config.loader.ts +218 -0
  109. package/runtime/subsystems/storage/index.ts +18 -0
  110. package/runtime/subsystems/storage/storage.local-backend.ts +113 -0
  111. package/runtime/subsystems/storage/storage.memory-backend.ts +78 -0
  112. package/runtime/subsystems/storage/storage.module.ts +60 -0
  113. package/runtime/subsystems/storage/storage.protocol.ts +78 -0
  114. package/runtime/subsystems/storage/storage.tokens.ts +9 -0
  115. package/runtime/subsystems/storage/storage.utils.ts +20 -0
  116. package/runtime/subsystems/sync/deep-equal.differ.ts +198 -0
  117. package/runtime/subsystems/sync/execute-sync.use-case.ts +334 -0
  118. package/runtime/subsystems/sync/index.ts +98 -0
  119. package/runtime/subsystems/sync/sync-audit.schema.ts +300 -0
  120. package/runtime/subsystems/sync/sync-change-source.protocol.ts +99 -0
  121. package/runtime/subsystems/sync/sync-cursor-store.drizzle-backend.ts +104 -0
  122. package/runtime/subsystems/sync/sync-cursor-store.memory-backend.ts +64 -0
  123. package/runtime/subsystems/sync/sync-cursor-store.protocol.ts +53 -0
  124. package/runtime/subsystems/sync/sync-errors.ts +54 -0
  125. package/runtime/subsystems/sync/sync-field-diff.protocol.ts +61 -0
  126. package/runtime/subsystems/sync/sync-loopback.protocol.ts +33 -0
  127. package/runtime/subsystems/sync/sync-run-recorder.drizzle-backend.ts +123 -0
  128. package/runtime/subsystems/sync/sync-run-recorder.memory-backend.ts +143 -0
  129. package/runtime/subsystems/sync/sync-run-recorder.protocol.ts +86 -0
  130. package/runtime/subsystems/sync/sync-sink.protocol.ts +55 -0
  131. package/runtime/subsystems/sync/sync.module.ts +156 -0
  132. package/runtime/subsystems/sync/sync.tokens.ts +57 -0
  133. 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
+ }