@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.
- package/CHANGELOG.md +93 -0
- package/consumer-skills/entities/families-and-queries.md +5 -3
- package/consumer-skills/integration/change-sources-and-sinks.md +1 -1
- package/dist/{chunk-3RWMQC3K.js → chunk-3MAZ4TQH.js} +12 -12
- package/dist/{chunk-VNBC3VXM.js → chunk-3VEVGL74.js} +4 -4
- package/dist/{chunk-WWGYCIJX.js → chunk-43SBT72G.js} +2 -2
- package/dist/{chunk-Y7GDG744.js → chunk-4GLNY5V6.js} +5 -5
- package/dist/{chunk-BK5ICA2F.js → chunk-4MVGAMUA.js} +4 -4
- package/dist/{chunk-3NMCDN7L.js → chunk-5TK7MEN4.js} +2 -2
- package/dist/chunk-5TK7MEN4.js.map +1 -0
- package/dist/{chunk-T6SCOJF4.js → chunk-7LKAMLV4.js} +4 -4
- package/dist/{chunk-BHZP6LOV.js → chunk-CDLWYZVQ.js} +7 -7
- package/dist/{chunk-DKKFTHHI.js → chunk-CZQUOIDY.js} +4 -4
- package/dist/{chunk-XWBK3XJK.js → chunk-DCCZB4UC.js} +4 -4
- package/dist/{chunk-EBKVKN75.js → chunk-DTXH24LR.js} +2 -2
- package/dist/{chunk-RUYLXR5F.js → chunk-GJDEPTPY.js} +10 -10
- package/dist/{chunk-32DOFN3T.js → chunk-IOQMMH6C.js} +17 -7
- package/dist/{chunk-32DOFN3T.js.map → chunk-IOQMMH6C.js.map} +1 -1
- package/dist/{chunk-BOPZWRJK.js → chunk-JYBFPNBJ.js} +8 -8
- package/dist/chunk-JYBFPNBJ.js.map +1 -0
- package/dist/{chunk-KSTZIULO.js → chunk-K2I6XIK5.js} +4 -4
- package/dist/{chunk-CEWLVVAH.js → chunk-L3VJ47BU.js} +5 -5
- package/dist/chunk-MKWQKKK7.js +72 -0
- package/dist/chunk-MKWQKKK7.js.map +1 -0
- package/dist/{chunk-DRCLNYH7.js → chunk-NXNVTXKG.js} +4 -4
- package/dist/{chunk-TDEHU73T.js → chunk-OGIZXGPY.js} +4 -4
- package/dist/{chunk-XDIIVIIK.js → chunk-OITTYGJS.js} +4 -4
- package/dist/{chunk-24WXSC3C.js → chunk-P3AYBRP6.js} +7 -7
- package/dist/{chunk-EJBK7I4F.js → chunk-RHYNACZS.js} +3 -3
- package/dist/{chunk-YK5JEVLX.js → chunk-SR7F3TJY.js} +4 -4
- package/dist/{chunk-YLPAPPLW.js → chunk-TIZXQU26.js} +36 -9
- package/dist/chunk-TIZXQU26.js.map +1 -0
- package/dist/{chunk-4PFF3ED4.js → chunk-UTNWFHJF.js} +4 -4
- package/dist/{chunk-LQ6PYFU6.js → chunk-Z7PQCAVK.js} +4 -4
- package/dist/runtime/base-classes/activity-entity-repository.d.ts +39 -7
- package/dist/runtime/base-classes/activity-entity-repository.js +1 -1
- package/dist/runtime/base-classes/activity-entity-service.d.ts +12 -10
- package/dist/runtime/base-classes/activity-entity-service.js +1 -1
- package/dist/runtime/base-classes/index.js +23 -23
- package/dist/runtime/shared/openapi/index.js +7 -7
- package/dist/runtime/shared/openapi/registry.js +2 -2
- package/dist/runtime/subsystems/analytics/analytics.module.js +2 -2
- package/dist/runtime/subsystems/analytics/index.js +4 -4
- package/dist/runtime/subsystems/auth/auth.module.js +3 -3
- package/dist/runtime/subsystems/auth/index.js +7 -7
- package/dist/runtime/subsystems/bridge/bridge-delivery-handler.js +2 -2
- package/dist/runtime/subsystems/bridge/bridge-delivery.drizzle-backend.js +2 -2
- package/dist/runtime/subsystems/bridge/bridge-outbox-drain-hook.js +4 -4
- package/dist/runtime/subsystems/bridge/bridge.module.js +15 -15
- package/dist/runtime/subsystems/bridge/index.js +17 -17
- package/dist/runtime/subsystems/cache/cache.module.js +1 -1
- package/dist/runtime/subsystems/cache/index.js +3 -3
- package/dist/runtime/subsystems/events/event-bus.drizzle-backend.js +2 -2
- package/dist/runtime/subsystems/events/event-bus.memory-backend.js +2 -2
- package/dist/runtime/subsystems/events/events.module.js +4 -4
- package/dist/runtime/subsystems/events/index.js +4 -4
- package/dist/runtime/subsystems/index.js +107 -107
- package/dist/runtime/subsystems/integration/build-change-source.js +2 -2
- package/dist/runtime/subsystems/integration/detection-config.schema.d.ts +23 -15
- package/dist/runtime/subsystems/integration/detection-config.schema.js +1 -1
- package/dist/runtime/subsystems/integration/execute-integration.use-case.js +2 -2
- package/dist/runtime/subsystems/integration/index.js +43 -43
- package/dist/runtime/subsystems/integration/integration-cursor-store.drizzle-backend.js +2 -2
- package/dist/runtime/subsystems/integration/integration-run-recorder.drizzle-backend.js +2 -2
- package/dist/runtime/subsystems/integration/integration.module.js +5 -5
- package/dist/runtime/subsystems/integration/webhook-change-source.d.ts +36 -6
- package/dist/runtime/subsystems/integration/webhook-change-source.js +1 -1
- package/dist/runtime/subsystems/jobs/index.js +30 -30
- package/dist/runtime/subsystems/jobs/job-orchestrator.bullmq-backend.js +4 -4
- package/dist/runtime/subsystems/jobs/job-orchestrator.memory-backend.js +1 -1
- package/dist/runtime/subsystems/jobs/job-run-service.drizzle-backend.js +2 -2
- package/dist/runtime/subsystems/jobs/job-run-service.memory-backend.js +2 -2
- package/dist/runtime/subsystems/jobs/job-worker.js +2 -2
- package/dist/runtime/subsystems/jobs/job-worker.module.js +10 -10
- package/dist/runtime/subsystems/jobs/jobs-domain.module.js +8 -8
- package/dist/runtime/subsystems/storage/index.js +4 -4
- package/dist/runtime/subsystems/storage/storage.module.js +2 -2
- package/dist/src/cli/index.js +15 -15
- package/dist/src/index.d.ts +34 -19
- package/dist/src/index.js +14 -14
- package/package.json +2 -1
- package/runtime/base-classes/activity-entity-repository.ts +72 -13
- package/runtime/base-classes/activity-entity-service.ts +14 -12
- package/runtime/subsystems/integration/detection-config.schema.ts +64 -54
- package/runtime/subsystems/integration/webhook-change-source.ts +187 -133
- package/src/patterns/library/activity.pattern.ts +40 -10
- package/dist/chunk-3NMCDN7L.js.map +0 -1
- package/dist/chunk-BOPZWRJK.js.map +0 -1
- package/dist/chunk-XCEI7NUH.js +0 -41
- package/dist/chunk-XCEI7NUH.js.map +0 -1
- package/dist/chunk-YLPAPPLW.js.map +0 -1
- /package/dist/{chunk-3RWMQC3K.js.map → chunk-3MAZ4TQH.js.map} +0 -0
- /package/dist/{chunk-VNBC3VXM.js.map → chunk-3VEVGL74.js.map} +0 -0
- /package/dist/{chunk-WWGYCIJX.js.map → chunk-43SBT72G.js.map} +0 -0
- /package/dist/{chunk-Y7GDG744.js.map → chunk-4GLNY5V6.js.map} +0 -0
- /package/dist/{chunk-BK5ICA2F.js.map → chunk-4MVGAMUA.js.map} +0 -0
- /package/dist/{chunk-T6SCOJF4.js.map → chunk-7LKAMLV4.js.map} +0 -0
- /package/dist/{chunk-BHZP6LOV.js.map → chunk-CDLWYZVQ.js.map} +0 -0
- /package/dist/{chunk-DKKFTHHI.js.map → chunk-CZQUOIDY.js.map} +0 -0
- /package/dist/{chunk-XWBK3XJK.js.map → chunk-DCCZB4UC.js.map} +0 -0
- /package/dist/{chunk-EBKVKN75.js.map → chunk-DTXH24LR.js.map} +0 -0
- /package/dist/{chunk-RUYLXR5F.js.map → chunk-GJDEPTPY.js.map} +0 -0
- /package/dist/{chunk-KSTZIULO.js.map → chunk-K2I6XIK5.js.map} +0 -0
- /package/dist/{chunk-CEWLVVAH.js.map → chunk-L3VJ47BU.js.map} +0 -0
- /package/dist/{chunk-DRCLNYH7.js.map → chunk-NXNVTXKG.js.map} +0 -0
- /package/dist/{chunk-TDEHU73T.js.map → chunk-OGIZXGPY.js.map} +0 -0
- /package/dist/{chunk-XDIIVIIK.js.map → chunk-OITTYGJS.js.map} +0 -0
- /package/dist/{chunk-24WXSC3C.js.map → chunk-P3AYBRP6.js.map} +0 -0
- /package/dist/{chunk-EJBK7I4F.js.map → chunk-RHYNACZS.js.map} +0 -0
- /package/dist/{chunk-YK5JEVLX.js.map → chunk-SR7F3TJY.js.map} +0 -0
- /package/dist/{chunk-4PFF3ED4.js.map → chunk-UTNWFHJF.js.map} +0 -0
- /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
|
-
*
|
|
6
|
-
*
|
|
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
|
-
|
|
14
|
-
|
|
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
|
|
38
|
+
* Find all activities for a specific subject (config-driven FK column).
|
|
37
39
|
*/
|
|
38
|
-
|
|
39
|
-
return this.repository.
|
|
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
|
|
45
|
+
* Find the most recent activities for a subject.
|
|
44
46
|
*/
|
|
45
|
-
findRecent(
|
|
46
|
-
return this.repository.
|
|
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
|
|
26
|
-
*
|
|
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
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
70
|
-
|
|
71
|
+
kind: z.literal("systemModstamp"),
|
|
72
|
+
field: z.string().min(1),
|
|
71
73
|
});
|
|
72
74
|
|
|
73
75
|
const ReplayIdCursorSchema = z.object({
|
|
74
|
-
|
|
75
|
-
|
|
76
|
+
kind: z.literal("replayId"),
|
|
77
|
+
field: z.string().min(1),
|
|
76
78
|
});
|
|
77
79
|
|
|
78
80
|
const TimestampCursorSchema = z.object({
|
|
79
|
-
|
|
80
|
-
|
|
81
|
+
kind: z.literal("timestamp"),
|
|
82
|
+
field: z.string().min(1),
|
|
81
83
|
});
|
|
82
84
|
|
|
83
85
|
const EventIdCursorSchema = z.object({
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
95
|
-
|
|
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
|
-
|
|
104
|
-
|
|
105
|
+
kind: z.literal("syncToken"),
|
|
106
|
+
field: z.string().min(1),
|
|
105
107
|
});
|
|
106
108
|
|
|
107
|
-
export const CursorStrategySchema = z.discriminatedUnion(
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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<
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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[
|
|
149
|
-
|
|
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
|
-
|
|
163
|
-
|
|
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
|
|
170
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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(
|
|
205
|
-
|
|
206
|
-
|
|
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
|
|
11
|
-
* the
|
|
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
|
|
43
|
+
import type { DetectionConfig } from "./detection-config.schema";
|
|
41
44
|
import type {
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
} from
|
|
45
|
+
Change,
|
|
46
|
+
IChangeSource,
|
|
47
|
+
IntegrationSubscriptionView,
|
|
48
|
+
} from "./integration-change-source.protocol";
|
|
46
49
|
import type {
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
} from
|
|
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
|
-
|
|
70
|
-
|
|
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 }`
|
|
76
|
-
* emits already-mapped canonical records `T`. The primitive
|
|
77
|
-
* `source: 'webhook'` and `dedupKey`
|
|
78
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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
|
}
|