@useairfoil/connector-kit 0.4.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.
Files changed (67) hide show
  1. package/README.md +283 -90
  2. package/dist/chunk-Bp6m_JJh.js +13 -0
  3. package/dist/connector-app/index.d.ts +9 -0
  4. package/dist/connector-app/index.js +8 -0
  5. package/dist/connector-app-CvqbfN6R.js +32 -0
  6. package/dist/connector-app-CvqbfN6R.js.map +1 -0
  7. package/dist/errors/index.d.ts +2 -0
  8. package/dist/errors/index.js +3 -0
  9. package/dist/errors-DHWylqRZ.js +8 -0
  10. package/dist/errors-DHWylqRZ.js.map +1 -0
  11. package/dist/index-8KnVpoZp.d.ts +32 -0
  12. package/dist/index-BlhJCpEk.d.ts +124 -0
  13. package/dist/index-CQJsFJs3.d.ts +23 -0
  14. package/dist/index-DK9YcR6W.d.ts +25 -0
  15. package/dist/index-DaNW63Mv.d.ts +21 -0
  16. package/dist/index-DyuT2jzP.d.ts +14 -0
  17. package/dist/index.d.ts +9 -165
  18. package/dist/index.js +44 -228
  19. package/dist/index.js.map +1 -1
  20. package/dist/ingestion/index.d.ts +6 -0
  21. package/dist/ingestion/index.js +7 -0
  22. package/dist/ingestion-CMCroK7Y.js +171 -0
  23. package/dist/ingestion-CMCroK7Y.js.map +1 -0
  24. package/dist/publisher/index.d.ts +5 -0
  25. package/dist/publisher/index.js +5 -0
  26. package/dist/publisher-DusgYvaV.js +131 -0
  27. package/dist/publisher-DusgYvaV.js.map +1 -0
  28. package/dist/server-DAeFWT4o.js +113 -0
  29. package/dist/server-DAeFWT4o.js.map +1 -0
  30. package/dist/service-B7g_lHMI.d.ts +32 -0
  31. package/dist/state-store/index.d.ts +4 -0
  32. package/dist/state-store/index.js +3 -0
  33. package/dist/state-store-DG_4QpWG.js +31 -0
  34. package/dist/state-store-DG_4QpWG.js.map +1 -0
  35. package/dist/telemetry-p0dE5Mdu.js +171 -0
  36. package/dist/telemetry-p0dE5Mdu.js.map +1 -0
  37. package/dist/types-ZNrA-Dn0.d.ts +160 -0
  38. package/dist/webhook/index.d.ts +5 -0
  39. package/dist/webhook/index.js +6 -0
  40. package/dist/webhook-BSA-tXtU.js +13 -0
  41. package/dist/webhook-BSA-tXtU.js.map +1 -0
  42. package/package.json +36 -7
  43. package/src/connector-app/index.ts +1 -0
  44. package/src/connector-app/start.ts +38 -0
  45. package/src/core/builder.ts +76 -9
  46. package/src/core/index.ts +22 -0
  47. package/src/core/types.ts +185 -75
  48. package/src/errors/index.ts +1 -0
  49. package/src/index.ts +8 -28
  50. package/src/ingestion/engine.ts +301 -179
  51. package/src/ingestion/index.ts +1 -0
  52. package/src/publisher/console.ts +18 -0
  53. package/src/publisher/index.ts +7 -0
  54. package/src/publisher/service.ts +30 -13
  55. package/src/publisher/wings.ts +159 -72
  56. package/src/runtime/context.ts +15 -0
  57. package/src/state-store/index.ts +2 -0
  58. package/src/state-store/memory.ts +25 -0
  59. package/src/state-store/service.ts +18 -0
  60. package/src/telemetry.ts +246 -0
  61. package/src/webhook/index.ts +8 -0
  62. package/src/webhook/server.ts +172 -37
  63. package/src/webhook/types.ts +6 -17
  64. package/src/ingestion/state-store.ts +0 -36
  65. package/src/streams/pull-stream.ts +0 -54
  66. package/src/streams/webhook-queue.ts +0 -14
  67. /package/src/{core/errors.ts → errors/connector-error.ts} +0 -0
@@ -1,203 +1,325 @@
1
- import { Effect, Queue, Ref, Stream } from "effect";
1
+ import { DateTime, Effect, Layer, Metric, Queue } from "effect";
2
+ import { HttpRouter, type HttpServer, HttpServerResponse } from "effect/unstable/http";
2
3
 
3
- import type { ConnectorError } from "../core/errors";
4
4
  import type {
5
- Batch,
6
5
  ConnectorDefinition,
7
6
  Cursor,
8
- EntityDefinition,
9
- EntityRow,
10
- EntitySchema,
11
- EventDefinition,
12
- IngestionState,
13
- LiveSource,
14
- Transform,
15
- WebhookStream,
7
+ ResourceBatch,
8
+ ResourceDefinition,
9
+ ResourceState,
10
+ WebhookRoute,
16
11
  } from "../core/types";
17
12
 
13
+ import { ConnectorError } from "../errors";
18
14
  import { Publisher } from "../publisher/service";
19
- import { StateStore } from "./state-store";
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";
20
25
 
21
- type TaggedBatch<T> = {
22
- readonly source: "live" | "backfill";
23
- readonly batch: Batch<T>;
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;
41
+ };
42
+
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
+ };
24
70
  };
25
71
 
26
- export const runConnector = (
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
+ }),
101
+ );
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,
108
+ );
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
+ );
119
+
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
+ }
133
+ });
134
+
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,
148
+ });
149
+ };
150
+
151
+ const runIngestion = (
27
152
  connector: ConnectorDefinition,
28
- initialCutoff: Cursor,
153
+ initialCutoff: Cursor.Value,
29
154
  ): Effect.Effect<void, ConnectorError, StateStore | Publisher> =>
30
- Effect.gen(function* () {
31
- // Start ingestion for every entity and event in parallel.
32
- const entityRuns = connector.entities.map((entity) => runEntity(entity, initialCutoff));
33
- const eventRuns = connector.events.map((event) => runEvent(event, initialCutoff));
34
- // main runner
35
- yield* Effect.all([...entityRuns, ...eventRuns], {
155
+ Effect.forEach(
156
+ connector.resources,
157
+ (resource) => runResourceSources(connector, resource, initialCutoff),
158
+ {
36
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,
210
+ });
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
+ },
37
220
  });
38
- });
39
221
 
40
- const createInitialState = (cutoff: Cursor): IngestionState<Cursor> => ({
41
- backfill: { cutoff },
42
- live: { cutoff },
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 } },
237
+ );
238
+ }
43
239
  });
44
240
 
45
- const makeStateRef = (
46
- key: string,
47
- initialCutoff: Cursor,
48
- ): Effect.Effect<Ref.Ref<IngestionState<Cursor>>, ConnectorError, StateStore> =>
49
- Effect.gen(function* () {
50
- // Load persisted state or initialize a new one for this stream.
51
- const store = yield* StateStore;
52
- const existing = yield* store.getState(key);
53
- const initial = existing ?? createInitialState(initialCutoff);
54
- return yield* Ref.make(initial);
55
- });
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;
56
248
 
57
- const runEntity = <S extends EntitySchema>(
58
- entity: EntityDefinition<S>,
59
- initialCutoff: Cursor,
60
- ): Effect.Effect<void, ConnectorError, StateStore | Publisher> =>
61
- Effect.gen(function* () {
62
- type Row = EntityRow<S>;
63
- const stateRef = yield* makeStateRef(entity.name, initialCutoff);
64
- // Tracks which primary keys have already been emitted.
65
- const seenRef = yield* Ref.make(new Set<string>());
66
-
67
- const liveStream = resolveLiveStream(entity.live);
68
- const tagLive = (batch: Batch<Row>) => ({
69
- source: "live" as const,
70
- batch,
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
+ },
71
262
  });
72
- const updateSeen = (rows: ReadonlyArray<Row>) =>
73
- Ref.update(seenRef, (seen) => {
74
- const next = new Set(seen);
75
- for (const row of rows) {
76
- const key = String(row[entity.primaryKey]);
77
- next.add(key);
78
- }
79
- return next;
80
- });
81
-
82
- // Entities are upserts, so live and backfill can overlap. We keep an in-memory
83
- // seen set (primary keys) so backfill does not re-emit rows already observed live.
84
- const backfillTagged = Stream.mapEffect(entity.backfill, (batch) =>
85
- Ref.get(seenRef).pipe(
86
- Effect.map((seen) => {
87
- const filtered = batch.rows.filter((row) => {
88
- const key = String(row[entity.primaryKey]);
89
- return !seen.has(key);
90
- });
91
- return {
92
- source: "backfill" as const,
93
- batch: { cursor: batch.cursor, rows: filtered },
94
- };
95
- }),
96
- ),
97
- ).pipe(Stream.tap(({ batch }) => updateSeen(batch.rows)));
98
-
99
- // For webhook live sources, we wait for the first live batch before starting
100
- // backfill. That first batch establishes the cutoff timestamp and seeds the
101
- // seen set so backfill can de-dupe correctly.
102
- // Queue-backed streams are single-consumer; splitting with take/drop would
103
- // consume and discard elements. Take the first element directly from the
104
- // queue, then let Stream.fromQueue continue from element #2.
105
- if (isWebhookStream(entity.live)) {
106
- const firstBatch = yield* Queue.take(entity.live.queue);
107
- yield* updateSeen(firstBatch.rows);
108
- yield* processTaggedStream(
109
- Stream.make({ source: "live" as const, batch: firstBatch }),
110
- entity.name,
111
- entity.transform,
112
- stateRef,
113
- );
114
-
115
- // liveStream is Stream.fromQueue on the same queue, continues from element #2.
116
- const liveTailTagged = Stream.map(liveStream, tagLive).pipe(
117
- Stream.tap(({ batch }) => updateSeen(batch.rows)),
118
- );
119
- const merged = Stream.merge(liveTailTagged, backfillTagged);
120
- yield* processTaggedStream(merged, entity.name, entity.transform, stateRef);
121
- return;
122
- }
123
-
124
- // For pull-based live sources, we can merge immediately because there is no
125
- // webhook cutoff gating and the live stream is not queue-backed.
126
- const liveTagged = Stream.map(liveStream, tagLive).pipe(
127
- Stream.tap(({ batch }) => updateSeen(batch.rows)),
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 } },
128
276
  );
129
277
 
130
- const merged = Stream.merge(liveTagged, backfillTagged);
131
- yield* processTaggedStream(merged, entity.name, entity.transform, stateRef);
132
- });
278
+ yield* Effect.sleep(resource.changes.interval ?? "1 minute");
279
+ }
280
+ });
133
281
 
134
- const runEvent = <S extends EntitySchema>(
135
- event: EventDefinition<S>,
136
- initialCutoff: Cursor,
137
- ): Effect.Effect<void, ConnectorError, StateStore | Publisher> =>
138
- Effect.gen(function* () {
139
- type Row = EntityRow<S>;
140
- const stateRef = yield* makeStateRef(event.name, initialCutoff);
141
- const liveStream = resolveLiveStream<Row>(event.live);
142
-
143
- // Events must backfill first to preserve ordering.
144
- if (event.backfill) {
145
- const backfillTagged = Stream.map(event.backfill, (batch) => ({
146
- source: "backfill" as const,
147
- batch,
148
- }));
149
- yield* processTaggedStream(backfillTagged, event.name, event.transform, stateRef);
150
- }
151
-
152
- const liveTagged = Stream.map(liveStream, (batch) => ({
153
- source: "live" as const,
154
- batch,
155
- }));
156
- yield* processTaggedStream(liveTagged, event.name, event.transform, stateRef);
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,
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,
157
308
  });
158
309
 
159
- const updateState = (
160
- state: IngestionState<Cursor>,
161
- source: "live" | "backfill",
162
- cursor: Cursor,
163
- ): IngestionState<Cursor> =>
164
- source === "live"
165
- ? { ...state, live: { ...state.live, current: cursor } }
166
- : { ...state, backfill: { ...state.backfill, current: cursor } };
167
-
168
- const resolveLiveStream = <T>(source: LiveSource<T>): Stream.Stream<Batch<T>, ConnectorError> =>
169
- isWebhookStream(source) ? source.stream : source;
170
-
171
- const isWebhookStream = <T>(source: LiveSource<T>): source is WebhookStream<T> =>
172
- typeof source === "object" && source !== null && "queue" in source && "stream" in source;
173
-
174
- const processTaggedStream = <T extends Record<string, unknown>>(
175
- stream: Stream.Stream<TaggedBatch<T>, ConnectorError>,
176
- name: string,
177
- transform: Transform<T> | undefined,
178
- stateRef: Ref.Ref<IngestionState<Cursor>>,
179
- ): Effect.Effect<void, ConnectorError, StateStore | Publisher> =>
180
- Stream.runForEach(stream, ({ source, batch }) =>
181
- Effect.gen(function* () {
182
- // Optional per-row transformation.
183
- const rows = transform ? yield* Effect.forEach(batch.rows, transform) : batch.rows;
184
-
185
- // Publish before updating cursor state.
186
- const publisher = yield* Publisher;
187
- yield* publisher.publish({
188
- name,
189
- batch: {
190
- cursor: batch.cursor,
191
- rows,
192
- },
193
- });
194
-
195
- // Persist state only after publish succeeds.
196
- const nextState = yield* Ref.updateAndGet(stateRef, (state) =>
197
- updateState(state, source, batch.cursor),
198
- );
199
-
200
- const store = yield* StateStore;
201
- yield* store.setState(name, nextState);
202
- }),
203
- );
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";
@@ -1,20 +1,37 @@
1
+ import type * as Wings from "@useairfoil/wings";
1
2
  import type * as Effect from "effect/Effect";
2
3
 
3
4
  import { Context } from "effect";
4
5
 
5
- import type { ConnectorError } from "../core/errors";
6
- import type { Batch } from "../core/types";
6
+ import type { ResourceBatch } from "../core/types";
7
+ import type { ConnectorError } from "../errors";
7
8
 
8
- export type PublishAck = {
9
- readonly success: boolean;
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;
10
29
  };
11
30
 
12
- export class Publisher extends Context.Service<
13
- Publisher,
14
- {
15
- readonly publish: (options: {
16
- readonly name: string;
17
- readonly batch: Batch<Record<string, unknown>>;
18
- }) => Effect.Effect<PublishAck, ConnectorError>;
19
- }
20
- >()("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
+ ) {}