@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.
Files changed (67) hide show
  1. package/README.md +279 -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 -166
  18. package/dist/index.js +44 -229
  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 +41 -14
  43. package/src/connector-app/index.ts +1 -0
  44. package/src/connector-app/start.ts +38 -0
  45. package/src/core/builder.ts +73 -15
  46. package/src/core/index.ts +22 -0
  47. package/src/core/types.ts +186 -78
  48. package/src/errors/index.ts +1 -0
  49. package/src/index.ts +8 -28
  50. package/src/ingestion/engine.ts +302 -206
  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 +33 -14
  55. package/src/publisher/wings.ts +162 -82
  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 +177 -54
  63. package/src/webhook/types.ts +6 -16
  64. package/src/ingestion/state-store.ts +0 -36
  65. package/src/streams/pull-stream.ts +0 -55
  66. package/src/streams/webhook-queue.ts +0 -15
  67. /package/src/{core/errors.ts → errors/connector-error.ts} +0 -0
@@ -1,229 +1,325 @@
1
- import { Effect, Queue, Ref, Stream } from "effect";
2
- import type { ConnectorError } from "../core/errors";
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
- EntityDefinition,
8
- EntityRow,
9
- EntitySchema,
10
- EventDefinition,
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 "./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";
18
25
 
19
- type TaggedBatch<T> = {
20
- readonly source: "live" | "backfill";
21
- 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;
22
41
  };
23
42
 
24
- export const runConnector = (
25
- connector: ConnectorDefinition,
26
- initialCutoff: Cursor,
27
- ): Effect.Effect<void, ConnectorError, StateStore | Publisher> =>
28
- Effect.gen(function* () {
29
- // Start ingestion for every entity and event in parallel.
30
- const entityRuns = connector.entities.map((entity) =>
31
- runEntity(entity, initialCutoff),
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
- const eventRuns = connector.events.map((event) =>
34
- runEvent(event, initialCutoff),
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
- // main runner
37
- yield* Effect.all([...entityRuns, ...eventRuns], {
38
- concurrency: "unbounded",
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 createInitialState = (cutoff: Cursor): IngestionState<Cursor> => ({
43
- backfill: { cutoff },
44
- live: { cutoff },
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 makeStateRef = (
48
- key: string,
49
- initialCutoff: Cursor,
50
- ): Effect.Effect<Ref.Ref<IngestionState<Cursor>>, ConnectorError, StateStore> =>
51
- Effect.gen(function* () {
52
- // Load persisted state or initialize a new one for this stream.
53
- const store = yield* StateStore;
54
- const existing = yield* store.getState(key);
55
- const initial = existing ?? createInitialState(initialCutoff);
56
- return yield* Ref.make(initial);
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 runEntity = <S extends EntitySchema>(
60
- entity: EntityDefinition<S>,
61
- initialCutoff: Cursor,
151
+ const runIngestion = (
152
+ connector: ConnectorDefinition,
153
+ initialCutoff: Cursor.Value,
62
154
  ): Effect.Effect<void, ConnectorError, StateStore | Publisher> =>
63
- Effect.gen(function* () {
64
- type Row = EntityRow<S>;
65
- const stateRef = yield* makeStateRef(entity.name, initialCutoff);
66
- // Tracks which primary keys have already been emitted.
67
- const seenRef = yield* Ref.make(new Set<string>());
68
-
69
- const liveStream = resolveLiveStream(entity.live);
70
- const tagLive = (batch: Batch<Row>) => ({
71
- source: "live" as const,
72
- batch,
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
- const updateSeen = (rows: ReadonlyArray<Row>) =>
75
- Ref.update(seenRef, (seen) => {
76
- const next = new Set(seen);
77
- for (const row of rows) {
78
- const key = String(row[entity.primaryKey]);
79
- next.add(key);
80
- }
81
- return next;
82
- });
83
-
84
- // Entities are upserts, so live and backfill can overlap. We keep an in-memory
85
- // seen set (primary keys) so backfill does not re-emit rows already observed live.
86
- const backfillTagged = Stream.mapEffect(entity.backfill, (batch) =>
87
- Ref.get(seenRef).pipe(
88
- Effect.map((seen) => {
89
- const filtered = batch.rows.filter((row) => {
90
- const key = String(row[entity.primaryKey]);
91
- return !seen.has(key);
92
- });
93
- return {
94
- source: "backfill" as const,
95
- batch: { cursor: batch.cursor, rows: filtered },
96
- };
97
- }),
98
- ),
99
- ).pipe(Stream.tap(({ batch }) => updateSeen(batch.rows)));
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
- const merged = Stream.merge(liveTagged, backfillTagged);
138
- yield* processTaggedStream(merged, entity.name, entity.transform, stateRef);
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
- const runEvent = <S extends EntitySchema>(
142
- event: EventDefinition<S>,
143
- initialCutoff: Cursor,
144
- ): Effect.Effect<void, ConnectorError, StateStore | Publisher> =>
145
- Effect.gen(function* () {
146
- type Row = EntityRow<S>;
147
- const stateRef = yield* makeStateRef(event.name, initialCutoff);
148
- const liveStream = resolveLiveStream<Row>(event.live);
149
-
150
- // Events must backfill first to preserve ordering.
151
- if (event.backfill) {
152
- const backfillTagged = Stream.map(event.backfill, (batch) => ({
153
- source: "backfill" as const,
154
- batch,
155
- }));
156
- yield* processTaggedStream(
157
- backfillTagged,
158
- event.name,
159
- event.transform,
160
- stateRef,
161
- );
162
- }
163
-
164
- const liveTagged = Stream.map(liveStream, (batch) => ({
165
- source: "live" as const,
166
- batch,
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
- const updateState = (
177
- state: IngestionState<Cursor>,
178
- source: "live" | "backfill",
179
- cursor: Cursor,
180
- ): IngestionState<Cursor> =>
181
- source === "live"
182
- ? { ...state, live: { ...state.live, current: cursor } }
183
- : { ...state, backfill: { ...state.backfill, current: cursor } };
184
-
185
- const resolveLiveStream = <T>(
186
- source: LiveSource<T>,
187
- ): Stream.Stream<Batch<T>, ConnectorError> =>
188
- isWebhookStream(source) ? source.stream : source;
189
-
190
- const isWebhookStream = <T>(
191
- source: LiveSource<T>,
192
- ): source is WebhookStream<T> =>
193
- typeof source === "object" &&
194
- source !== null &&
195
- "queue" in source &&
196
- "stream" in source;
197
-
198
- const processTaggedStream = <T extends Record<string, unknown>>(
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";
@@ -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
- export type PublishAck = {
7
- readonly success: boolean;
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 class Publisher extends ServiceMap.Service<
11
- Publisher,
12
- {
13
- readonly publish: (options: {
14
- readonly name: string;
15
- readonly batch: Batch<Record<string, unknown>>;
16
- }) => Effect.Effect<PublishAck, ConnectorError>;
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
+ ) {}