@pattern-stack/codegen 0.16.0 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (112) hide show
  1. package/CHANGELOG.md +93 -0
  2. package/consumer-skills/entities/families-and-queries.md +5 -3
  3. package/consumer-skills/integration/change-sources-and-sinks.md +1 -1
  4. package/dist/{chunk-3RWMQC3K.js → chunk-3MAZ4TQH.js} +12 -12
  5. package/dist/{chunk-VNBC3VXM.js → chunk-3VEVGL74.js} +4 -4
  6. package/dist/{chunk-WWGYCIJX.js → chunk-43SBT72G.js} +2 -2
  7. package/dist/{chunk-Y7GDG744.js → chunk-4GLNY5V6.js} +5 -5
  8. package/dist/{chunk-BK5ICA2F.js → chunk-4MVGAMUA.js} +4 -4
  9. package/dist/{chunk-3NMCDN7L.js → chunk-5TK7MEN4.js} +2 -2
  10. package/dist/chunk-5TK7MEN4.js.map +1 -0
  11. package/dist/{chunk-T6SCOJF4.js → chunk-7LKAMLV4.js} +4 -4
  12. package/dist/{chunk-BHZP6LOV.js → chunk-CDLWYZVQ.js} +7 -7
  13. package/dist/{chunk-DKKFTHHI.js → chunk-CZQUOIDY.js} +4 -4
  14. package/dist/{chunk-XWBK3XJK.js → chunk-DCCZB4UC.js} +4 -4
  15. package/dist/{chunk-EBKVKN75.js → chunk-DTXH24LR.js} +2 -2
  16. package/dist/{chunk-RUYLXR5F.js → chunk-GJDEPTPY.js} +10 -10
  17. package/dist/{chunk-32DOFN3T.js → chunk-IOQMMH6C.js} +17 -7
  18. package/dist/{chunk-32DOFN3T.js.map → chunk-IOQMMH6C.js.map} +1 -1
  19. package/dist/{chunk-BOPZWRJK.js → chunk-JYBFPNBJ.js} +8 -8
  20. package/dist/chunk-JYBFPNBJ.js.map +1 -0
  21. package/dist/{chunk-KSTZIULO.js → chunk-K2I6XIK5.js} +4 -4
  22. package/dist/{chunk-CEWLVVAH.js → chunk-L3VJ47BU.js} +5 -5
  23. package/dist/chunk-MKWQKKK7.js +72 -0
  24. package/dist/chunk-MKWQKKK7.js.map +1 -0
  25. package/dist/{chunk-DRCLNYH7.js → chunk-NXNVTXKG.js} +4 -4
  26. package/dist/{chunk-TDEHU73T.js → chunk-OGIZXGPY.js} +4 -4
  27. package/dist/{chunk-XDIIVIIK.js → chunk-OITTYGJS.js} +4 -4
  28. package/dist/{chunk-24WXSC3C.js → chunk-P3AYBRP6.js} +7 -7
  29. package/dist/{chunk-EJBK7I4F.js → chunk-RHYNACZS.js} +3 -3
  30. package/dist/{chunk-YK5JEVLX.js → chunk-SR7F3TJY.js} +4 -4
  31. package/dist/{chunk-YLPAPPLW.js → chunk-TIZXQU26.js} +36 -9
  32. package/dist/chunk-TIZXQU26.js.map +1 -0
  33. package/dist/{chunk-4PFF3ED4.js → chunk-UTNWFHJF.js} +4 -4
  34. package/dist/{chunk-LQ6PYFU6.js → chunk-Z7PQCAVK.js} +4 -4
  35. package/dist/runtime/base-classes/activity-entity-repository.d.ts +39 -7
  36. package/dist/runtime/base-classes/activity-entity-repository.js +1 -1
  37. package/dist/runtime/base-classes/activity-entity-service.d.ts +12 -10
  38. package/dist/runtime/base-classes/activity-entity-service.js +1 -1
  39. package/dist/runtime/base-classes/index.js +23 -23
  40. package/dist/runtime/shared/openapi/index.js +7 -7
  41. package/dist/runtime/shared/openapi/registry.js +2 -2
  42. package/dist/runtime/subsystems/analytics/analytics.module.js +2 -2
  43. package/dist/runtime/subsystems/analytics/index.js +4 -4
  44. package/dist/runtime/subsystems/auth/auth.module.js +3 -3
  45. package/dist/runtime/subsystems/auth/index.js +7 -7
  46. package/dist/runtime/subsystems/bridge/bridge-delivery-handler.js +2 -2
  47. package/dist/runtime/subsystems/bridge/bridge-delivery.drizzle-backend.js +2 -2
  48. package/dist/runtime/subsystems/bridge/bridge-outbox-drain-hook.js +4 -4
  49. package/dist/runtime/subsystems/bridge/bridge.module.js +15 -15
  50. package/dist/runtime/subsystems/bridge/index.js +17 -17
  51. package/dist/runtime/subsystems/cache/cache.module.js +1 -1
  52. package/dist/runtime/subsystems/cache/index.js +3 -3
  53. package/dist/runtime/subsystems/events/event-bus.drizzle-backend.js +2 -2
  54. package/dist/runtime/subsystems/events/event-bus.memory-backend.js +2 -2
  55. package/dist/runtime/subsystems/events/events.module.js +4 -4
  56. package/dist/runtime/subsystems/events/index.js +4 -4
  57. package/dist/runtime/subsystems/index.js +107 -107
  58. package/dist/runtime/subsystems/integration/build-change-source.js +2 -2
  59. package/dist/runtime/subsystems/integration/detection-config.schema.d.ts +23 -15
  60. package/dist/runtime/subsystems/integration/detection-config.schema.js +1 -1
  61. package/dist/runtime/subsystems/integration/execute-integration.use-case.js +2 -2
  62. package/dist/runtime/subsystems/integration/index.js +43 -43
  63. package/dist/runtime/subsystems/integration/integration-cursor-store.drizzle-backend.js +2 -2
  64. package/dist/runtime/subsystems/integration/integration-run-recorder.drizzle-backend.js +2 -2
  65. package/dist/runtime/subsystems/integration/integration.module.js +5 -5
  66. package/dist/runtime/subsystems/integration/webhook-change-source.d.ts +36 -6
  67. package/dist/runtime/subsystems/integration/webhook-change-source.js +1 -1
  68. package/dist/runtime/subsystems/jobs/index.js +30 -30
  69. package/dist/runtime/subsystems/jobs/job-orchestrator.bullmq-backend.js +4 -4
  70. package/dist/runtime/subsystems/jobs/job-orchestrator.memory-backend.js +1 -1
  71. package/dist/runtime/subsystems/jobs/job-run-service.drizzle-backend.js +2 -2
  72. package/dist/runtime/subsystems/jobs/job-run-service.memory-backend.js +2 -2
  73. package/dist/runtime/subsystems/jobs/job-worker.js +2 -2
  74. package/dist/runtime/subsystems/jobs/job-worker.module.js +10 -10
  75. package/dist/runtime/subsystems/jobs/jobs-domain.module.js +8 -8
  76. package/dist/runtime/subsystems/storage/index.js +4 -4
  77. package/dist/runtime/subsystems/storage/storage.module.js +2 -2
  78. package/dist/src/cli/index.js +15 -15
  79. package/dist/src/index.d.ts +34 -19
  80. package/dist/src/index.js +14 -14
  81. package/package.json +2 -1
  82. package/runtime/base-classes/activity-entity-repository.ts +72 -13
  83. package/runtime/base-classes/activity-entity-service.ts +14 -12
  84. package/runtime/subsystems/integration/detection-config.schema.ts +64 -54
  85. package/runtime/subsystems/integration/webhook-change-source.ts +187 -133
  86. package/src/patterns/library/activity.pattern.ts +40 -10
  87. package/dist/chunk-3NMCDN7L.js.map +0 -1
  88. package/dist/chunk-BOPZWRJK.js.map +0 -1
  89. package/dist/chunk-XCEI7NUH.js +0 -41
  90. package/dist/chunk-XCEI7NUH.js.map +0 -1
  91. package/dist/chunk-YLPAPPLW.js.map +0 -1
  92. /package/dist/{chunk-3RWMQC3K.js.map → chunk-3MAZ4TQH.js.map} +0 -0
  93. /package/dist/{chunk-VNBC3VXM.js.map → chunk-3VEVGL74.js.map} +0 -0
  94. /package/dist/{chunk-WWGYCIJX.js.map → chunk-43SBT72G.js.map} +0 -0
  95. /package/dist/{chunk-Y7GDG744.js.map → chunk-4GLNY5V6.js.map} +0 -0
  96. /package/dist/{chunk-BK5ICA2F.js.map → chunk-4MVGAMUA.js.map} +0 -0
  97. /package/dist/{chunk-T6SCOJF4.js.map → chunk-7LKAMLV4.js.map} +0 -0
  98. /package/dist/{chunk-BHZP6LOV.js.map → chunk-CDLWYZVQ.js.map} +0 -0
  99. /package/dist/{chunk-DKKFTHHI.js.map → chunk-CZQUOIDY.js.map} +0 -0
  100. /package/dist/{chunk-XWBK3XJK.js.map → chunk-DCCZB4UC.js.map} +0 -0
  101. /package/dist/{chunk-EBKVKN75.js.map → chunk-DTXH24LR.js.map} +0 -0
  102. /package/dist/{chunk-RUYLXR5F.js.map → chunk-GJDEPTPY.js.map} +0 -0
  103. /package/dist/{chunk-KSTZIULO.js.map → chunk-K2I6XIK5.js.map} +0 -0
  104. /package/dist/{chunk-CEWLVVAH.js.map → chunk-L3VJ47BU.js.map} +0 -0
  105. /package/dist/{chunk-DRCLNYH7.js.map → chunk-NXNVTXKG.js.map} +0 -0
  106. /package/dist/{chunk-TDEHU73T.js.map → chunk-OGIZXGPY.js.map} +0 -0
  107. /package/dist/{chunk-XDIIVIIK.js.map → chunk-OITTYGJS.js.map} +0 -0
  108. /package/dist/{chunk-24WXSC3C.js.map → chunk-P3AYBRP6.js.map} +0 -0
  109. /package/dist/{chunk-EJBK7I4F.js.map → chunk-RHYNACZS.js.map} +0 -0
  110. /package/dist/{chunk-YK5JEVLX.js.map → chunk-SR7F3TJY.js.map} +0 -0
  111. /package/dist/{chunk-4PFF3ED4.js.map → chunk-UTNWFHJF.js.map} +0 -0
  112. /package/dist/{chunk-LQ6PYFU6.js.map → chunk-Z7PQCAVK.js.map} +0 -0
@@ -1,17 +1,19 @@
1
1
  /**
2
2
  * ActivityEntityService<TRepo, TEntity>
3
3
  *
4
- * Family-specific base service for activity entities.
5
- * Delegates to an activity repository that provides date-range,
6
- * user, and opportunity queries.
4
+ * Family-specific base service for activity / interaction entities. Delegates
5
+ * to an activity repository that provides date-range, actor (`user_id`), and
6
+ * config-driven subject queries. The subject FK column is resolved inside the
7
+ * repository from its `patternConfig` (ADR-031 §4) — the service is
8
+ * subject-name-agnostic. See ACTIVITY-SUBJECT-1.
7
9
  */
8
10
  import { BaseService, type IBaseRepository } from './base-service';
9
11
 
10
12
  export interface IActivityEntityRepository<TEntity> extends IBaseRepository<TEntity> {
11
13
  findByDateRange(start: Date, end: Date): Promise<TEntity[]>;
12
14
  findByUserId(userId: string): Promise<TEntity[]>;
13
- findByOpportunityId(opportunityId: string): Promise<TEntity[]>;
14
- findRecentByOpportunityId(opportunityId: string, limit?: number): Promise<TEntity[]>;
15
+ findBySubjectId(subjectId: string): Promise<TEntity[]>;
16
+ findRecentBySubjectId(subjectId: string, limit?: number): Promise<TEntity[]>;
15
17
  }
16
18
 
17
19
  export abstract class ActivityEntityService<
@@ -26,23 +28,23 @@ export abstract class ActivityEntityService<
26
28
  }
27
29
 
28
30
  /**
29
- * Find all activities for a specific user.
31
+ * Find all activities for a specific user (actor / owner scoping).
30
32
  */
31
33
  findByUser(userId: string): Promise<TEntity[]> {
32
34
  return this.repository.findByUserId(userId);
33
35
  }
34
36
 
35
37
  /**
36
- * Find all activities for a specific opportunity.
38
+ * Find all activities for a specific subject (config-driven FK column).
37
39
  */
38
- findByOpportunity(opportunityId: string): Promise<TEntity[]> {
39
- return this.repository.findByOpportunityId(opportunityId);
40
+ findBySubject(subjectId: string): Promise<TEntity[]> {
41
+ return this.repository.findBySubjectId(subjectId);
40
42
  }
41
43
 
42
44
  /**
43
- * Find the most recent activities for an opportunity.
45
+ * Find the most recent activities for a subject.
44
46
  */
45
- findRecent(opportunityId: string, limit?: number): Promise<TEntity[]> {
46
- return this.repository.findRecentByOpportunityId(opportunityId, limit);
47
+ findRecent(subjectId: string, limit?: number): Promise<TEntity[]> {
48
+ return this.repository.findRecentBySubjectId(subjectId, limit);
47
49
  }
48
50
  }
@@ -22,10 +22,12 @@
22
22
  * primitive while emitting `Change<T>.source = 'cdc'`. Long-lived
23
23
  * streaming CDC (SFDC Pub-Sub, Debezium) is a separate primitive
24
24
  * deferred to #226-8.
25
- * - `webhook` mode requires `eventIdField` so `WebhookChangeSource<T>`
26
- * can populate `Change<T>.dedupKey` from the inbound staging row.
25
+ * - `webhook` mode's `eventIdField` is optional: `WebhookChangeSource<T>`
26
+ * prefers an `eventId` yielded by the queue iterator and falls back to the
27
+ * `eventIdField` record extraction (precedence: yielded eventId >
28
+ * eventIdField extraction > undefined dedupKey).
27
29
  */
28
- import { z } from 'zod';
30
+ import { z } from "zod";
29
31
 
30
32
  // ============================================================================
31
33
  // Field mapping — provider field → canonical target
@@ -37,9 +39,9 @@ import { z } from 'zod';
37
39
  * etc.); the schema does not enumerate transforms — adapters interpret them.
38
40
  */
39
41
  export const FieldMappingSchema = z.object({
40
- source: z.string().min(1),
41
- target: z.string().min(1),
42
- transform: z.string().min(1).optional(),
42
+ source: z.string().min(1),
43
+ target: z.string().min(1),
44
+ transform: z.string().min(1).optional(),
43
45
  });
44
46
 
45
47
  export type FieldMapping = z.infer<typeof FieldMappingSchema>;
@@ -54,9 +56,9 @@ export type FieldMapping = z.infer<typeof FieldMappingSchema>;
54
56
  * adapters interpret per provider.
55
57
  */
56
58
  export const ResolvedFilterSchema = z.object({
57
- field: z.string().min(1),
58
- op: z.enum(['eq', 'neq', 'in', 'nin', 'gt', 'gte', 'lt', 'lte']),
59
- value: z.unknown(),
59
+ field: z.string().min(1),
60
+ op: z.enum(["eq", "neq", "in", "nin", "gt", "gte", "lt", "lte"]),
61
+ value: z.unknown(),
60
62
  });
61
63
 
62
64
  export type ResolvedFilter = z.infer<typeof ResolvedFilterSchema>;
@@ -66,23 +68,23 @@ export type ResolvedFilter = z.infer<typeof ResolvedFilterSchema>;
66
68
  // ============================================================================
67
69
 
68
70
  const SystemModstampCursorSchema = z.object({
69
- kind: z.literal('systemModstamp'),
70
- field: z.string().min(1),
71
+ kind: z.literal("systemModstamp"),
72
+ field: z.string().min(1),
71
73
  });
72
74
 
73
75
  const ReplayIdCursorSchema = z.object({
74
- kind: z.literal('replayId'),
75
- field: z.string().min(1),
76
+ kind: z.literal("replayId"),
77
+ field: z.string().min(1),
76
78
  });
77
79
 
78
80
  const TimestampCursorSchema = z.object({
79
- kind: z.literal('timestamp'),
80
- field: z.string().min(1),
81
+ kind: z.literal("timestamp"),
82
+ field: z.string().min(1),
81
83
  });
82
84
 
83
85
  const EventIdCursorSchema = z.object({
84
- kind: z.literal('eventId'),
85
- field: z.string().min(1),
86
+ kind: z.literal("eventId"),
87
+ field: z.string().min(1),
86
88
  });
87
89
 
88
90
  /**
@@ -91,8 +93,8 @@ const EventIdCursorSchema = z.object({
91
93
  * `field` is metadata for codegen/adapters (the response key the token lives on).
92
94
  */
93
95
  const HistoryIdCursorSchema = z.object({
94
- kind: z.literal('historyId'),
95
- field: z.string().min(1),
96
+ kind: z.literal("historyId"),
97
+ field: z.string().min(1),
96
98
  });
97
99
 
98
100
  /**
@@ -100,17 +102,17 @@ const HistoryIdCursorSchema = z.object({
100
102
  * same divisibility profile as `historyId`.
101
103
  */
102
104
  const SyncTokenCursorSchema = z.object({
103
- kind: z.literal('syncToken'),
104
- field: z.string().min(1),
105
+ kind: z.literal("syncToken"),
106
+ field: z.string().min(1),
105
107
  });
106
108
 
107
- export const CursorStrategySchema = z.discriminatedUnion('kind', [
108
- SystemModstampCursorSchema,
109
- ReplayIdCursorSchema,
110
- TimestampCursorSchema,
111
- EventIdCursorSchema,
112
- HistoryIdCursorSchema,
113
- SyncTokenCursorSchema,
109
+ export const CursorStrategySchema = z.discriminatedUnion("kind", [
110
+ SystemModstampCursorSchema,
111
+ ReplayIdCursorSchema,
112
+ TimestampCursorSchema,
113
+ EventIdCursorSchema,
114
+ HistoryIdCursorSchema,
115
+ SyncTokenCursorSchema,
114
116
  ]);
115
117
 
116
118
  export type CursorStrategy = z.infer<typeof CursorStrategySchema>;
@@ -135,18 +137,20 @@ export type CursorStrategy = z.infer<typeof CursorStrategySchema>;
135
137
  * `eventId` is classified atomic conservatively: a generic opaque id is treated
136
138
  * all-or-nothing unless a concrete strategy proves it monotonically resumable.
137
139
  */
138
- export const CURSOR_DIVISIBILITY: Readonly<Record<CursorStrategy['kind'], boolean>> = {
139
- systemModstamp: true,
140
- timestamp: true,
141
- replayId: true,
142
- eventId: false,
143
- historyId: false,
144
- syncToken: false,
140
+ export const CURSOR_DIVISIBILITY: Readonly<
141
+ Record<CursorStrategy["kind"], boolean>
142
+ > = {
143
+ systemModstamp: true,
144
+ timestamp: true,
145
+ replayId: true,
146
+ eventId: false,
147
+ historyId: false,
148
+ syncToken: false,
145
149
  };
146
150
 
147
151
  /** Predicate form of {@link CURSOR_DIVISIBILITY}. */
148
- export function isDivisibleCursor(kind: CursorStrategy['kind']): boolean {
149
- return CURSOR_DIVISIBILITY[kind];
152
+ export function isDivisibleCursor(kind: CursorStrategy["kind"]): boolean {
153
+ return CURSOR_DIVISIBILITY[kind];
150
154
  }
151
155
 
152
156
  // ============================================================================
@@ -159,19 +163,25 @@ export function isDivisibleCursor(kind: CursorStrategy['kind']): boolean {
159
163
  * `field` — used for Stripe-style event endpoints. Defaults to `'poll'`.
160
164
  */
161
165
  export const PollDetectionSchema = z.object({
162
- cursor: CursorStrategySchema,
163
- provenance: z.enum(['poll', 'cdc']).optional(),
166
+ cursor: CursorStrategySchema,
167
+ provenance: z.enum(["poll", "cdc"]).optional(),
164
168
  });
165
169
 
166
170
  export type PollDetection = z.infer<typeof PollDetectionSchema>;
167
171
 
168
172
  /**
169
- * Webhook-mode block. `eventIdField` names the column in the consumer-owned
170
- * inbound staging row that `WebhookChangeSource<T>` reads to set
171
- * `Change<T>.dedupKey`.
173
+ * Webhook-mode block. `eventIdField`, when present, names the field on the
174
+ * emitted canonical record that `WebhookChangeSource<T>` reads to set
175
+ * `Change<T>.dedupKey` — used only as the fallback when the queue iterator
176
+ * does NOT yield an `eventId` alongside the record.
177
+ *
178
+ * `eventIdField` is **optional**: a queue iterator that always yields an
179
+ * `eventId` (vendor delivery metadata, the preferred channel) need not declare
180
+ * a record field for it. dedupKey precedence is: yielded `eventId` >
181
+ * `eventIdField` record extraction > undefined.
172
182
  */
173
183
  export const WebhookDetectionSchema = z.object({
174
- eventIdField: z.string().min(1),
184
+ eventIdField: z.string().min(1).optional(),
175
185
  });
176
186
 
177
187
  export type WebhookDetection = z.infer<typeof WebhookDetectionSchema>;
@@ -181,17 +191,17 @@ export type WebhookDetection = z.infer<typeof WebhookDetectionSchema>;
181
191
  // ============================================================================
182
192
 
183
193
  const PollModeSchema = z.object({
184
- mode: z.literal('poll'),
185
- poll: PollDetectionSchema,
186
- mapping: z.array(FieldMappingSchema).min(1),
187
- filters: z.array(ResolvedFilterSchema).default([]),
194
+ mode: z.literal("poll"),
195
+ poll: PollDetectionSchema,
196
+ mapping: z.array(FieldMappingSchema).min(1),
197
+ filters: z.array(ResolvedFilterSchema).default([]),
188
198
  });
189
199
 
190
200
  const WebhookModeSchema = z.object({
191
- mode: z.literal('webhook'),
192
- webhook: WebhookDetectionSchema,
193
- mapping: z.array(FieldMappingSchema).min(1),
194
- filters: z.array(ResolvedFilterSchema).default([]),
201
+ mode: z.literal("webhook"),
202
+ webhook: WebhookDetectionSchema,
203
+ mapping: z.array(FieldMappingSchema).min(1),
204
+ filters: z.array(ResolvedFilterSchema).default([]),
195
205
  });
196
206
 
197
207
  /**
@@ -201,9 +211,9 @@ const WebhookModeSchema = z.object({
201
211
  * (Stripe-style event endpoints) is expressed via `mode: 'poll'` with
202
212
  * `poll.provenance: 'cdc'`.
203
213
  */
204
- export const DetectionConfigSchema = z.discriminatedUnion('mode', [
205
- PollModeSchema,
206
- WebhookModeSchema,
214
+ export const DetectionConfigSchema = z.discriminatedUnion("mode", [
215
+ PollModeSchema,
216
+ WebhookModeSchema,
207
217
  ]);
208
218
 
209
219
  export type DetectionConfig = z.infer<typeof DetectionConfigSchema>;
@@ -7,8 +7,11 @@
7
7
  * queue. The primitive owns:
8
8
  *
9
9
  * - canonical `Change<T>.source = 'webhook'` stamping;
10
- * - `dedupKey` derivation from the configured `webhook.eventIdField` on
11
- * the emitted record;
10
+ * - `dedupKey` derivation, preferring the `eventId` yielded alongside the
11
+ * record by the queue iterator, and falling back to the configured
12
+ * `webhook.eventIdField` on the emitted record when no `eventId` is yielded
13
+ * (precedence: yielded `eventId` > `eventIdField` record extraction >
14
+ * undefined `dedupKey`);
12
15
  * - `externalId` derivation: the mapping entry whose `target === 'external_id'`
13
16
  * names — via its `source` — the field on the emitted record that carries
14
17
  * the canonical external id (mirrors `PollChangeSource`);
@@ -37,16 +40,16 @@
37
40
  * into either this primitive or the poll primitive.
38
41
  */
39
42
 
40
- import type { DetectionConfig } from './detection-config.schema';
43
+ import type { DetectionConfig } from "./detection-config.schema";
41
44
  import type {
42
- Change,
43
- IChangeSource,
44
- IntegrationSubscriptionView,
45
- } from './integration-change-source.protocol';
45
+ Change,
46
+ IChangeSource,
47
+ IntegrationSubscriptionView,
48
+ } from "./integration-change-source.protocol";
46
49
  import type {
47
- ChangeIterator,
48
- ChangeMiddleware,
49
- } from './integration-middleware.protocol';
50
+ ChangeIterator,
51
+ ChangeMiddleware,
52
+ } from "./integration-middleware.protocol";
50
53
 
51
54
  // ============================================================================
52
55
  // Cursor + queue callback shapes
@@ -66,16 +69,28 @@ export type WebhookCursor = unknown;
66
69
  * `userId` / `tenantId`.
67
70
  */
68
71
  export interface WebhookFetchContext {
69
- readonly subscription: IntegrationSubscriptionView;
70
- readonly cursor: WebhookCursor | null;
72
+ readonly subscription: IntegrationSubscriptionView;
73
+ readonly cursor: WebhookCursor | null;
71
74
  }
72
75
 
73
76
  /**
74
77
  * Consumer-supplied queue iterator. Returns an async iterable of
75
- * `{ record }` pairs — the consumer drains the inbound staging queue and
76
- * emits already-mapped canonical records `T`. The primitive stamps
77
- * `source: 'webhook'` and `dedupKey` from the record's configured
78
- * `webhook.eventIdField`; the consumer is the one who decided when a
78
+ * `{ record, eventId?, cursor? }` tuples — the consumer drains the inbound
79
+ * staging queue and emits already-mapped canonical records `T`. The primitive
80
+ * stamps `source: 'webhook'` and derives `dedupKey` with this precedence:
81
+ *
82
+ * 1. the yielded `eventId` (vendor delivery metadata — the queue is the
83
+ * right channel for it: a vendor's event id should never need a field
84
+ * on the vendor-neutral canonical record);
85
+ * 2. else the record field named by `webhook.eventIdField`, when configured;
86
+ * 3. else `undefined`.
87
+ *
88
+ * Yielding `eventId` is the safe channel when one canonical record identity
89
+ * (the `external_id`) can recur across distinct vendor events in a single
90
+ * drain batch — e.g. a message create and its later edit share an
91
+ * `external_id` but are different events. Reading dedup identity off the
92
+ * record (`eventIdField`) collapses those into one `dedupKey`; the yielded
93
+ * `eventId` keeps them distinct. The consumer is the one who decided when a
79
94
  * staging row is "ready" to drain.
80
95
  *
81
96
  * Webhook mode has no per-record cursor advance — the staging-row drain
@@ -84,33 +99,33 @@ export interface WebhookFetchContext {
84
99
  * is whatever the consumer chooses to surface, if anything.
85
100
  */
86
101
  export type WebhookFetchCallback<T> = (
87
- ctx: WebhookFetchContext,
88
- ) => AsyncIterable<{ record: T; cursor?: WebhookCursor }>;
102
+ ctx: WebhookFetchContext,
103
+ ) => AsyncIterable<{ record: T; eventId?: string; cursor?: WebhookCursor }>;
89
104
 
90
105
  // ============================================================================
91
106
  // Constructor options
92
107
  // ============================================================================
93
108
 
94
109
  export interface WebhookChangeSourceOptions<T> {
95
- /** Consumer-supplied inbound queue iterator. */
96
- readonly queue: WebhookFetchCallback<T>;
97
- /**
98
- * Parsed detection config. MUST be `mode: 'webhook'`; the constructor
99
- * throws if a poll config is supplied. Codegen-emitted factories call
100
- * `DetectionConfigSchema.parse(...)` upstream so this is a safety net,
101
- * not the primary validation point.
102
- */
103
- readonly config: DetectionConfig;
104
- /**
105
- * Optional middleware chain. Same shape and composition rules as
106
- * `PollChangeSource` — first element is the outermost layer.
107
- */
108
- readonly middlewares?: ReadonlyArray<ChangeMiddleware<T>>;
109
- /**
110
- * Optional human label for run logs (e.g. `'stripe-webhook-charge'`).
111
- * Defaults to a derived label based on the mapping at construction.
112
- */
113
- readonly label?: string;
110
+ /** Consumer-supplied inbound queue iterator. */
111
+ readonly queue: WebhookFetchCallback<T>;
112
+ /**
113
+ * Parsed detection config. MUST be `mode: 'webhook'`; the constructor
114
+ * throws if a poll config is supplied. Codegen-emitted factories call
115
+ * `DetectionConfigSchema.parse(...)` upstream so this is a safety net,
116
+ * not the primary validation point.
117
+ */
118
+ readonly config: DetectionConfig;
119
+ /**
120
+ * Optional middleware chain. Same shape and composition rules as
121
+ * `PollChangeSource` — first element is the outermost layer.
122
+ */
123
+ readonly middlewares?: ReadonlyArray<ChangeMiddleware<T>>;
124
+ /**
125
+ * Optional human label for run logs (e.g. `'stripe-webhook-charge'`).
126
+ * Defaults to a derived label based on the mapping at construction.
127
+ */
128
+ readonly label?: string;
114
129
  }
115
130
 
116
131
  // ============================================================================
@@ -118,100 +133,139 @@ export interface WebhookChangeSourceOptions<T> {
118
133
  // ============================================================================
119
134
 
120
135
  export class WebhookChangeSource<T> implements IChangeSource<T> {
121
- public readonly label: string;
122
-
123
- private readonly queue: WebhookFetchCallback<T>;
124
- private readonly externalIdSourceField: string;
125
- private readonly eventIdSourceField: string;
126
- private readonly composed: ChangeIterator<T>;
127
-
128
- constructor(opts: WebhookChangeSourceOptions<T>) {
129
- if (opts.config.mode !== 'webhook') {
130
- throw new Error(
131
- `WebhookChangeSource requires DetectionConfig.mode === 'webhook'; got '${(opts.config as { mode: string }).mode}'`,
132
- );
133
- }
134
- const config = opts.config;
135
-
136
- // Field mapping: locate the entry whose canonical `target` is `external_id`
137
- // — mirrors the poll primitive's contract. Adapters emit records
138
- // already-mapped; the primitive needs to know which key on T carries the
139
- // external id so it can stamp `Change.externalId`. That key is the
140
- // mapping's `source` (the field on the emitted record), NOT its `target`
141
- // (the canonical column) they differ whenever the canonical record is
142
- // vendor-neutral camelCase (e.g. `source: 'externalId'` `target: 'external_id'`).
143
- const externalIdMapping = config.mapping.find(
144
- (m) => m.target === 'external_id',
145
- );
146
- if (!externalIdMapping) {
147
- throw new Error(
148
- "WebhookChangeSource: DetectionConfig.mapping must include an entry with target 'external_id' so emitted Change<T>.externalId can be populated",
149
- );
150
- }
151
- this.externalIdSourceField = externalIdMapping.source;
152
- this.eventIdSourceField = config.webhook.eventIdField;
153
-
154
- this.queue = opts.queue;
155
-
156
- this.label =
157
- opts.label ?? `webhook-change-source:${externalIdMapping.source}`;
158
-
159
- // Compose middleware chain same shape as PollChangeSource.
160
- const inner: ChangeIterator<T> = (sub, cur) => this.fetch(sub, cur);
161
- const middlewares = opts.middlewares ?? [];
162
- this.composed = middlewares.reduceRight<ChangeIterator<T>>(
163
- (next, mw) => mw(next),
164
- inner,
165
- );
166
- }
167
-
168
- listChanges(
169
- subscription: IntegrationSubscriptionView,
170
- cursor: unknown | null,
171
- ): AsyncIterable<Change<T>> {
172
- return this.composed(subscription, cursor);
173
- }
174
-
175
- private async *fetch(
176
- subscription: IntegrationSubscriptionView,
177
- cursor: unknown | null,
178
- ): AsyncIterable<Change<T>> {
179
- const ctx: WebhookFetchContext = {
180
- subscription,
181
- cursor: cursor as WebhookCursor | null,
182
- };
183
-
184
- for await (const { record, cursor: nextCursor } of this.queue(ctx)) {
185
- const externalIdRaw = (record as Record<string, unknown>)[
186
- this.externalIdSourceField
187
- ];
188
- if (typeof externalIdRaw !== 'string' || externalIdRaw.length === 0) {
189
- throw new Error(
190
- `WebhookChangeSource: record missing string '${this.externalIdSourceField}' — emitted records MUST carry the canonical external id keyed by the mapping source`,
191
- );
192
- }
193
- const eventIdRaw = (record as Record<string, unknown>)[
194
- this.eventIdSourceField
195
- ];
196
- if (typeof eventIdRaw !== 'string' || eventIdRaw.length === 0) {
197
- throw new Error(
198
- `WebhookChangeSource: record missing string '${this.eventIdSourceField}' — webhook records MUST carry the event id (DetectionConfig.webhook.eventIdField) so Change<T>.dedupKey can be populated`,
199
- );
200
- }
201
-
202
- const change: Change<T> = {
203
- externalId: externalIdRaw,
204
- // Webhook mode cannot distinguish create vs. update vs. delete on
205
- // its own the orchestrator's diff stage handles classification.
206
- // Tombstone / soft-delete detection is consumer-driven (same as
207
- // poll mode see ADR-033).
208
- operation: 'updated',
209
- record,
210
- cursor: nextCursor ?? null,
211
- source: 'webhook',
212
- dedupKey: eventIdRaw,
213
- };
214
- yield change;
215
- }
216
- }
136
+ public readonly label: string;
137
+
138
+ private readonly queue: WebhookFetchCallback<T>;
139
+ private readonly externalIdSourceField: string;
140
+ /**
141
+ * Record field carrying the event id, when `webhook.eventIdField` is
142
+ * configured. Used only as the fallback when the queue iterator does NOT
143
+ * yield an `eventId` — see {@link WebhookFetchCallback} for the precedence.
144
+ */
145
+ private readonly eventIdSourceField: string | undefined;
146
+ private readonly composed: ChangeIterator<T>;
147
+
148
+ constructor(opts: WebhookChangeSourceOptions<T>) {
149
+ if (opts.config.mode !== "webhook") {
150
+ throw new Error(
151
+ `WebhookChangeSource requires DetectionConfig.mode === 'webhook'; got '${(opts.config as { mode: string }).mode}'`,
152
+ );
153
+ }
154
+ const config = opts.config;
155
+
156
+ // Field mapping: locate the entry whose canonical `target` is `external_id`
157
+ // mirrors the poll primitive's contract. Adapters emit records
158
+ // already-mapped; the primitive needs to know which key on T carries the
159
+ // external id so it can stamp `Change.externalId`. That key is the
160
+ // mapping's `source` (the field on the emitted record), NOT its `target`
161
+ // (the canonical column) — they differ whenever the canonical record is
162
+ // vendor-neutral camelCase (e.g. `source: 'externalId'` → `target: 'external_id'`).
163
+ const externalIdMapping = config.mapping.find(
164
+ (m) => m.target === "external_id",
165
+ );
166
+ if (!externalIdMapping) {
167
+ throw new Error(
168
+ "WebhookChangeSource: DetectionConfig.mapping must include an entry with target 'external_id' so emitted Change<T>.externalId can be populated",
169
+ );
170
+ }
171
+ this.externalIdSourceField = externalIdMapping.source;
172
+ this.eventIdSourceField = config.webhook.eventIdField;
173
+ // `eventIdField` is optional (a callback that always yields `eventId` need
174
+ // not declare one); `undefined` here just disables the fallback extraction.
175
+
176
+ this.queue = opts.queue;
177
+
178
+ this.label =
179
+ opts.label ?? `webhook-change-source:${externalIdMapping.source}`;
180
+
181
+ // Compose middleware chain — same shape as PollChangeSource.
182
+ const inner: ChangeIterator<T> = (sub, cur) => this.fetch(sub, cur);
183
+ const middlewares = opts.middlewares ?? [];
184
+ this.composed = middlewares.reduceRight<ChangeIterator<T>>(
185
+ (next, mw) => mw(next),
186
+ inner,
187
+ );
188
+ }
189
+
190
+ listChanges(
191
+ subscription: IntegrationSubscriptionView,
192
+ cursor: unknown | null,
193
+ ): AsyncIterable<Change<T>> {
194
+ return this.composed(subscription, cursor);
195
+ }
196
+
197
+ private async *fetch(
198
+ subscription: IntegrationSubscriptionView,
199
+ cursor: unknown | null,
200
+ ): AsyncIterable<Change<T>> {
201
+ const ctx: WebhookFetchContext = {
202
+ subscription,
203
+ cursor: cursor as WebhookCursor | null,
204
+ };
205
+
206
+ for await (const {
207
+ record,
208
+ eventId: yieldedEventId,
209
+ cursor: nextCursor,
210
+ } of this.queue(ctx)) {
211
+ const externalIdRaw = (record as Record<string, unknown>)[
212
+ this.externalIdSourceField
213
+ ];
214
+ if (typeof externalIdRaw !== "string" || externalIdRaw.length === 0) {
215
+ throw new Error(
216
+ `WebhookChangeSource: record missing string '${this.externalIdSourceField}' — emitted records MUST carry the canonical external id keyed by the mapping source`,
217
+ );
218
+ }
219
+
220
+ // dedupKey precedence: yielded `eventId` > `eventIdField` record
221
+ // extraction > undefined. The yielded id is vendor delivery metadata
222
+ // (the right channel for it), and keeps distinct vendor events for the
223
+ // same `external_id` (e.g. a message and its edit) from collapsing to
224
+ // one dedupKey — which a record-field extraction would do.
225
+ const dedupKey = this.deriveDedupKey(yieldedEventId, record);
226
+
227
+ const change: Change<T> = {
228
+ externalId: externalIdRaw,
229
+ // Webhook mode cannot distinguish create vs. update vs. delete on
230
+ // its own — the orchestrator's diff stage handles classification.
231
+ // Tombstone / soft-delete detection is consumer-driven (same as
232
+ // poll mode — see ADR-033).
233
+ operation: "updated",
234
+ record,
235
+ cursor: nextCursor ?? null,
236
+ source: "webhook",
237
+ dedupKey,
238
+ };
239
+ yield change;
240
+ }
241
+ }
242
+
243
+ /**
244
+ * Resolve `Change<T>.dedupKey` with the precedence: yielded `eventId` >
245
+ * `webhook.eventIdField` record extraction > `undefined`. A non-empty
246
+ * yielded `eventId` always wins; otherwise the configured field is read off
247
+ * the record (and must be a non-empty string when the field is configured);
248
+ * with neither, `dedupKey` is `undefined` (the orchestrator then has no
249
+ * delivery-level dedup signal for this change).
250
+ */
251
+ private deriveDedupKey(
252
+ yieldedEventId: string | undefined,
253
+ record: T,
254
+ ): string | undefined {
255
+ if (yieldedEventId !== undefined && yieldedEventId.length > 0) {
256
+ return yieldedEventId;
257
+ }
258
+ if (this.eventIdSourceField === undefined) {
259
+ return undefined;
260
+ }
261
+ const eventIdRaw = (record as Record<string, unknown>)[
262
+ this.eventIdSourceField
263
+ ];
264
+ if (typeof eventIdRaw !== "string" || eventIdRaw.length === 0) {
265
+ throw new Error(
266
+ `WebhookChangeSource: record missing string '${this.eventIdSourceField}' — a webhook record MUST carry the event id (DetectionConfig.webhook.eventIdField) so Change<T>.dedupKey can be populated, unless the queue iterator yields an 'eventId' alongside the record`,
267
+ );
268
+ }
269
+ return eventIdRaw;
270
+ }
217
271
  }