@useairfoil/connector-kit 0.3.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 +127 -0
- package/dist/index.d.ts +166 -0
- package/dist/index.js +237 -0
- package/dist/index.js.map +1 -0
- package/package.json +48 -0
- package/src/core/builder.ts +21 -0
- package/src/core/errors.ts +6 -0
- package/src/core/types.ts +102 -0
- package/src/index.ts +28 -0
- package/src/ingestion/engine.ts +229 -0
- package/src/ingestion/state-store.ts +36 -0
- package/src/publisher/service.ts +18 -0
- package/src/publisher/wings.ts +124 -0
- package/src/streams/pull-stream.ts +55 -0
- package/src/streams/webhook-queue.ts +15 -0
- package/src/webhook/server.ts +76 -0
- package/src/webhook/types.ts +16 -0
package/README.md
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# @useairfoil/connector-kit
|
|
2
|
+
|
|
3
|
+
Toolkit for building Airfoil connectors with Effect.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## User guide (build a connector)
|
|
8
|
+
|
|
9
|
+
This section is for connector authors who want to build and run a connector.
|
|
10
|
+
|
|
11
|
+
### Install
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
bun add @useairfoil/connector-kit
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
### Minimal example
|
|
18
|
+
|
|
19
|
+
```ts
|
|
20
|
+
import { Schema, Effect, Layer, Stream } from "effect";
|
|
21
|
+
import {
|
|
22
|
+
defineConnector,
|
|
23
|
+
defineEntity,
|
|
24
|
+
Publisher,
|
|
25
|
+
runConnector,
|
|
26
|
+
StateStoreInMemory,
|
|
27
|
+
WebhookServerLayer,
|
|
28
|
+
makeWebhookQueue,
|
|
29
|
+
} from "@useairfoil/connector-kit";
|
|
30
|
+
|
|
31
|
+
const Customer = Schema.Struct({
|
|
32
|
+
id: Schema.String,
|
|
33
|
+
email: Schema.String,
|
|
34
|
+
created_at: Schema.Date,
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const program = Effect.gen(function* () {
|
|
38
|
+
const webhook = yield* makeWebhookQueue<Schema.Schema.Type<typeof Customer>>();
|
|
39
|
+
|
|
40
|
+
const connector = defineConnector({
|
|
41
|
+
name: "producer-example",
|
|
42
|
+
entities: [
|
|
43
|
+
defineEntity({
|
|
44
|
+
name: "customers",
|
|
45
|
+
schema: Customer,
|
|
46
|
+
primaryKey: "id",
|
|
47
|
+
live: webhook.queue,
|
|
48
|
+
backfill: Stream.empty,
|
|
49
|
+
}),
|
|
50
|
+
],
|
|
51
|
+
events: [],
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
yield* runConnector(connector, new Date());
|
|
55
|
+
}).pipe(
|
|
56
|
+
Effect.provide(StateStoreInMemory),
|
|
57
|
+
Effect.provide(
|
|
58
|
+
Layer.succeed(Publisher, {
|
|
59
|
+
publish: () => Effect.succeed({ requestId: 1n }),
|
|
60
|
+
}),
|
|
61
|
+
),
|
|
62
|
+
Effect.provide(
|
|
63
|
+
WebhookServerLayer({
|
|
64
|
+
port: 8080,
|
|
65
|
+
routes: [
|
|
66
|
+
{
|
|
67
|
+
path: "/webhook/customers",
|
|
68
|
+
schema: Customer,
|
|
69
|
+
dispatch: (payload) =>
|
|
70
|
+
Effect.succeed([
|
|
71
|
+
{
|
|
72
|
+
queue: webhook.queue,
|
|
73
|
+
batch: { cursor: new Date(), rows: [payload] },
|
|
74
|
+
},
|
|
75
|
+
]),
|
|
76
|
+
},
|
|
77
|
+
],
|
|
78
|
+
}),
|
|
79
|
+
),
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
Effect.runPromise(program);
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
---
|
|
86
|
+
|
|
87
|
+
## Development (concepts and architecture)
|
|
88
|
+
|
|
89
|
+
### Core concepts
|
|
90
|
+
|
|
91
|
+
- `defineConnector` describes your connector and its entities.
|
|
92
|
+
- `defineEntity` wires live and backfill streams for each entity.
|
|
93
|
+
- `Publisher` is the output boundary (where batches go).
|
|
94
|
+
- `StateStore` tracks cursors and backfill state.
|
|
95
|
+
- `WebhookServerLayer` turns webhook routes into an HTTP server.
|
|
96
|
+
|
|
97
|
+
### Layers and Effect services
|
|
98
|
+
|
|
99
|
+
Connector-kit is designed around Effect services and Layers. Your application should provide:
|
|
100
|
+
|
|
101
|
+
- a `Publisher` Layer
|
|
102
|
+
- a `StateStore` Layer
|
|
103
|
+
- an HTTP server Layer (if you use webhooks)
|
|
104
|
+
- any custom services your connector needs (API clients, Effect Config)
|
|
105
|
+
|
|
106
|
+
### Testing with VCR
|
|
107
|
+
|
|
108
|
+
VCR is provided via `@useairfoil/effect-http-client` as an Effect `HttpClient` layer. This keeps HTTP recording out of connector logic.
|
|
109
|
+
|
|
110
|
+
```ts
|
|
111
|
+
import { FetchHttpClient } from "@effect/platform";
|
|
112
|
+
import { NodeFileSystem } from "@effect/platform-node";
|
|
113
|
+
import { CassetteStoreLive, VcrHttpClientLayer } from "@useairfoil/effect-http-client";
|
|
114
|
+
import { Layer } from "effect";
|
|
115
|
+
|
|
116
|
+
const cassetteLayer = CassetteStoreLive.pipe(
|
|
117
|
+
Layer.provide(NodeFileSystem.layer),
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
const vcrLayer = VcrHttpClientLayer({
|
|
121
|
+
cassetteDir: "cassettes",
|
|
122
|
+
cassetteName: "example",
|
|
123
|
+
mode: "auto",
|
|
124
|
+
}).pipe(
|
|
125
|
+
Layer.provide(Layer.mergeAll(FetchHttpClient.layer, cassetteLayer)),
|
|
126
|
+
);
|
|
127
|
+
```
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { Effect, Layer, Queue, Schema, Stream } from "effect";
|
|
2
|
+
import * as ServiceMap from "effect/ServiceMap";
|
|
3
|
+
import * as Wings from "@useairfoil/wings";
|
|
4
|
+
import { PartitionValue } from "@useairfoil/wings";
|
|
5
|
+
import { HttpRouter, HttpServerRequest } from "effect/unstable/http";
|
|
6
|
+
import * as effect_Types0 from "effect/Types";
|
|
7
|
+
import * as effect_Cause0 from "effect/Cause";
|
|
8
|
+
import * as Effect$1 from "effect/Effect";
|
|
9
|
+
import * as effect_Layer0 from "effect/Layer";
|
|
10
|
+
import * as Schema$1 from "effect/Schema";
|
|
11
|
+
|
|
12
|
+
//#region src/core/errors.d.ts
|
|
13
|
+
declare const ConnectorError_base: new <A extends Record<string, any> = {}>(args: effect_Types0.MatchRecord<{ readonly [P in keyof A as P extends "_tag" ? never : P]: A[P] }, void, { readonly [P in keyof A as P extends "_tag" ? never : P]: A[P] }>) => effect_Cause0.YieldableError & {
|
|
14
|
+
readonly _tag: "ConnectorError";
|
|
15
|
+
} & Readonly<A>;
|
|
16
|
+
declare class ConnectorError extends ConnectorError_base<{
|
|
17
|
+
readonly message: string;
|
|
18
|
+
readonly cause?: unknown;
|
|
19
|
+
}> {}
|
|
20
|
+
//#endregion
|
|
21
|
+
//#region src/core/types.d.ts
|
|
22
|
+
type Cursor = string | number | bigint | Date;
|
|
23
|
+
type Batch<T> = {
|
|
24
|
+
readonly cursor: Cursor;
|
|
25
|
+
readonly rows: ReadonlyArray<T>;
|
|
26
|
+
};
|
|
27
|
+
type StreamState<C = Cursor> = {
|
|
28
|
+
readonly cutoff: C;
|
|
29
|
+
readonly current?: C;
|
|
30
|
+
};
|
|
31
|
+
type IngestionState<C = Cursor> = {
|
|
32
|
+
readonly backfill: StreamState<C>;
|
|
33
|
+
readonly live: StreamState<C>;
|
|
34
|
+
};
|
|
35
|
+
type Transform<T> = (row: T) => Effect.Effect<T, ConnectorError>;
|
|
36
|
+
type LiveStream<T> = Stream.Stream<Batch<T>, ConnectorError>;
|
|
37
|
+
type BackfillStream<T> = Stream.Stream<Batch<T>, ConnectorError>;
|
|
38
|
+
type WebhookStream<T> = {
|
|
39
|
+
/**
|
|
40
|
+
* The queue is used to store the batches that are received from the webhook.
|
|
41
|
+
*/
|
|
42
|
+
readonly queue: Queue.Queue<Batch<T>>;
|
|
43
|
+
/**
|
|
44
|
+
* The stream is used to process the batches that are received from the webhook.
|
|
45
|
+
*/
|
|
46
|
+
readonly stream: Stream.Stream<Batch<T>, ConnectorError>;
|
|
47
|
+
};
|
|
48
|
+
type LiveSource<T> = LiveStream<T> | WebhookStream<T>;
|
|
49
|
+
/** Schema type used by connector definitions. */
|
|
50
|
+
type EntitySchema = Schema.Schema<unknown>;
|
|
51
|
+
/** Decoded row type produced by a schema. */
|
|
52
|
+
type EntityType<S extends EntitySchema> = Schema.Schema.Type<S>;
|
|
53
|
+
/** Primary key type derived from the decoded schema shape. */
|
|
54
|
+
type EntityKey<S extends EntitySchema> = EntityType<S> extends Record<string, unknown> ? keyof EntityType<S> & string : never;
|
|
55
|
+
/** Row type constrained to object-like shapes for ingestion. */
|
|
56
|
+
type EntityRow<S extends EntitySchema> = EntityType<S> & Record<string, unknown>;
|
|
57
|
+
type EntityDefinition<S extends EntitySchema> = {
|
|
58
|
+
readonly name: string;
|
|
59
|
+
readonly schema: S;
|
|
60
|
+
/**
|
|
61
|
+
* The primary key is used to identify the rows in the database.
|
|
62
|
+
*/
|
|
63
|
+
readonly primaryKey: EntityKey<S>;
|
|
64
|
+
/**
|
|
65
|
+
* The live source is used to stream the live data from the database.
|
|
66
|
+
*/
|
|
67
|
+
readonly live: LiveSource<EntityRow<S>>;
|
|
68
|
+
/**
|
|
69
|
+
* The backfill stream is used to stream the backfill data from the database.
|
|
70
|
+
*/
|
|
71
|
+
readonly backfill: BackfillStream<EntityRow<S>>;
|
|
72
|
+
/**
|
|
73
|
+
* The transform is used to transform the rows before they are published.
|
|
74
|
+
*/
|
|
75
|
+
readonly transform?: Transform<EntityRow<S>>;
|
|
76
|
+
};
|
|
77
|
+
type EventDefinition<S extends EntitySchema> = {
|
|
78
|
+
readonly name: string;
|
|
79
|
+
readonly schema: S;
|
|
80
|
+
/**
|
|
81
|
+
* The live source is used to stream the live data from the database.
|
|
82
|
+
*/
|
|
83
|
+
readonly live: LiveSource<EntityRow<S>>;
|
|
84
|
+
/**
|
|
85
|
+
* The backfill stream is used to stream the backfill data from the database.
|
|
86
|
+
*/
|
|
87
|
+
readonly backfill?: BackfillStream<EntityRow<S>>;
|
|
88
|
+
/**
|
|
89
|
+
* The transform is used to transform the rows before they are published.
|
|
90
|
+
*/
|
|
91
|
+
readonly transform?: Transform<EntityRow<S>>;
|
|
92
|
+
};
|
|
93
|
+
type ConnectorDefinition<Entities extends ReadonlyArray<EntityDefinition<EntitySchema>> = ReadonlyArray<EntityDefinition<EntitySchema>>, Events extends ReadonlyArray<EventDefinition<EntitySchema>> = ReadonlyArray<EventDefinition<EntitySchema>>> = {
|
|
94
|
+
readonly name: string;
|
|
95
|
+
readonly entities: Entities;
|
|
96
|
+
readonly events: Events;
|
|
97
|
+
};
|
|
98
|
+
//#endregion
|
|
99
|
+
//#region src/core/builder.d.ts
|
|
100
|
+
declare const defineConnector: <const Entities extends ReadonlyArray<EntityDefinition<EntitySchema>>, const Events extends ReadonlyArray<EventDefinition<EntitySchema>>>(definition: ConnectorDefinition<Entities, Events>) => ConnectorDefinition<Entities, Events>;
|
|
101
|
+
declare const defineEntity: <S extends EntitySchema>(definition: EntityDefinition<S>) => EntityDefinition<S>;
|
|
102
|
+
declare const defineEvent: <S extends EntitySchema>(definition: EventDefinition<S>) => EventDefinition<S>;
|
|
103
|
+
//#endregion
|
|
104
|
+
//#region src/publisher/service.d.ts
|
|
105
|
+
type PublishAck = {
|
|
106
|
+
readonly success: boolean;
|
|
107
|
+
};
|
|
108
|
+
declare const Publisher_base: ServiceMap.ServiceClass<Publisher, "Publisher", {
|
|
109
|
+
readonly publish: (options: {
|
|
110
|
+
readonly name: string;
|
|
111
|
+
readonly batch: Batch<Record<string, unknown>>;
|
|
112
|
+
}) => Effect$1.Effect<PublishAck, ConnectorError>;
|
|
113
|
+
}>;
|
|
114
|
+
declare class Publisher extends Publisher_base {}
|
|
115
|
+
//#endregion
|
|
116
|
+
//#region src/ingestion/state-store.d.ts
|
|
117
|
+
declare const StateStore_base: ServiceMap.ServiceClass<StateStore, "StateStore", {
|
|
118
|
+
readonly getState: (key: string) => Effect.Effect<IngestionState<Cursor> | undefined, ConnectorError>;
|
|
119
|
+
readonly setState: (key: string, state: IngestionState<Cursor>) => Effect.Effect<void, ConnectorError>;
|
|
120
|
+
}>;
|
|
121
|
+
declare class StateStore extends StateStore_base {}
|
|
122
|
+
declare const StateStoreInMemory: Layer.Layer<StateStore, never, never>;
|
|
123
|
+
//#endregion
|
|
124
|
+
//#region src/ingestion/engine.d.ts
|
|
125
|
+
declare const runConnector: (connector: ConnectorDefinition, initialCutoff: Cursor) => Effect.Effect<void, ConnectorError, StateStore | Publisher>;
|
|
126
|
+
//#endregion
|
|
127
|
+
//#region src/publisher/wings.d.ts
|
|
128
|
+
type WingsPublisherConfig = {
|
|
129
|
+
readonly connector: ConnectorDefinition;
|
|
130
|
+
/** Map of entity/event name to Wings topic. */
|
|
131
|
+
readonly topics: Record<string, Wings.Cluster.Topic.Topic>;
|
|
132
|
+
/** per-stream partition value (key is entity/event name). */
|
|
133
|
+
readonly partitionValues?: Record<string, PartitionValue>;
|
|
134
|
+
};
|
|
135
|
+
declare const WingsPublisherLayer: (config: WingsPublisherConfig) => Layer.Layer<Publisher, ConnectorError, Wings.WingsClient.WingsClient>;
|
|
136
|
+
//#endregion
|
|
137
|
+
//#region src/streams/pull-stream.d.ts
|
|
138
|
+
type PullPage<T> = {
|
|
139
|
+
readonly cursor: Cursor;
|
|
140
|
+
readonly rows: ReadonlyArray<T>;
|
|
141
|
+
readonly hasMore: boolean;
|
|
142
|
+
};
|
|
143
|
+
type PullFetcher<T, R = never> = (cursor: Cursor | undefined) => Effect.Effect<PullPage<T>, ConnectorError, R>;
|
|
144
|
+
type PullStreamOptions<T, R = never> = {
|
|
145
|
+
readonly initialCursor?: Cursor;
|
|
146
|
+
readonly fetchPage: PullFetcher<T, R>;
|
|
147
|
+
};
|
|
148
|
+
declare const makePullStream: <T, R = never>(options: PullStreamOptions<T, R>) => Stream.Stream<Batch<T>, ConnectorError, R>;
|
|
149
|
+
//#endregion
|
|
150
|
+
//#region src/streams/webhook-queue.d.ts
|
|
151
|
+
declare const makeWebhookQueue: <T>(options?: {
|
|
152
|
+
readonly capacity?: number;
|
|
153
|
+
}) => Effect.Effect<WebhookStream<T>, never>;
|
|
154
|
+
//#endregion
|
|
155
|
+
//#region src/webhook/types.d.ts
|
|
156
|
+
type WebhookRoute<TPayload> = {
|
|
157
|
+
readonly path: HttpRouter.PathInput;
|
|
158
|
+
readonly schema: Schema$1.Schema<TPayload>;
|
|
159
|
+
readonly handle: (payload: TPayload, request: HttpServerRequest.HttpServerRequest, rawBody?: Uint8Array) => Effect.Effect<void, ConnectorError>;
|
|
160
|
+
};
|
|
161
|
+
//#endregion
|
|
162
|
+
//#region src/webhook/server.d.ts
|
|
163
|
+
declare const buildWebhookRouter: <TPayload>(routes: ReadonlyArray<WebhookRoute<TPayload>>) => effect_Layer0.Layer<never, never, HttpRouter.HttpRouter | HttpRouter.Request<"Requires", unknown>>;
|
|
164
|
+
//#endregion
|
|
165
|
+
export { type BackfillStream, type Batch, type ConnectorDefinition, ConnectorError, type Cursor, type EntityDefinition, type EntityKey, type EntityRow, type EntitySchema, type EntityType, type EventDefinition, type IngestionState, type LiveSource, type LiveStream, Publisher, StateStore, StateStoreInMemory, type StreamState, type Transform, type WebhookRoute, type WebhookStream, WingsPublisherLayer, buildWebhookRouter, defineConnector, defineEntity, defineEvent, makePullStream, makeWebhookQueue, runConnector };
|
|
166
|
+
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import { Cause, Data, Effect, Layer, Queue, Ref, Schema, Stream } from "effect";
|
|
2
|
+
import * as ServiceMap from "effect/ServiceMap";
|
|
3
|
+
import * as Wings from "@useairfoil/wings";
|
|
4
|
+
import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http";
|
|
5
|
+
|
|
6
|
+
//#region src/core/builder.ts
|
|
7
|
+
const defineConnector = (definition) => definition;
|
|
8
|
+
const defineEntity = (definition) => definition;
|
|
9
|
+
const defineEvent = (definition) => definition;
|
|
10
|
+
|
|
11
|
+
//#endregion
|
|
12
|
+
//#region src/core/errors.ts
|
|
13
|
+
var ConnectorError = class extends Data.TaggedError("ConnectorError") {};
|
|
14
|
+
|
|
15
|
+
//#endregion
|
|
16
|
+
//#region src/publisher/service.ts
|
|
17
|
+
var Publisher = class extends ServiceMap.Service()("Publisher") {};
|
|
18
|
+
|
|
19
|
+
//#endregion
|
|
20
|
+
//#region src/ingestion/state-store.ts
|
|
21
|
+
var StateStore = class extends ServiceMap.Service()("StateStore") {};
|
|
22
|
+
const StateStoreInMemory = Layer.effect(StateStore)(Effect.gen(function* () {
|
|
23
|
+
const ref = yield* Ref.make(/* @__PURE__ */ new Map());
|
|
24
|
+
return {
|
|
25
|
+
getState: (key) => Effect.map(Ref.get(ref), (map) => map.get(key)),
|
|
26
|
+
setState: (key, state) => Effect.map(Ref.update(ref, (map) => {
|
|
27
|
+
const next = new Map(map);
|
|
28
|
+
next.set(key, state);
|
|
29
|
+
return next;
|
|
30
|
+
}), () => void 0)
|
|
31
|
+
};
|
|
32
|
+
}));
|
|
33
|
+
|
|
34
|
+
//#endregion
|
|
35
|
+
//#region src/ingestion/engine.ts
|
|
36
|
+
const runConnector = (connector, initialCutoff) => Effect.gen(function* () {
|
|
37
|
+
const entityRuns = connector.entities.map((entity) => runEntity(entity, initialCutoff));
|
|
38
|
+
const eventRuns = connector.events.map((event) => runEvent(event, initialCutoff));
|
|
39
|
+
yield* Effect.all([...entityRuns, ...eventRuns], { concurrency: "unbounded" });
|
|
40
|
+
});
|
|
41
|
+
const createInitialState = (cutoff) => ({
|
|
42
|
+
backfill: { cutoff },
|
|
43
|
+
live: { cutoff }
|
|
44
|
+
});
|
|
45
|
+
const makeStateRef = (key, initialCutoff) => Effect.gen(function* () {
|
|
46
|
+
const initial = (yield* (yield* StateStore).getState(key)) ?? createInitialState(initialCutoff);
|
|
47
|
+
return yield* Ref.make(initial);
|
|
48
|
+
});
|
|
49
|
+
const runEntity = (entity, initialCutoff) => Effect.gen(function* () {
|
|
50
|
+
const stateRef = yield* makeStateRef(entity.name, initialCutoff);
|
|
51
|
+
const seenRef = yield* Ref.make(/* @__PURE__ */ new Set());
|
|
52
|
+
const liveStream = resolveLiveStream(entity.live);
|
|
53
|
+
const tagLive = (batch) => ({
|
|
54
|
+
source: "live",
|
|
55
|
+
batch
|
|
56
|
+
});
|
|
57
|
+
const updateSeen = (rows) => Ref.update(seenRef, (seen) => {
|
|
58
|
+
const next = new Set(seen);
|
|
59
|
+
for (const row of rows) {
|
|
60
|
+
const key = String(row[entity.primaryKey]);
|
|
61
|
+
next.add(key);
|
|
62
|
+
}
|
|
63
|
+
return next;
|
|
64
|
+
});
|
|
65
|
+
const backfillTagged = Stream.mapEffect(entity.backfill, (batch) => Ref.get(seenRef).pipe(Effect.map((seen) => {
|
|
66
|
+
const filtered = batch.rows.filter((row) => {
|
|
67
|
+
const key = String(row[entity.primaryKey]);
|
|
68
|
+
return !seen.has(key);
|
|
69
|
+
});
|
|
70
|
+
return {
|
|
71
|
+
source: "backfill",
|
|
72
|
+
batch: {
|
|
73
|
+
cursor: batch.cursor,
|
|
74
|
+
rows: filtered
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
}))).pipe(Stream.tap(({ batch }) => updateSeen(batch.rows)));
|
|
78
|
+
if (isWebhookStream(entity.live)) {
|
|
79
|
+
const firstBatch = yield* Queue.take(entity.live.queue);
|
|
80
|
+
yield* updateSeen(firstBatch.rows);
|
|
81
|
+
yield* processTaggedStream(Stream.make({
|
|
82
|
+
source: "live",
|
|
83
|
+
batch: firstBatch
|
|
84
|
+
}), entity.name, entity.transform, stateRef);
|
|
85
|
+
const liveTailTagged = Stream.map(liveStream, tagLive).pipe(Stream.tap(({ batch }) => updateSeen(batch.rows)));
|
|
86
|
+
yield* processTaggedStream(Stream.merge(liveTailTagged, backfillTagged), entity.name, entity.transform, stateRef);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
const liveTagged = Stream.map(liveStream, tagLive).pipe(Stream.tap(({ batch }) => updateSeen(batch.rows)));
|
|
90
|
+
yield* processTaggedStream(Stream.merge(liveTagged, backfillTagged), entity.name, entity.transform, stateRef);
|
|
91
|
+
});
|
|
92
|
+
const runEvent = (event, initialCutoff) => Effect.gen(function* () {
|
|
93
|
+
const stateRef = yield* makeStateRef(event.name, initialCutoff);
|
|
94
|
+
const liveStream = resolveLiveStream(event.live);
|
|
95
|
+
if (event.backfill) yield* processTaggedStream(Stream.map(event.backfill, (batch) => ({
|
|
96
|
+
source: "backfill",
|
|
97
|
+
batch
|
|
98
|
+
})), event.name, event.transform, stateRef);
|
|
99
|
+
yield* processTaggedStream(Stream.map(liveStream, (batch) => ({
|
|
100
|
+
source: "live",
|
|
101
|
+
batch
|
|
102
|
+
})), event.name, event.transform, stateRef);
|
|
103
|
+
});
|
|
104
|
+
const updateState = (state, source, cursor) => source === "live" ? {
|
|
105
|
+
...state,
|
|
106
|
+
live: {
|
|
107
|
+
...state.live,
|
|
108
|
+
current: cursor
|
|
109
|
+
}
|
|
110
|
+
} : {
|
|
111
|
+
...state,
|
|
112
|
+
backfill: {
|
|
113
|
+
...state.backfill,
|
|
114
|
+
current: cursor
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
const resolveLiveStream = (source) => isWebhookStream(source) ? source.stream : source;
|
|
118
|
+
const isWebhookStream = (source) => typeof source === "object" && source !== null && "queue" in source && "stream" in source;
|
|
119
|
+
const processTaggedStream = (stream, name, transform, stateRef) => Stream.runForEach(stream, ({ source, batch }) => Effect.gen(function* () {
|
|
120
|
+
const rows = transform ? yield* Effect.forEach(batch.rows, transform) : batch.rows;
|
|
121
|
+
yield* (yield* Publisher).publish({
|
|
122
|
+
name,
|
|
123
|
+
batch: {
|
|
124
|
+
cursor: batch.cursor,
|
|
125
|
+
rows
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
const nextState = yield* Ref.updateAndGet(stateRef, (state) => updateState(state, source, batch.cursor));
|
|
129
|
+
yield* (yield* StateStore).setState(name, nextState);
|
|
130
|
+
}));
|
|
131
|
+
|
|
132
|
+
//#endregion
|
|
133
|
+
//#region src/publisher/wings.ts
|
|
134
|
+
const buildRecordBatch = (rows) => {
|
|
135
|
+
const [batch] = Wings.tableFromJSON(Array.from(rows)).batches;
|
|
136
|
+
if (!batch) throw new ConnectorError({ message: "No rows to publish" });
|
|
137
|
+
return batch;
|
|
138
|
+
};
|
|
139
|
+
const WingsPublisherLayer = (config) => Layer.effect(Publisher)(Effect.gen(function* () {
|
|
140
|
+
const entries = /* @__PURE__ */ new Map();
|
|
141
|
+
for (const def of [...config.connector.entities, ...config.connector.events]) {
|
|
142
|
+
const topic = config.topics[def.name];
|
|
143
|
+
if (!topic) return yield* Effect.fail(new ConnectorError({ message: `Missing topic for ${def.name}` }));
|
|
144
|
+
const partitionIndex = topic.partitionKey !== void 0 ? topic.schema.fields.findIndex((field) => field.id === topic.partitionKey) : void 0;
|
|
145
|
+
const partitionField = partitionIndex !== void 0 && partitionIndex >= 0 ? topic.schema.fields[partitionIndex]?.name : void 0;
|
|
146
|
+
const publisher = yield* Wings.WingsClient.publisher({
|
|
147
|
+
topic,
|
|
148
|
+
partitionValue: config.partitionValues?.[def.name]
|
|
149
|
+
}).pipe(Effect.mapError((error) => new ConnectorError({
|
|
150
|
+
message: error.message,
|
|
151
|
+
cause: error
|
|
152
|
+
})));
|
|
153
|
+
entries.set(def.name, {
|
|
154
|
+
publisher,
|
|
155
|
+
partitionField,
|
|
156
|
+
partitionValue: config.partitionValues?.[def.name]
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
return { publish: ({ name, batch }) => Effect.gen(function* () {
|
|
160
|
+
const entry = entries.get(name);
|
|
161
|
+
if (!entry) return yield* Effect.fail(new ConnectorError({ message: `Unknown stream ${name}` }));
|
|
162
|
+
if (batch.rows.length === 0) return { success: true };
|
|
163
|
+
const recordBatch = buildRecordBatch(batch.rows);
|
|
164
|
+
const result = yield* entry.publisher.push({
|
|
165
|
+
batch: recordBatch,
|
|
166
|
+
partitionValue: entry.partitionValue
|
|
167
|
+
}).pipe(Effect.mapError((error) => new ConnectorError({
|
|
168
|
+
message: error.message,
|
|
169
|
+
cause: error
|
|
170
|
+
})));
|
|
171
|
+
return { success: !!(result.result && result.result.$case === "accepted") };
|
|
172
|
+
}) };
|
|
173
|
+
}));
|
|
174
|
+
|
|
175
|
+
//#endregion
|
|
176
|
+
//#region src/streams/pull-stream.ts
|
|
177
|
+
const makePullStream = (options) => Stream.unfold({
|
|
178
|
+
cursor: options.initialCursor,
|
|
179
|
+
done: false
|
|
180
|
+
}, (state) => Effect.gen(function* () {
|
|
181
|
+
if (state.done) return;
|
|
182
|
+
let nextCursor = state.cursor;
|
|
183
|
+
while (true) {
|
|
184
|
+
const page = yield* options.fetchPage(nextCursor);
|
|
185
|
+
if (page.rows.length > 0) return [{
|
|
186
|
+
cursor: page.cursor,
|
|
187
|
+
rows: page.rows
|
|
188
|
+
}, page.hasMore ? {
|
|
189
|
+
cursor: page.cursor,
|
|
190
|
+
done: false
|
|
191
|
+
} : {
|
|
192
|
+
cursor: void 0,
|
|
193
|
+
done: true
|
|
194
|
+
}];
|
|
195
|
+
if (!page.hasMore) return;
|
|
196
|
+
nextCursor = page.cursor;
|
|
197
|
+
}
|
|
198
|
+
}));
|
|
199
|
+
|
|
200
|
+
//#endregion
|
|
201
|
+
//#region src/streams/webhook-queue.ts
|
|
202
|
+
const makeWebhookQueue = (options) => Effect.gen(function* () {
|
|
203
|
+
const capacity = options?.capacity ?? 1024;
|
|
204
|
+
const queue = yield* Queue.bounded(capacity);
|
|
205
|
+
return {
|
|
206
|
+
queue,
|
|
207
|
+
stream: Stream.fromQueue(queue)
|
|
208
|
+
};
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
//#endregion
|
|
212
|
+
//#region src/webhook/server.ts
|
|
213
|
+
var InvalidWebhookPayloadError = class extends Data.TaggedError("InvalidWebhookPayloadError") {};
|
|
214
|
+
const makeHandler = (route) => Effect.gen(function* () {
|
|
215
|
+
const request = yield* HttpServerRequest.HttpServerRequest;
|
|
216
|
+
const rawBuffer = yield* request.arrayBuffer.pipe(Effect.mapError(() => new InvalidWebhookPayloadError({ message: "Failed to read request body" })));
|
|
217
|
+
const rawBody = new Uint8Array(rawBuffer);
|
|
218
|
+
const rawText = new TextDecoder().decode(rawBody);
|
|
219
|
+
const rawJson = yield* Effect.try({
|
|
220
|
+
try: () => JSON.parse(rawText),
|
|
221
|
+
catch: () => new InvalidWebhookPayloadError({ message: "Invalid JSON body" })
|
|
222
|
+
});
|
|
223
|
+
const payload = yield* Schema.decodeUnknownEffect(route.schema)(rawJson).pipe(Effect.mapError(() => new InvalidWebhookPayloadError({ message: "Invalid webhook payload" })));
|
|
224
|
+
yield* route.handle(payload, request, rawBody);
|
|
225
|
+
return HttpServerResponse.jsonUnsafe({ ok: true });
|
|
226
|
+
}).pipe(Effect.catchTag("InvalidWebhookPayloadError", () => Effect.succeed(HttpServerResponse.jsonUnsafe({
|
|
227
|
+
ok: false,
|
|
228
|
+
error: "Invalid webhook payload"
|
|
229
|
+
}, { status: 400 }))), Effect.catchCause((cause) => Effect.logWarning(`Webhook handler error: ${Cause.pretty(cause)}`).pipe(Effect.andThen(Effect.succeed(HttpServerResponse.jsonUnsafe({
|
|
230
|
+
ok: false,
|
|
231
|
+
error: "Webhook handler failed"
|
|
232
|
+
}, { status: 500 }))))));
|
|
233
|
+
const buildWebhookRouter = (routes) => HttpRouter.addAll(routes.map((route) => HttpRouter.route("POST", route.path, makeHandler(route))));
|
|
234
|
+
|
|
235
|
+
//#endregion
|
|
236
|
+
export { ConnectorError, Publisher, StateStore, StateStoreInMemory, WingsPublisherLayer, buildWebhookRouter, defineConnector, defineEntity, defineEvent, makePullStream, makeWebhookQueue, runConnector };
|
|
237
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","names":["nextCursor: Cursor | undefined"],"sources":["../src/core/builder.ts","../src/core/errors.ts","../src/publisher/service.ts","../src/ingestion/state-store.ts","../src/ingestion/engine.ts","../src/publisher/wings.ts","../src/streams/pull-stream.ts","../src/streams/webhook-queue.ts","../src/webhook/server.ts"],"sourcesContent":["import type {\n ConnectorDefinition,\n EntityDefinition,\n EntitySchema,\n EventDefinition,\n} from \"./types\";\n\nexport const defineConnector = <\n const Entities extends ReadonlyArray<EntityDefinition<EntitySchema>>,\n const Events extends ReadonlyArray<EventDefinition<EntitySchema>>,\n>(\n definition: ConnectorDefinition<Entities, Events>,\n) => definition;\n\nexport const defineEntity = <S extends EntitySchema>(\n definition: EntityDefinition<S>,\n) => definition;\n\nexport const defineEvent = <S extends EntitySchema>(\n definition: EventDefinition<S>,\n) => definition;\n","import { Data } from \"effect\";\n\nexport class ConnectorError extends Data.TaggedError(\"ConnectorError\")<{\n readonly message: string;\n readonly cause?: unknown;\n}> {}\n","import type * as Effect from \"effect/Effect\";\nimport * as ServiceMap from \"effect/ServiceMap\";\nimport type { ConnectorError } from \"../core/errors\";\nimport type { Batch } from \"../core/types\";\n\nexport type PublishAck = {\n readonly success: boolean;\n};\n\nexport class Publisher extends ServiceMap.Service<\n Publisher,\n {\n readonly publish: (options: {\n readonly name: string;\n readonly batch: Batch<Record<string, unknown>>;\n }) => Effect.Effect<PublishAck, ConnectorError>;\n }\n>()(\"Publisher\") {}\n","import { Effect, Layer, Ref } from \"effect\";\nimport * as ServiceMap from \"effect/ServiceMap\";\nimport type { ConnectorError } from \"../core/errors\";\nimport type { Cursor, IngestionState } from \"../core/types\";\n\nexport class StateStore extends ServiceMap.Service<\n StateStore,\n {\n readonly getState: (\n key: string,\n ) => Effect.Effect<IngestionState<Cursor> | undefined, ConnectorError>;\n readonly setState: (\n key: string,\n state: IngestionState<Cursor>,\n ) => Effect.Effect<void, ConnectorError>;\n }\n>()(\"StateStore\") {}\n\nexport const StateStoreInMemory = Layer.effect(StateStore)(\n Effect.gen(function* () {\n const ref = yield* Ref.make(new Map<string, IngestionState<Cursor>>());\n\n return {\n getState: (key) => Effect.map(Ref.get(ref), (map) => map.get(key)),\n setState: (key, state) =>\n Effect.map(\n Ref.update(ref, (map) => {\n const next = new Map(map);\n next.set(key, state);\n return next;\n }),\n () => undefined,\n ),\n };\n }),\n);\n","import { Effect, Queue, Ref, Stream } from \"effect\";\nimport type { ConnectorError } from \"../core/errors\";\nimport type {\n Batch,\n ConnectorDefinition,\n Cursor,\n EntityDefinition,\n EntityRow,\n EntitySchema,\n EventDefinition,\n IngestionState,\n LiveSource,\n Transform,\n WebhookStream,\n} from \"../core/types\";\nimport { Publisher } from \"../publisher/service\";\nimport { StateStore } from \"./state-store\";\n\ntype TaggedBatch<T> = {\n readonly source: \"live\" | \"backfill\";\n readonly batch: Batch<T>;\n};\n\nexport const runConnector = (\n connector: ConnectorDefinition,\n initialCutoff: Cursor,\n): Effect.Effect<void, ConnectorError, StateStore | Publisher> =>\n Effect.gen(function* () {\n // Start ingestion for every entity and event in parallel.\n const entityRuns = connector.entities.map((entity) =>\n runEntity(entity, initialCutoff),\n );\n const eventRuns = connector.events.map((event) =>\n runEvent(event, initialCutoff),\n );\n // main runner\n yield* Effect.all([...entityRuns, ...eventRuns], {\n concurrency: \"unbounded\",\n });\n });\n\nconst createInitialState = (cutoff: Cursor): IngestionState<Cursor> => ({\n backfill: { cutoff },\n live: { cutoff },\n});\n\nconst makeStateRef = (\n key: string,\n initialCutoff: Cursor,\n): Effect.Effect<Ref.Ref<IngestionState<Cursor>>, ConnectorError, StateStore> =>\n Effect.gen(function* () {\n // Load persisted state or initialize a new one for this stream.\n const store = yield* StateStore;\n const existing = yield* store.getState(key);\n const initial = existing ?? createInitialState(initialCutoff);\n return yield* Ref.make(initial);\n });\n\nconst runEntity = <S extends EntitySchema>(\n entity: EntityDefinition<S>,\n initialCutoff: Cursor,\n): Effect.Effect<void, ConnectorError, StateStore | Publisher> =>\n Effect.gen(function* () {\n type Row = EntityRow<S>;\n const stateRef = yield* makeStateRef(entity.name, initialCutoff);\n // Tracks which primary keys have already been emitted.\n const seenRef = yield* Ref.make(new Set<string>());\n\n const liveStream = resolveLiveStream(entity.live);\n const tagLive = (batch: Batch<Row>) => ({\n source: \"live\" as const,\n batch,\n });\n const updateSeen = (rows: ReadonlyArray<Row>) =>\n Ref.update(seenRef, (seen) => {\n const next = new Set(seen);\n for (const row of rows) {\n const key = String(row[entity.primaryKey]);\n next.add(key);\n }\n return next;\n });\n\n // Entities are upserts, so live and backfill can overlap. We keep an in-memory\n // seen set (primary keys) so backfill does not re-emit rows already observed live.\n const backfillTagged = Stream.mapEffect(entity.backfill, (batch) =>\n Ref.get(seenRef).pipe(\n Effect.map((seen) => {\n const filtered = batch.rows.filter((row) => {\n const key = String(row[entity.primaryKey]);\n return !seen.has(key);\n });\n return {\n source: \"backfill\" as const,\n batch: { cursor: batch.cursor, rows: filtered },\n };\n }),\n ),\n ).pipe(Stream.tap(({ batch }) => updateSeen(batch.rows)));\n\n // For webhook live sources, we wait for the first live batch before starting\n // backfill. That first batch establishes the cutoff timestamp and seeds the\n // seen set so backfill can de-dupe correctly.\n // Queue-backed streams are single-consumer; splitting with take/drop would\n // consume and discard elements. Take the first element directly from the\n // queue, then let Stream.fromQueue continue from element #2.\n if (isWebhookStream(entity.live)) {\n const firstBatch = yield* Queue.take(entity.live.queue);\n yield* updateSeen(firstBatch.rows);\n yield* processTaggedStream(\n Stream.make({ source: \"live\" as const, batch: firstBatch }),\n entity.name,\n entity.transform,\n stateRef,\n );\n\n // liveStream is Stream.fromQueue on the same queue, continues from element #2.\n const liveTailTagged = Stream.map(liveStream, tagLive).pipe(\n Stream.tap(({ batch }) => updateSeen(batch.rows)),\n );\n const merged = Stream.merge(liveTailTagged, backfillTagged);\n yield* processTaggedStream(\n merged,\n entity.name,\n entity.transform,\n stateRef,\n );\n return;\n }\n\n // For pull-based live sources, we can merge immediately because there is no\n // webhook cutoff gating and the live stream is not queue-backed.\n const liveTagged = Stream.map(liveStream, tagLive).pipe(\n Stream.tap(({ batch }) => updateSeen(batch.rows)),\n );\n\n const merged = Stream.merge(liveTagged, backfillTagged);\n yield* processTaggedStream(merged, entity.name, entity.transform, stateRef);\n });\n\nconst runEvent = <S extends EntitySchema>(\n event: EventDefinition<S>,\n initialCutoff: Cursor,\n): Effect.Effect<void, ConnectorError, StateStore | Publisher> =>\n Effect.gen(function* () {\n type Row = EntityRow<S>;\n const stateRef = yield* makeStateRef(event.name, initialCutoff);\n const liveStream = resolveLiveStream<Row>(event.live);\n\n // Events must backfill first to preserve ordering.\n if (event.backfill) {\n const backfillTagged = Stream.map(event.backfill, (batch) => ({\n source: \"backfill\" as const,\n batch,\n }));\n yield* processTaggedStream(\n backfillTagged,\n event.name,\n event.transform,\n stateRef,\n );\n }\n\n const liveTagged = Stream.map(liveStream, (batch) => ({\n source: \"live\" as const,\n batch,\n }));\n yield* processTaggedStream(\n liveTagged,\n event.name,\n event.transform,\n stateRef,\n );\n });\n\nconst updateState = (\n state: IngestionState<Cursor>,\n source: \"live\" | \"backfill\",\n cursor: Cursor,\n): IngestionState<Cursor> =>\n source === \"live\"\n ? { ...state, live: { ...state.live, current: cursor } }\n : { ...state, backfill: { ...state.backfill, current: cursor } };\n\nconst resolveLiveStream = <T>(\n source: LiveSource<T>,\n): Stream.Stream<Batch<T>, ConnectorError> =>\n isWebhookStream(source) ? source.stream : source;\n\nconst isWebhookStream = <T>(\n source: LiveSource<T>,\n): source is WebhookStream<T> =>\n typeof source === \"object\" &&\n source !== null &&\n \"queue\" in source &&\n \"stream\" in source;\n\nconst processTaggedStream = <T extends Record<string, unknown>>(\n stream: Stream.Stream<TaggedBatch<T>, ConnectorError>,\n name: string,\n transform: Transform<T> | undefined,\n stateRef: Ref.Ref<IngestionState<Cursor>>,\n): Effect.Effect<void, ConnectorError, StateStore | Publisher> =>\n Stream.runForEach(stream, ({ source, batch }) =>\n Effect.gen(function* () {\n // Optional per-row transformation.\n const rows = transform\n ? yield* Effect.forEach(batch.rows, transform)\n : batch.rows;\n\n // Publish before updating cursor state.\n const publisher = yield* Publisher;\n yield* publisher.publish({\n name,\n batch: {\n cursor: batch.cursor,\n rows,\n },\n });\n\n // Persist state only after publish succeeds.\n const nextState = yield* Ref.updateAndGet(stateRef, (state) =>\n updateState(state, source, batch.cursor),\n );\n\n const store = yield* StateStore;\n yield* store.setState(name, nextState);\n }),\n );\n","import type { PartitionValue } from \"@useairfoil/wings\";\nimport * as Wings from \"@useairfoil/wings\";\nimport { Effect, Layer } from \"effect\";\nimport { ConnectorError } from \"../core/errors\";\nimport type { ConnectorDefinition } from \"../core/types\";\nimport { Publisher } from \"./service\";\n\ntype Rows = Record<string, unknown>;\n\nexport type WingsPublisherConfig = {\n readonly connector: ConnectorDefinition;\n /** Map of entity/event name to Wings topic. */\n readonly topics: Record<string, Wings.Cluster.Topic.Topic>;\n /** per-stream partition value (key is entity/event name). */\n readonly partitionValues?: Record<string, PartitionValue>;\n};\n\n/** Publisher entry for a single entity/event. */\ntype PublisherEntry = {\n /** Wings publisher for the entity/event. */\n readonly publisher: Wings.WingsClient.Publisher;\n /** Partition field name (if any). */\n readonly partitionField?: string;\n /** Partition value (if any). */\n readonly partitionValue?: PartitionValue;\n};\n\nconst buildRecordBatch = (rows: ReadonlyArray<Rows>) => {\n // Convert JSON rows into an Arrow RecordBatch for Wings.\n const table = Wings.tableFromJSON(Array.from(rows));\n const [batch] = table.batches;\n\n if (!batch) {\n throw new ConnectorError({ message: \"No rows to publish\" });\n }\n\n return batch;\n};\n\nexport const WingsPublisherLayer = (\n config: WingsPublisherConfig,\n): Layer.Layer<Publisher, ConnectorError, Wings.WingsClient.WingsClient> =>\n Layer.effect(Publisher)(\n Effect.gen(function* () {\n const entries = new Map<string, PublisherEntry>();\n\n // create and store a wings publisher for each entity/event.\n for (const def of [\n ...config.connector.entities,\n ...config.connector.events,\n ]) {\n const topic = config.topics[def.name];\n if (!topic) {\n return yield* Effect.fail(\n new ConnectorError({ message: `Missing topic for ${def.name}` }),\n );\n }\n\n const partitionIndex =\n topic.partitionKey !== undefined\n ? topic.schema.fields.findIndex(\n (field) => field.id === topic.partitionKey,\n )\n : undefined;\n\n const partitionField =\n partitionIndex !== undefined && partitionIndex >= 0\n ? topic.schema.fields[partitionIndex]?.name\n : undefined;\n\n const publisher = yield* Wings.WingsClient.publisher({\n topic,\n partitionValue: config.partitionValues?.[def.name],\n }).pipe(\n Effect.mapError(\n (error) =>\n new ConnectorError({ message: error.message, cause: error }),\n ),\n );\n\n entries.set(def.name, {\n publisher,\n partitionField,\n partitionValue: config.partitionValues?.[def.name],\n });\n }\n\n return {\n publish: ({ name, batch }) =>\n Effect.gen(function* () {\n const entry = entries.get(name);\n if (!entry) {\n return yield* Effect.fail(\n new ConnectorError({ message: `Unknown stream ${name}` }),\n );\n }\n\n if (batch.rows.length === 0) {\n return { success: true };\n }\n\n const recordBatch = buildRecordBatch(batch.rows);\n const result = yield* entry.publisher\n .push({\n batch: recordBatch,\n partitionValue: entry.partitionValue,\n })\n .pipe(\n Effect.mapError(\n (error) =>\n new ConnectorError({\n message: error.message,\n cause: error,\n }),\n ),\n );\n\n return {\n success: !!(result.result && result.result.$case === \"accepted\"),\n };\n }),\n };\n }),\n );\n","import { Effect, Stream } from \"effect\";\nimport type { ConnectorError } from \"../core/errors\";\nimport type { Batch, Cursor } from \"../core/types\";\n\nexport type PullPage<T> = {\n readonly cursor: Cursor;\n readonly rows: ReadonlyArray<T>;\n readonly hasMore: boolean;\n};\n\nexport type PullFetcher<T, R = never> = (\n cursor: Cursor | undefined,\n) => Effect.Effect<PullPage<T>, ConnectorError, R>;\n\nexport type PullStreamOptions<T, R = never> = {\n readonly initialCursor?: Cursor;\n readonly fetchPage: PullFetcher<T, R>;\n};\n\nexport const makePullStream = <T, R = never>(\n options: PullStreamOptions<T, R>,\n): Stream.Stream<Batch<T>, ConnectorError, R> =>\n Stream.unfold({ cursor: options.initialCursor, done: false }, (state) =>\n Effect.gen(function* () {\n if (state.done) {\n return undefined;\n }\n\n let nextCursor: Cursor | undefined = state.cursor;\n\n while (true) {\n const page = yield* options.fetchPage(nextCursor);\n\n if (page.rows.length > 0) {\n const batch: Batch<T> = {\n cursor: page.cursor,\n rows: page.rows,\n };\n\n return [\n batch,\n page.hasMore\n ? { cursor: page.cursor, done: false }\n : { cursor: undefined, done: true },\n ] as const;\n }\n\n if (!page.hasMore) {\n return undefined;\n }\n\n nextCursor = page.cursor;\n }\n }),\n );\n","import { Effect, Queue, Stream } from \"effect\";\nimport type { ConnectorError } from \"../core/errors\";\nimport type { Batch, WebhookStream } from \"../core/types\";\n\nexport const makeWebhookQueue = <T>(options?: {\n readonly capacity?: number;\n}): Effect.Effect<WebhookStream<T>, never> =>\n Effect.gen(function* () {\n const capacity = options?.capacity ?? 1024;\n const queue = yield* Queue.bounded<Batch<T>>(capacity);\n const stream: Stream.Stream<Batch<T>, ConnectorError> = Stream.fromQueue(\n queue,\n );\n return { queue, stream };\n });\n","import { Cause, Data, Effect, Schema } from \"effect\";\nimport {\n HttpRouter,\n HttpServerRequest,\n HttpServerResponse,\n} from \"effect/unstable/http\";\nimport type { WebhookRoute } from \"./types\";\n\nclass InvalidWebhookPayloadError extends Data.TaggedError(\n \"InvalidWebhookPayloadError\",\n)<{\n readonly message: string;\n}> {}\n\nconst makeHandler = <TPayload>(route: WebhookRoute<TPayload>) =>\n Effect.gen(function* () {\n const request = yield* HttpServerRequest.HttpServerRequest;\n const rawBuffer = yield* request.arrayBuffer.pipe(\n Effect.mapError(\n () =>\n new InvalidWebhookPayloadError({\n message: \"Failed to read request body\",\n }),\n ),\n );\n const rawBody = new Uint8Array(rawBuffer);\n const rawText = new TextDecoder().decode(rawBody);\n const rawJson = yield* Effect.try({\n try: () => JSON.parse(rawText) as unknown,\n catch: () =>\n new InvalidWebhookPayloadError({ message: \"Invalid JSON body\" }),\n });\n const payload = yield* Schema.decodeUnknownEffect(route.schema)(\n rawJson,\n ).pipe(\n Effect.mapError(\n () =>\n new InvalidWebhookPayloadError({\n message: \"Invalid webhook payload\",\n }),\n ),\n );\n yield* route.handle(payload, request, rawBody);\n // unsafeJson serializes synchronously — no Effect, no HttpBodyError\n return HttpServerResponse.jsonUnsafe({ ok: true });\n }).pipe(\n Effect.catchTag(\"InvalidWebhookPayloadError\", () =>\n Effect.succeed(\n HttpServerResponse.jsonUnsafe(\n { ok: false, error: \"Invalid webhook payload\" },\n { status: 400 },\n ),\n ),\n ),\n Effect.catchCause((cause) =>\n Effect.logWarning(`Webhook handler error: ${Cause.pretty(cause)}`).pipe(\n Effect.andThen(\n Effect.succeed(\n HttpServerResponse.jsonUnsafe(\n { ok: false, error: \"Webhook handler failed\" },\n { status: 500 },\n ),\n ),\n ),\n ),\n ),\n );\n\nexport const buildWebhookRouter = <TPayload>(\n routes: ReadonlyArray<WebhookRoute<TPayload>>,\n) =>\n HttpRouter.addAll(\n routes.map((route) =>\n HttpRouter.route(\"POST\", route.path, makeHandler(route)),\n ),\n );\n"],"mappings":"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"}
|
package/package.json
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@useairfoil/connector-kit",
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"files": [
|
|
6
|
+
"dist",
|
|
7
|
+
"src",
|
|
8
|
+
"README.md"
|
|
9
|
+
],
|
|
10
|
+
"main": "./dist/index.js",
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"exports": {
|
|
13
|
+
".": {
|
|
14
|
+
"types": "./dist/index.d.ts",
|
|
15
|
+
"import": "./dist/index.js",
|
|
16
|
+
"default": "./dist/index.js"
|
|
17
|
+
}
|
|
18
|
+
},
|
|
19
|
+
"scripts": {
|
|
20
|
+
"build": "tsdown",
|
|
21
|
+
"test": "vitest",
|
|
22
|
+
"test:ci": "vitest run",
|
|
23
|
+
"typecheck": "tsc --noEmit",
|
|
24
|
+
"lint": "biome check .",
|
|
25
|
+
"lint:fix": "biome check . --write",
|
|
26
|
+
"format": "biome format --write ."
|
|
27
|
+
},
|
|
28
|
+
"devDependencies": {
|
|
29
|
+
"@effect/vitest": "4.0.0-beta.25",
|
|
30
|
+
"@types/node": "^24.2.0",
|
|
31
|
+
"tsdown": "^0.15.6",
|
|
32
|
+
"vitest": "^3.2.4"
|
|
33
|
+
},
|
|
34
|
+
"dependencies": {
|
|
35
|
+
"@useairfoil/flight": "0.2.0",
|
|
36
|
+
"@useairfoil/wings": "0.2.0",
|
|
37
|
+
"consola": "^3.4.2",
|
|
38
|
+
"json-stable-stringify": "^1.3.0"
|
|
39
|
+
},
|
|
40
|
+
"peerDependencies": {
|
|
41
|
+
"effect": "4.0.0-beta.25"
|
|
42
|
+
},
|
|
43
|
+
"peerDependenciesMeta": {
|
|
44
|
+
"effect": {
|
|
45
|
+
"optional": false
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ConnectorDefinition,
|
|
3
|
+
EntityDefinition,
|
|
4
|
+
EntitySchema,
|
|
5
|
+
EventDefinition,
|
|
6
|
+
} from "./types";
|
|
7
|
+
|
|
8
|
+
export const defineConnector = <
|
|
9
|
+
const Entities extends ReadonlyArray<EntityDefinition<EntitySchema>>,
|
|
10
|
+
const Events extends ReadonlyArray<EventDefinition<EntitySchema>>,
|
|
11
|
+
>(
|
|
12
|
+
definition: ConnectorDefinition<Entities, Events>,
|
|
13
|
+
) => definition;
|
|
14
|
+
|
|
15
|
+
export const defineEntity = <S extends EntitySchema>(
|
|
16
|
+
definition: EntityDefinition<S>,
|
|
17
|
+
) => definition;
|
|
18
|
+
|
|
19
|
+
export const defineEvent = <S extends EntitySchema>(
|
|
20
|
+
definition: EventDefinition<S>,
|
|
21
|
+
) => definition;
|