@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,57 +1,184 @@
1
- import { Cause, Data, Effect, Schema } from "effect";
2
- import {
3
- HttpRouter,
4
- HttpServerRequest,
5
- HttpServerResponse,
6
- } from "effect/unstable/http";
7
- import type { WebhookRoute } from "./types";
8
-
9
- class InvalidWebhookPayloadError extends Data.TaggedError(
10
- "InvalidWebhookPayloadError",
11
- )<{
12
- readonly message: string;
13
- }> {}
14
-
15
- const makeHandler = <TPayload>(route: WebhookRoute<TPayload>) =>
1
+ import { Cause, Context, Effect, Queue, Schema } from "effect";
2
+ import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http";
3
+
4
+ import type {
5
+ ResourceBatch,
6
+ ResourceDefinition,
7
+ ResourceMutation,
8
+ WebhookHandler,
9
+ WebhookRoute,
10
+ } from "../core/types";
11
+
12
+ import { ConnectorError } from "../errors";
13
+ import { Publisher } from "../publisher/service";
14
+ import { Attr, SpanName, annotateError } from "../telemetry";
15
+
16
+ export type QueuedWebhookBatch = {
17
+ readonly resource: string;
18
+ readonly batch: ResourceBatch;
19
+ };
20
+
21
+ export class WebhookQueue extends Context.Service<
22
+ WebhookQueue,
23
+ {
24
+ readonly queue: Queue.Queue<QueuedWebhookBatch>;
25
+ }
26
+ >()("@useairfoil/connector-kit/WebhookQueue") {}
27
+
28
+ const resourceBatch = <Row extends object>(
29
+ mutations: ReadonlyArray<ResourceMutation<Row>>,
30
+ ): ResourceBatch<Row> => ({
31
+ mutations,
32
+ });
33
+
34
+ const decodeAndHandleWebhook = <Row extends object, Payload>(
35
+ resourceName: string,
36
+ webhook: WebhookHandler<Row, Payload>,
37
+ payload: unknown,
38
+ ) =>
39
+ Schema.decodeUnknownEffect(webhook.schema)(payload).pipe(
40
+ Effect.mapError(
41
+ (cause) =>
42
+ new ConnectorError({
43
+ message: `Invalid webhook payload for resource ${resourceName}`,
44
+ cause,
45
+ }),
46
+ ),
47
+ Effect.flatMap((decoded) => webhook.handler({ payload: decoded })),
48
+ );
49
+
50
+ const makeRouteContext = (
51
+ request: HttpServerRequest.HttpServerRequest,
52
+ rawBody: Uint8Array,
53
+ payload: unknown,
54
+ batches: Array<QueuedWebhookBatch>,
55
+ ): import("../core/types").WebhookRouteContext<ReadonlyArray<ResourceDefinition>, unknown> => ({
56
+ request,
57
+ rawBody,
58
+ payload,
59
+ to: (resource, payload) =>
60
+ Effect.gen(function* () {
61
+ if (!resource.webhook) {
62
+ return yield* Effect.fail(
63
+ new ConnectorError({
64
+ message: `Resource ${resource.name} does not define a webhook handler`,
65
+ }),
66
+ );
67
+ }
68
+
69
+ const mutations = yield* decodeAndHandleWebhook(resource.name, resource.webhook, payload);
70
+ batches.push({
71
+ resource: resource.name,
72
+ batch: resourceBatch(mutations),
73
+ });
74
+ }),
75
+ });
76
+
77
+ const compactBatches = (batches: ReadonlyArray<QueuedWebhookBatch>) => {
78
+ const grouped = new Map<string, ResourceMutation[]>();
79
+ for (const item of batches) {
80
+ const current = grouped.get(item.resource) ?? [];
81
+ grouped.set(item.resource, [...current, ...item.batch.mutations]);
82
+ }
83
+ return Array.from(grouped.entries()).map(([resource, mutations]) => ({
84
+ resource,
85
+ batch: resourceBatch(mutations),
86
+ }));
87
+ };
88
+
89
+ const publishBatches = (batches: ReadonlyArray<QueuedWebhookBatch>) =>
90
+ Effect.gen(function* () {
91
+ const publisher = yield* Publisher;
92
+ yield* Effect.forEach(
93
+ batches,
94
+ (batch) =>
95
+ Effect.gen(function* () {
96
+ const ack = yield* publisher.publish({
97
+ resource: batch.resource,
98
+ source: "webhook",
99
+ batch: batch.batch,
100
+ });
101
+ if (ack.status === "rejected") {
102
+ return yield* Effect.fail(new ConnectorError({ message: ack.reason }));
103
+ }
104
+ }),
105
+ { concurrency: "unbounded" },
106
+ );
107
+ });
108
+
109
+ const badRequest = (message: string) =>
110
+ HttpServerResponse.jsonUnsafe({ ok: false, error: message }, { status: 400 });
111
+
112
+ const readRawBody = (request: HttpServerRequest.HttpServerRequest) =>
113
+ request.arrayBuffer.pipe(Effect.map((buffer) => new Uint8Array(buffer)));
114
+
115
+ const parseJson = (rawBody: Uint8Array) =>
116
+ Effect.try({
117
+ try: () => JSON.parse(new TextDecoder().decode(rawBody)) as unknown,
118
+ catch: (cause) => new ConnectorError({ message: "Invalid webhook JSON", cause }),
119
+ });
120
+
121
+ const makeHandler = (route: WebhookRoute) =>
16
122
  Effect.gen(function* () {
17
123
  const request = yield* HttpServerRequest.HttpServerRequest;
18
- const rawBuffer = yield* request.arrayBuffer.pipe(
19
- Effect.mapError(
20
- () =>
21
- new InvalidWebhookPayloadError({
22
- message: "Failed to read request body",
23
- }),
24
- ),
124
+ const collected: Array<QueuedWebhookBatch> = [];
125
+
126
+ const rawBodyResult = yield* readRawBody(request).pipe(
127
+ Effect.match({
128
+ onFailure: () => ({ _tag: "Error" as const, message: "Failed to read webhook body" }),
129
+ onSuccess: (value) => ({ _tag: "Success" as const, value }),
130
+ }),
25
131
  );
26
- const rawBody = new Uint8Array(rawBuffer);
27
- const rawText = new TextDecoder().decode(rawBody);
28
- const rawJson = yield* Effect.try({
29
- try: () => JSON.parse(rawText) as unknown,
30
- catch: () =>
31
- new InvalidWebhookPayloadError({ message: "Invalid JSON body" }),
32
- });
33
- const payload = yield* Schema.decodeUnknownEffect(route.schema)(
34
- rawJson,
35
- ).pipe(
36
- Effect.mapError(
37
- () =>
38
- new InvalidWebhookPayloadError({
39
- message: "Invalid webhook payload",
40
- }),
132
+ if (rawBodyResult._tag === "Error") {
133
+ return badRequest(rawBodyResult.message);
134
+ }
135
+
136
+ const jsonResult = yield* parseJson(rawBodyResult.value).pipe(
137
+ Effect.match({
138
+ onFailure: (error) => ({ _tag: "Error" as const, message: error.message }),
139
+ onSuccess: (value) => ({ _tag: "Success" as const, value }),
140
+ }),
141
+ );
142
+ if (jsonResult._tag === "Error") {
143
+ return badRequest(jsonResult.message);
144
+ }
145
+
146
+ const payloadResult = yield* Schema.decodeUnknownEffect(route.schema)(jsonResult.value).pipe(
147
+ Effect.match({
148
+ onFailure: () => ({ _tag: "Error" as const, message: "Invalid webhook payload" }),
149
+ onSuccess: (value) => ({ _tag: "Success" as const, value }),
150
+ }),
151
+ );
152
+ if (payloadResult._tag === "Error") {
153
+ return badRequest(payloadResult.message);
154
+ }
155
+
156
+ const response = yield* Effect.withSpan(
157
+ route
158
+ .handler(makeRouteContext(request, rawBodyResult.value, payloadResult.value, collected))
159
+ .pipe(Effect.tapError((error) => annotateError("webhook_handle", error))),
160
+ SpanName.webhookHandle,
161
+ { attributes: { [Attr.webhookPath]: route.path } },
162
+ );
163
+
164
+ const batches = compactBatches(collected);
165
+
166
+ if (route.ackMode === "after-enqueue") {
167
+ const queue = yield* WebhookQueue;
168
+ yield* Queue.offerAll(queue.queue, batches);
169
+ return response;
170
+ }
171
+
172
+ yield* Effect.withSpan(
173
+ publishBatches(batches).pipe(
174
+ Effect.tapError((error) => annotateError("webhook_publish", error)),
41
175
  ),
176
+ SpanName.webhookHandle,
177
+ { attributes: { [Attr.webhookPath]: route.path } },
42
178
  );
43
- yield* route.handle(payload, request, rawBody);
44
- // unsafeJson serializes synchronously — no Effect, no HttpBodyError
45
- return HttpServerResponse.jsonUnsafe({ ok: true });
179
+
180
+ return response;
46
181
  }).pipe(
47
- Effect.catchTag("InvalidWebhookPayloadError", () =>
48
- Effect.succeed(
49
- HttpServerResponse.jsonUnsafe(
50
- { ok: false, error: "Invalid webhook payload" },
51
- { status: 400 },
52
- ),
53
- ),
54
- ),
55
182
  Effect.catchCause((cause) =>
56
183
  Effect.logWarning(`Webhook handler error: ${Cause.pretty(cause)}`).pipe(
57
184
  Effect.andThen(
@@ -66,11 +193,7 @@ const makeHandler = <TPayload>(route: WebhookRoute<TPayload>) =>
66
193
  ),
67
194
  );
68
195
 
69
- export const buildWebhookRouter = <TPayload>(
70
- routes: ReadonlyArray<WebhookRoute<TPayload>>,
71
- ) =>
196
+ export const router = (routes: ReadonlyArray<WebhookRoute>) =>
72
197
  HttpRouter.addAll(
73
- routes.map((route) =>
74
- HttpRouter.route("POST", route.path, makeHandler(route)),
75
- ),
198
+ routes.map((route) => HttpRouter.route("POST", route.path, makeHandler(route))),
76
199
  );
@@ -1,16 +1,6 @@
1
- /** biome-ignore-all lint/suspicious/noExplicitAny: Effect Schema encodes invariantly; `any` on the encoded type is intentional. */
2
-
3
- import type { Effect } from "effect";
4
- import type * as Schema from "effect/Schema";
5
- import type { HttpRouter, HttpServerRequest } from "effect/unstable/http";
6
- import type { ConnectorError } from "../core/errors";
7
-
8
- export type WebhookRoute<TPayload> = {
9
- readonly path: HttpRouter.PathInput;
10
- readonly schema: Schema.Schema<TPayload>;
11
- readonly handle: (
12
- payload: TPayload,
13
- request: HttpServerRequest.HttpServerRequest,
14
- rawBody?: Uint8Array,
15
- ) => Effect.Effect<void, ConnectorError>;
16
- };
1
+ export type {
2
+ WebhookAckMode,
3
+ WebhookRoute,
4
+ WebhookRouteContext,
5
+ WebhookRouteInput,
6
+ } from "../core/types";
@@ -1,36 +0,0 @@
1
- import { Effect, Layer, Ref } from "effect";
2
- import * as ServiceMap from "effect/ServiceMap";
3
- import type { ConnectorError } from "../core/errors";
4
- import type { Cursor, IngestionState } from "../core/types";
5
-
6
- export class StateStore extends ServiceMap.Service<
7
- StateStore,
8
- {
9
- readonly getState: (
10
- key: string,
11
- ) => Effect.Effect<IngestionState<Cursor> | undefined, ConnectorError>;
12
- readonly setState: (
13
- key: string,
14
- state: IngestionState<Cursor>,
15
- ) => Effect.Effect<void, ConnectorError>;
16
- }
17
- >()("StateStore") {}
18
-
19
- export const StateStoreInMemory = Layer.effect(StateStore)(
20
- Effect.gen(function* () {
21
- const ref = yield* Ref.make(new Map<string, IngestionState<Cursor>>());
22
-
23
- return {
24
- getState: (key) => Effect.map(Ref.get(ref), (map) => map.get(key)),
25
- setState: (key, state) =>
26
- Effect.map(
27
- Ref.update(ref, (map) => {
28
- const next = new Map(map);
29
- next.set(key, state);
30
- return next;
31
- }),
32
- () => undefined,
33
- ),
34
- };
35
- }),
36
- );
@@ -1,55 +0,0 @@
1
- import { Effect, Stream } from "effect";
2
- import type { ConnectorError } from "../core/errors";
3
- import type { Batch, Cursor } from "../core/types";
4
-
5
- export type PullPage<T> = {
6
- readonly cursor: Cursor;
7
- readonly rows: ReadonlyArray<T>;
8
- readonly hasMore: boolean;
9
- };
10
-
11
- export type PullFetcher<T, R = never> = (
12
- cursor: Cursor | undefined,
13
- ) => Effect.Effect<PullPage<T>, ConnectorError, R>;
14
-
15
- export type PullStreamOptions<T, R = never> = {
16
- readonly initialCursor?: Cursor;
17
- readonly fetchPage: PullFetcher<T, R>;
18
- };
19
-
20
- export const makePullStream = <T, R = never>(
21
- options: PullStreamOptions<T, R>,
22
- ): Stream.Stream<Batch<T>, ConnectorError, R> =>
23
- Stream.unfold({ cursor: options.initialCursor, done: false }, (state) =>
24
- Effect.gen(function* () {
25
- if (state.done) {
26
- return undefined;
27
- }
28
-
29
- let nextCursor: Cursor | undefined = state.cursor;
30
-
31
- while (true) {
32
- const page = yield* options.fetchPage(nextCursor);
33
-
34
- if (page.rows.length > 0) {
35
- const batch: Batch<T> = {
36
- cursor: page.cursor,
37
- rows: page.rows,
38
- };
39
-
40
- return [
41
- batch,
42
- page.hasMore
43
- ? { cursor: page.cursor, done: false }
44
- : { cursor: undefined, done: true },
45
- ] as const;
46
- }
47
-
48
- if (!page.hasMore) {
49
- return undefined;
50
- }
51
-
52
- nextCursor = page.cursor;
53
- }
54
- }),
55
- );
@@ -1,15 +0,0 @@
1
- import { Effect, Queue, Stream } from "effect";
2
- import type { ConnectorError } from "../core/errors";
3
- import type { Batch, WebhookStream } from "../core/types";
4
-
5
- export const makeWebhookQueue = <T>(options?: {
6
- readonly capacity?: number;
7
- }): Effect.Effect<WebhookStream<T>, never> =>
8
- Effect.gen(function* () {
9
- const capacity = options?.capacity ?? 1024;
10
- const queue = yield* Queue.bounded<Batch<T>>(capacity);
11
- const stream: Stream.Stream<Batch<T>, ConnectorError> = Stream.fromQueue(
12
- queue,
13
- );
14
- return { queue, stream };
15
- });