@useairfoil/connector-kit 0.3.0 → 0.5.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/README.md +279 -90
- package/dist/chunk-Bp6m_JJh.js +13 -0
- package/dist/connector-app/index.d.ts +9 -0
- package/dist/connector-app/index.js +8 -0
- package/dist/connector-app-CvqbfN6R.js +32 -0
- package/dist/connector-app-CvqbfN6R.js.map +1 -0
- package/dist/errors/index.d.ts +2 -0
- package/dist/errors/index.js +3 -0
- package/dist/errors-DHWylqRZ.js +8 -0
- package/dist/errors-DHWylqRZ.js.map +1 -0
- package/dist/index-8KnVpoZp.d.ts +32 -0
- package/dist/index-BlhJCpEk.d.ts +124 -0
- package/dist/index-CQJsFJs3.d.ts +23 -0
- package/dist/index-DK9YcR6W.d.ts +25 -0
- package/dist/index-DaNW63Mv.d.ts +21 -0
- package/dist/index-DyuT2jzP.d.ts +14 -0
- package/dist/index.d.ts +9 -166
- package/dist/index.js +44 -229
- package/dist/index.js.map +1 -1
- package/dist/ingestion/index.d.ts +6 -0
- package/dist/ingestion/index.js +7 -0
- package/dist/ingestion-CMCroK7Y.js +171 -0
- package/dist/ingestion-CMCroK7Y.js.map +1 -0
- package/dist/publisher/index.d.ts +5 -0
- package/dist/publisher/index.js +5 -0
- package/dist/publisher-DusgYvaV.js +131 -0
- package/dist/publisher-DusgYvaV.js.map +1 -0
- package/dist/server-DAeFWT4o.js +113 -0
- package/dist/server-DAeFWT4o.js.map +1 -0
- package/dist/service-B7g_lHMI.d.ts +32 -0
- package/dist/state-store/index.d.ts +4 -0
- package/dist/state-store/index.js +3 -0
- package/dist/state-store-DG_4QpWG.js +31 -0
- package/dist/state-store-DG_4QpWG.js.map +1 -0
- package/dist/telemetry-p0dE5Mdu.js +171 -0
- package/dist/telemetry-p0dE5Mdu.js.map +1 -0
- package/dist/types-ZNrA-Dn0.d.ts +160 -0
- package/dist/webhook/index.d.ts +5 -0
- package/dist/webhook/index.js +6 -0
- package/dist/webhook-BSA-tXtU.js +13 -0
- package/dist/webhook-BSA-tXtU.js.map +1 -0
- package/package.json +41 -14
- package/src/connector-app/index.ts +1 -0
- package/src/connector-app/start.ts +38 -0
- package/src/core/builder.ts +73 -15
- package/src/core/index.ts +22 -0
- package/src/core/types.ts +186 -78
- package/src/errors/index.ts +1 -0
- package/src/index.ts +8 -28
- package/src/ingestion/engine.ts +302 -206
- package/src/ingestion/index.ts +1 -0
- package/src/publisher/console.ts +18 -0
- package/src/publisher/index.ts +7 -0
- package/src/publisher/service.ts +33 -14
- package/src/publisher/wings.ts +162 -82
- package/src/runtime/context.ts +15 -0
- package/src/state-store/index.ts +2 -0
- package/src/state-store/memory.ts +25 -0
- package/src/state-store/service.ts +18 -0
- package/src/telemetry.ts +246 -0
- package/src/webhook/index.ts +8 -0
- package/src/webhook/server.ts +177 -54
- package/src/webhook/types.ts +6 -16
- package/src/ingestion/state-store.ts +0 -36
- package/src/streams/pull-stream.ts +0 -55
- package/src/streams/webhook-queue.ts +0 -15
- /package/src/{core/errors.ts → errors/connector-error.ts} +0 -0
package/src/ingestion/engine.ts
CHANGED
|
@@ -1,229 +1,325 @@
|
|
|
1
|
-
import { Effect,
|
|
2
|
-
import type
|
|
1
|
+
import { DateTime, Effect, Layer, Metric, Queue } from "effect";
|
|
2
|
+
import { HttpRouter, type HttpServer, HttpServerResponse } from "effect/unstable/http";
|
|
3
|
+
|
|
3
4
|
import type {
|
|
4
|
-
Batch,
|
|
5
5
|
ConnectorDefinition,
|
|
6
6
|
Cursor,
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
IngestionState,
|
|
12
|
-
LiveSource,
|
|
13
|
-
Transform,
|
|
14
|
-
WebhookStream,
|
|
7
|
+
ResourceBatch,
|
|
8
|
+
ResourceDefinition,
|
|
9
|
+
ResourceState,
|
|
10
|
+
WebhookRoute,
|
|
15
11
|
} from "../core/types";
|
|
12
|
+
|
|
13
|
+
import { ConnectorError } from "../errors";
|
|
16
14
|
import { Publisher } from "../publisher/service";
|
|
17
|
-
import { StateStore } from "
|
|
15
|
+
import { StateStore } from "../state-store";
|
|
16
|
+
import {
|
|
17
|
+
Attr,
|
|
18
|
+
EventAttr,
|
|
19
|
+
EventName,
|
|
20
|
+
SpanName,
|
|
21
|
+
addCurrentSpanEvent,
|
|
22
|
+
annotateError,
|
|
23
|
+
} from "../telemetry";
|
|
24
|
+
import { router, WebhookQueue, type QueuedWebhookBatch } from "../webhook/server";
|
|
18
25
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
26
|
+
const connectorBatchesTotal = Metric.counter("connector_batches_total", {
|
|
27
|
+
description: "Total resource batches attempted by connector sources",
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const connectorMutationsTotal = Metric.counter("connector_mutations_total", {
|
|
31
|
+
description: "Total resource mutations attempted by connector sources",
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
const connectorBatchSize = Metric.histogram("connector_batch_size", {
|
|
35
|
+
description: "Distribution of resource mutation batch sizes",
|
|
36
|
+
boundaries: [1, 5, 10, 25, 50, 100, 250, 500, 1000],
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
type RunBaseOptions = {
|
|
40
|
+
readonly initialCutoff?: Cursor.Value;
|
|
22
41
|
};
|
|
23
42
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
43
|
+
const normalizeCursor = (value: Cursor.Value): Cursor.Value =>
|
|
44
|
+
value instanceof Date ? value.toISOString() : value;
|
|
45
|
+
|
|
46
|
+
const normalizeResourceState = (state: ResourceState): ResourceState => ({
|
|
47
|
+
backfill: state.backfill
|
|
48
|
+
? {
|
|
49
|
+
cutoff: normalizeCursor(state.backfill.cutoff),
|
|
50
|
+
pageCursor:
|
|
51
|
+
state.backfill.pageCursor === undefined
|
|
52
|
+
? undefined
|
|
53
|
+
: normalizeCursor(state.backfill.pageCursor),
|
|
54
|
+
completed: state.backfill.completed,
|
|
55
|
+
}
|
|
56
|
+
: undefined,
|
|
57
|
+
changes: state.changes
|
|
58
|
+
? {
|
|
59
|
+
cursor: normalizeCursor(state.changes.cursor),
|
|
60
|
+
}
|
|
61
|
+
: undefined,
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
export type RunOptions = RunBaseOptions & {
|
|
65
|
+
readonly webhook?: {
|
|
66
|
+
readonly routes: ReadonlyArray<WebhookRoute>;
|
|
67
|
+
readonly healthPath?: HttpRouter.PathInput;
|
|
68
|
+
readonly disableHttpLogger?: boolean;
|
|
69
|
+
};
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
type RunNoWebhookOptions = RunBaseOptions & {
|
|
73
|
+
readonly webhook?: undefined;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
type RunWebhookOptions = RunOptions & {
|
|
77
|
+
readonly webhook: NonNullable<RunOptions["webhook"]>;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
export function run<const Resources extends ReadonlyArray<ResourceDefinition>>(
|
|
81
|
+
connector: ConnectorDefinition<Resources>,
|
|
82
|
+
options?: RunNoWebhookOptions,
|
|
83
|
+
): Effect.Effect<void, ConnectorError, StateStore | Publisher>;
|
|
84
|
+
export function run<const Resources extends ReadonlyArray<ResourceDefinition>>(
|
|
85
|
+
connector: ConnectorDefinition<Resources>,
|
|
86
|
+
options: RunWebhookOptions,
|
|
87
|
+
): Effect.Effect<void, ConnectorError, StateStore | Publisher | HttpServer.HttpServer>;
|
|
88
|
+
export function run(connector: ConnectorDefinition, options?: RunOptions) {
|
|
89
|
+
const runtimeLayer = options?.webhook
|
|
90
|
+
? Layer.mergeAll(makeWebhookQueueLayer(), makeWebhookServerLayer(options.webhook))
|
|
91
|
+
: Layer.empty;
|
|
92
|
+
|
|
93
|
+
return Effect.gen(function* () {
|
|
94
|
+
const initialCutoff =
|
|
95
|
+
options?.initialCutoff ?? (yield* DateTime.now.pipe(Effect.map(DateTime.formatIso)));
|
|
96
|
+
yield* Effect.logInfo("Connector started").pipe(
|
|
97
|
+
Effect.annotateLogs({
|
|
98
|
+
[Attr.connectorName]: connector.name,
|
|
99
|
+
resources: connector.resources.length,
|
|
100
|
+
}),
|
|
32
101
|
);
|
|
33
|
-
|
|
34
|
-
|
|
102
|
+
|
|
103
|
+
const sourceRun = runIngestion(connector, initialCutoff);
|
|
104
|
+
const webhookRun = options?.webhook ? runWebhookRuntime(options.webhook.routes) : Effect.void;
|
|
105
|
+
|
|
106
|
+
return yield* Effect.all([sourceRun, webhookRun], { concurrency: "unbounded" }).pipe(
|
|
107
|
+
Effect.asVoid,
|
|
35
108
|
);
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
109
|
+
}).pipe(Effect.provide(runtimeLayer));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const makeWebhookQueueLayer = (): Layer.Layer<WebhookQueue> =>
|
|
113
|
+
Layer.effect(WebhookQueue)(
|
|
114
|
+
Effect.gen(function* () {
|
|
115
|
+
const queue = yield* Queue.bounded<QueuedWebhookBatch>(1024);
|
|
116
|
+
return WebhookQueue.of({ queue });
|
|
117
|
+
}),
|
|
118
|
+
);
|
|
41
119
|
|
|
42
|
-
const
|
|
43
|
-
|
|
44
|
-
|
|
120
|
+
const runWebhookRuntime = (routes: ReadonlyArray<WebhookRoute>) => {
|
|
121
|
+
const hasAfterEnqueue = routes.some((route) => route.ackMode === "after-enqueue");
|
|
122
|
+
return Effect.all([hasAfterEnqueue ? runWebhookQueueConsumer : Effect.void, Effect.never], {
|
|
123
|
+
concurrency: "unbounded",
|
|
124
|
+
}).pipe(Effect.asVoid);
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
const runWebhookQueueConsumer = Effect.gen(function* () {
|
|
128
|
+
const queue = yield* WebhookQueue;
|
|
129
|
+
while (true) {
|
|
130
|
+
const batch = yield* Queue.take(queue.queue);
|
|
131
|
+
yield* publishBatch({ resource: batch.resource, source: "webhook", batch: batch.batch });
|
|
132
|
+
}
|
|
45
133
|
});
|
|
46
134
|
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
135
|
+
const makeWebhookServerLayer = (options: {
|
|
136
|
+
readonly routes: ReadonlyArray<WebhookRoute>;
|
|
137
|
+
readonly healthPath?: HttpRouter.PathInput;
|
|
138
|
+
readonly disableHttpLogger?: boolean;
|
|
139
|
+
}): Layer.Layer<never, never, HttpServer.HttpServer> => {
|
|
140
|
+
const healthPath: HttpRouter.PathInput = options.healthPath ?? "/health";
|
|
141
|
+
const app = Layer.mergeAll(
|
|
142
|
+
router(options.routes),
|
|
143
|
+
HttpRouter.add("GET", healthPath, Effect.succeed(HttpServerResponse.text("ok"))),
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
return HttpRouter.serve(app, {
|
|
147
|
+
disableLogger: options.disableHttpLogger ?? true,
|
|
57
148
|
});
|
|
149
|
+
};
|
|
58
150
|
|
|
59
|
-
const
|
|
60
|
-
|
|
61
|
-
initialCutoff: Cursor,
|
|
151
|
+
const runIngestion = (
|
|
152
|
+
connector: ConnectorDefinition,
|
|
153
|
+
initialCutoff: Cursor.Value,
|
|
62
154
|
): Effect.Effect<void, ConnectorError, StateStore | Publisher> =>
|
|
63
|
-
Effect.
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
155
|
+
Effect.forEach(
|
|
156
|
+
connector.resources,
|
|
157
|
+
(resource) => runResourceSources(connector, resource, initialCutoff),
|
|
158
|
+
{
|
|
159
|
+
concurrency: "unbounded",
|
|
160
|
+
},
|
|
161
|
+
).pipe(Effect.asVoid);
|
|
162
|
+
|
|
163
|
+
const initializeResourceState = (
|
|
164
|
+
existing: ResourceState | undefined,
|
|
165
|
+
initialCutoff: Cursor.Value,
|
|
166
|
+
): ResourceState => ({
|
|
167
|
+
changes: existing?.changes ?? { cursor: initialCutoff },
|
|
168
|
+
backfill: existing?.backfill ?? {
|
|
169
|
+
cutoff: initialCutoff,
|
|
170
|
+
completed: false,
|
|
171
|
+
},
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
const getInitializedState = Effect.fnUntraced(function* (
|
|
175
|
+
resource: string,
|
|
176
|
+
initialCutoff: Cursor.Value,
|
|
177
|
+
) {
|
|
178
|
+
const store = yield* StateStore;
|
|
179
|
+
const existing = yield* store.getResourceState(resource);
|
|
180
|
+
return initializeResourceState(existing, initialCutoff);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
const runResourceSources = (
|
|
184
|
+
connector: ConnectorDefinition,
|
|
185
|
+
resource: ResourceDefinition,
|
|
186
|
+
initialCutoff: Cursor.Value,
|
|
187
|
+
) => {
|
|
188
|
+
const runs = [
|
|
189
|
+
resource.backfill ? runBackfill(connector, resource, initialCutoff) : Effect.void,
|
|
190
|
+
resource.changes ? runChanges(connector, resource, initialCutoff) : Effect.void,
|
|
191
|
+
];
|
|
192
|
+
|
|
193
|
+
return Effect.all(runs, { concurrency: "unbounded" }).pipe(Effect.asVoid);
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
const runBackfill = Effect.fnUntraced(function* (
|
|
197
|
+
connector: ConnectorDefinition,
|
|
198
|
+
resource: ResourceDefinition,
|
|
199
|
+
initialCutoff: Cursor.Value,
|
|
200
|
+
) {
|
|
201
|
+
if (!resource.backfill) return;
|
|
202
|
+
const store = yield* StateStore;
|
|
203
|
+
let state = yield* getInitializedState(resource.name, initialCutoff);
|
|
204
|
+
|
|
205
|
+
while (state.backfill?.completed !== true) {
|
|
206
|
+
const backfill = state.backfill ?? { cutoff: initialCutoff, completed: false };
|
|
207
|
+
const page = yield* resource.backfill.fetch({
|
|
208
|
+
pageCursor: backfill.pageCursor,
|
|
209
|
+
cutoff: backfill.cutoff,
|
|
73
210
|
});
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
// For webhook live sources, we wait for the first live batch before starting
|
|
102
|
-
// backfill. That first batch establishes the cutoff timestamp and seeds the
|
|
103
|
-
// seen set so backfill can de-dupe correctly.
|
|
104
|
-
// Queue-backed streams are single-consumer; splitting with take/drop would
|
|
105
|
-
// consume and discard elements. Take the first element directly from the
|
|
106
|
-
// queue, then let Stream.fromQueue continue from element #2.
|
|
107
|
-
if (isWebhookStream(entity.live)) {
|
|
108
|
-
const firstBatch = yield* Queue.take(entity.live.queue);
|
|
109
|
-
yield* updateSeen(firstBatch.rows);
|
|
110
|
-
yield* processTaggedStream(
|
|
111
|
-
Stream.make({ source: "live" as const, batch: firstBatch }),
|
|
112
|
-
entity.name,
|
|
113
|
-
entity.transform,
|
|
114
|
-
stateRef,
|
|
115
|
-
);
|
|
116
|
-
|
|
117
|
-
// liveStream is Stream.fromQueue on the same queue, continues from element #2.
|
|
118
|
-
const liveTailTagged = Stream.map(liveStream, tagLive).pipe(
|
|
119
|
-
Stream.tap(({ batch }) => updateSeen(batch.rows)),
|
|
120
|
-
);
|
|
121
|
-
const merged = Stream.merge(liveTailTagged, backfillTagged);
|
|
122
|
-
yield* processTaggedStream(
|
|
123
|
-
merged,
|
|
124
|
-
entity.name,
|
|
125
|
-
entity.transform,
|
|
126
|
-
stateRef,
|
|
127
|
-
);
|
|
128
|
-
return;
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
// For pull-based live sources, we can merge immediately because there is no
|
|
132
|
-
// webhook cutoff gating and the live stream is not queue-backed.
|
|
133
|
-
const liveTagged = Stream.map(liveStream, tagLive).pipe(
|
|
134
|
-
Stream.tap(({ batch }) => updateSeen(batch.rows)),
|
|
211
|
+
|
|
212
|
+
yield* publishBatch({
|
|
213
|
+
connector,
|
|
214
|
+
resource: resource.name,
|
|
215
|
+
source: "backfill",
|
|
216
|
+
batch: {
|
|
217
|
+
cursor: page.nextPageCursor ?? backfill.cutoff,
|
|
218
|
+
mutations: page.mutations,
|
|
219
|
+
},
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
state = {
|
|
223
|
+
...state,
|
|
224
|
+
backfill: {
|
|
225
|
+
cutoff: backfill.cutoff,
|
|
226
|
+
pageCursor: page.nextPageCursor,
|
|
227
|
+
completed: !page.hasMore,
|
|
228
|
+
},
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
yield* Effect.withSpan(
|
|
232
|
+
store
|
|
233
|
+
.setResourceState(resource.name, normalizeResourceState(state))
|
|
234
|
+
.pipe(Effect.tapError((error) => annotateError("state_set", error))),
|
|
235
|
+
SpanName.stateSet,
|
|
236
|
+
{ attributes: { [Attr.stateKey]: resource.name } },
|
|
135
237
|
);
|
|
238
|
+
}
|
|
239
|
+
});
|
|
136
240
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
241
|
+
const runChanges = Effect.fnUntraced(function* (
|
|
242
|
+
connector: ConnectorDefinition,
|
|
243
|
+
resource: ResourceDefinition,
|
|
244
|
+
initialCutoff: Cursor.Value,
|
|
245
|
+
) {
|
|
246
|
+
if (!resource.changes) return;
|
|
247
|
+
const store = yield* StateStore;
|
|
140
248
|
|
|
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
|
-
yield* processTaggedStream(
|
|
169
|
-
liveTagged,
|
|
170
|
-
event.name,
|
|
171
|
-
event.transform,
|
|
172
|
-
stateRef,
|
|
249
|
+
while (true) {
|
|
250
|
+
const state = yield* getInitializedState(resource.name, initialCutoff);
|
|
251
|
+
const cursor = state.changes?.cursor ?? initialCutoff;
|
|
252
|
+
const page = yield* resource.changes.fetch({ cursor });
|
|
253
|
+
|
|
254
|
+
yield* publishBatch({
|
|
255
|
+
connector,
|
|
256
|
+
resource: resource.name,
|
|
257
|
+
source: "changes",
|
|
258
|
+
batch: {
|
|
259
|
+
cursor: page.cursor,
|
|
260
|
+
mutations: page.mutations,
|
|
261
|
+
},
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
yield* Effect.withSpan(
|
|
265
|
+
store
|
|
266
|
+
.setResourceState(
|
|
267
|
+
resource.name,
|
|
268
|
+
normalizeResourceState({
|
|
269
|
+
...state,
|
|
270
|
+
changes: { cursor: page.cursor },
|
|
271
|
+
}),
|
|
272
|
+
)
|
|
273
|
+
.pipe(Effect.tapError((error) => annotateError("state_set", error))),
|
|
274
|
+
SpanName.stateSet,
|
|
275
|
+
{ attributes: { [Attr.stateKey]: resource.name } },
|
|
173
276
|
);
|
|
174
|
-
});
|
|
175
277
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
stream: Stream.Stream<TaggedBatch<T>, ConnectorError>,
|
|
200
|
-
name: string,
|
|
201
|
-
transform: Transform<T> | undefined,
|
|
202
|
-
stateRef: Ref.Ref<IngestionState<Cursor>>,
|
|
203
|
-
): Effect.Effect<void, ConnectorError, StateStore | Publisher> =>
|
|
204
|
-
Stream.runForEach(stream, ({ source, batch }) =>
|
|
205
|
-
Effect.gen(function* () {
|
|
206
|
-
// Optional per-row transformation.
|
|
207
|
-
const rows = transform
|
|
208
|
-
? yield* Effect.forEach(batch.rows, transform)
|
|
209
|
-
: batch.rows;
|
|
210
|
-
|
|
211
|
-
// Publish before updating cursor state.
|
|
212
|
-
const publisher = yield* Publisher;
|
|
213
|
-
yield* publisher.publish({
|
|
214
|
-
name,
|
|
215
|
-
batch: {
|
|
216
|
-
cursor: batch.cursor,
|
|
217
|
-
rows,
|
|
218
|
-
},
|
|
219
|
-
});
|
|
220
|
-
|
|
221
|
-
// Persist state only after publish succeeds.
|
|
222
|
-
const nextState = yield* Ref.updateAndGet(stateRef, (state) =>
|
|
223
|
-
updateState(state, source, batch.cursor),
|
|
224
|
-
);
|
|
225
|
-
|
|
226
|
-
const store = yield* StateStore;
|
|
227
|
-
yield* store.setState(name, nextState);
|
|
228
|
-
}),
|
|
278
|
+
yield* Effect.sleep(resource.changes.interval ?? "1 minute");
|
|
279
|
+
}
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
const publishBatch = Effect.fnUntraced(function* (options: {
|
|
283
|
+
readonly connector?: ConnectorDefinition;
|
|
284
|
+
readonly resource: string;
|
|
285
|
+
readonly source: "backfill" | "changes" | "webhook";
|
|
286
|
+
readonly batch: ResourceBatch;
|
|
287
|
+
}) {
|
|
288
|
+
const metric = {
|
|
289
|
+
connector: options.connector?.name ?? "unknown",
|
|
290
|
+
resource: options.resource,
|
|
291
|
+
source: options.source,
|
|
292
|
+
};
|
|
293
|
+
yield* Metric.update(Metric.withAttributes(connectorBatchesTotal, metric), 1);
|
|
294
|
+
yield* Metric.update(
|
|
295
|
+
Metric.withAttributes(connectorMutationsTotal, metric),
|
|
296
|
+
options.batch.mutations.length,
|
|
297
|
+
);
|
|
298
|
+
yield* Metric.update(
|
|
299
|
+
Metric.withAttributes(connectorBatchSize, metric),
|
|
300
|
+
options.batch.mutations.length,
|
|
229
301
|
);
|
|
302
|
+
|
|
303
|
+
const publisher = yield* Publisher;
|
|
304
|
+
const ack = yield* publisher.publish({
|
|
305
|
+
resource: options.resource,
|
|
306
|
+
source: options.source,
|
|
307
|
+
batch: options.batch,
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
yield* Effect.annotateCurrentSpan({ [Attr.publisherSuccess]: ack.status === "accepted" });
|
|
311
|
+
if (ack.status === "rejected") {
|
|
312
|
+
yield* Effect.annotateCurrentSpan({ [Attr.errorPhase]: "publish" });
|
|
313
|
+
return yield* Effect.fail(
|
|
314
|
+
new ConnectorError({
|
|
315
|
+
message: `Publisher rejected batch for ${options.resource}: ${ack.reason}`,
|
|
316
|
+
}),
|
|
317
|
+
);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
if (options.batch.cursor !== undefined) {
|
|
321
|
+
yield* addCurrentSpanEvent(EventName.batchCheckpoint, {
|
|
322
|
+
[EventAttr.batchCursor]: options.batch.cursor,
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { run, type RunOptions } from "./engine";
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { Effect, Layer } from "effect";
|
|
2
|
+
|
|
3
|
+
import { Publisher } from "./service";
|
|
4
|
+
|
|
5
|
+
export const layerConsole = Layer.succeed(Publisher)({
|
|
6
|
+
publish: ({ resource, source, batch }) =>
|
|
7
|
+
Effect.gen(function* () {
|
|
8
|
+
yield* Effect.logInfo(`[publisher] -> Source: ${source} | Resource: ${resource}`).pipe(
|
|
9
|
+
Effect.annotateLogs({
|
|
10
|
+
mutations: batch.mutations.length,
|
|
11
|
+
cursor: batch.cursor,
|
|
12
|
+
source,
|
|
13
|
+
}),
|
|
14
|
+
);
|
|
15
|
+
|
|
16
|
+
return { status: "accepted" as const, resource };
|
|
17
|
+
}),
|
|
18
|
+
});
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export type { ResourceBatch } from "../core/types";
|
|
2
|
+
export type { PublishAck, PublishOptions, PublisherService, PublishSource } from "./service";
|
|
3
|
+
export type { WingsPublisherConfig } from "./wings";
|
|
4
|
+
|
|
5
|
+
export { layerConsole } from "./console";
|
|
6
|
+
export { Publisher } from "./service";
|
|
7
|
+
export { layerWings } from "./wings";
|
package/src/publisher/service.ts
CHANGED
|
@@ -1,18 +1,37 @@
|
|
|
1
|
+
import type * as Wings from "@useairfoil/wings";
|
|
1
2
|
import type * as Effect from "effect/Effect";
|
|
2
|
-
import * as ServiceMap from "effect/ServiceMap";
|
|
3
|
-
import type { ConnectorError } from "../core/errors";
|
|
4
|
-
import type { Batch } from "../core/types";
|
|
5
3
|
|
|
6
|
-
|
|
7
|
-
|
|
4
|
+
import { Context } from "effect";
|
|
5
|
+
|
|
6
|
+
import type { ResourceBatch } from "../core/types";
|
|
7
|
+
import type { ConnectorError } from "../errors";
|
|
8
|
+
|
|
9
|
+
export type PublishSource = "backfill" | "changes" | "webhook";
|
|
10
|
+
|
|
11
|
+
export type PublishAck =
|
|
12
|
+
| {
|
|
13
|
+
readonly status: "accepted";
|
|
14
|
+
readonly resource: string;
|
|
15
|
+
readonly partition?: Wings.PartitionValue.PartitionValue;
|
|
16
|
+
}
|
|
17
|
+
| {
|
|
18
|
+
readonly status: "rejected";
|
|
19
|
+
readonly resource: string;
|
|
20
|
+
readonly reason: string;
|
|
21
|
+
readonly rejectedRows?: number;
|
|
22
|
+
readonly partition?: Wings.PartitionValue.PartitionValue;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export type PublishOptions = {
|
|
26
|
+
readonly resource: string;
|
|
27
|
+
readonly source: PublishSource;
|
|
28
|
+
readonly batch: ResourceBatch;
|
|
8
29
|
};
|
|
9
30
|
|
|
10
|
-
export
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
}
|
|
18
|
-
>()("Publisher") {}
|
|
31
|
+
export interface PublisherService {
|
|
32
|
+
readonly publish: (options: PublishOptions) => Effect.Effect<PublishAck, ConnectorError>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export class Publisher extends Context.Service<Publisher, PublisherService>()(
|
|
36
|
+
"@useairfoil/connector-kit/Publisher",
|
|
37
|
+
) {}
|