@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
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import type { Effect, Queue, Schema, Stream } from "effect";
|
|
2
|
+
import type { ConnectorError } from "./errors";
|
|
3
|
+
|
|
4
|
+
export type Cursor = string | number | bigint | Date;
|
|
5
|
+
|
|
6
|
+
export type Batch<T> = {
|
|
7
|
+
readonly cursor: Cursor;
|
|
8
|
+
readonly rows: ReadonlyArray<T>;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export type StreamState<C = Cursor> = {
|
|
12
|
+
readonly cutoff: C;
|
|
13
|
+
readonly current?: C;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export type IngestionState<C = Cursor> = {
|
|
17
|
+
readonly backfill: StreamState<C>;
|
|
18
|
+
readonly live: StreamState<C>;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export type Transform<T> = (row: T) => Effect.Effect<T, ConnectorError>;
|
|
22
|
+
|
|
23
|
+
export type LiveStream<T> = Stream.Stream<Batch<T>, ConnectorError>;
|
|
24
|
+
|
|
25
|
+
export type BackfillStream<T> = Stream.Stream<Batch<T>, ConnectorError>;
|
|
26
|
+
|
|
27
|
+
export type WebhookStream<T> = {
|
|
28
|
+
/**
|
|
29
|
+
* The queue is used to store the batches that are received from the webhook.
|
|
30
|
+
*/
|
|
31
|
+
readonly queue: Queue.Queue<Batch<T>>;
|
|
32
|
+
/**
|
|
33
|
+
* The stream is used to process the batches that are received from the webhook.
|
|
34
|
+
*/
|
|
35
|
+
readonly stream: Stream.Stream<Batch<T>, ConnectorError>;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export type LiveSource<T> = LiveStream<T> | WebhookStream<T>;
|
|
39
|
+
|
|
40
|
+
/** Schema type used by connector definitions. */
|
|
41
|
+
export type EntitySchema = Schema.Schema<unknown>;
|
|
42
|
+
/** Decoded row type produced by a schema. */
|
|
43
|
+
export type EntityType<S extends EntitySchema> = Schema.Schema.Type<S>;
|
|
44
|
+
/** Primary key type derived from the decoded schema shape. */
|
|
45
|
+
export type EntityKey<S extends EntitySchema> =
|
|
46
|
+
EntityType<S> extends Record<string, unknown>
|
|
47
|
+
? keyof EntityType<S> & string
|
|
48
|
+
: never;
|
|
49
|
+
/** Row type constrained to object-like shapes for ingestion. */
|
|
50
|
+
export type EntityRow<S extends EntitySchema> = EntityType<S> &
|
|
51
|
+
Record<string, unknown>;
|
|
52
|
+
|
|
53
|
+
export type EntityDefinition<S extends EntitySchema> = {
|
|
54
|
+
readonly name: string;
|
|
55
|
+
readonly schema: S;
|
|
56
|
+
/**
|
|
57
|
+
* The primary key is used to identify the rows in the database.
|
|
58
|
+
*/
|
|
59
|
+
readonly primaryKey: EntityKey<S>;
|
|
60
|
+
/**
|
|
61
|
+
* The live source is used to stream the live data from the database.
|
|
62
|
+
*/
|
|
63
|
+
readonly live: LiveSource<EntityRow<S>>;
|
|
64
|
+
/**
|
|
65
|
+
* The backfill stream is used to stream the backfill data from the database.
|
|
66
|
+
*/
|
|
67
|
+
readonly backfill: BackfillStream<EntityRow<S>>;
|
|
68
|
+
/**
|
|
69
|
+
* The transform is used to transform the rows before they are published.
|
|
70
|
+
*/
|
|
71
|
+
readonly transform?: Transform<EntityRow<S>>;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
export type EventDefinition<S extends EntitySchema> = {
|
|
75
|
+
readonly name: string;
|
|
76
|
+
readonly schema: S;
|
|
77
|
+
/**
|
|
78
|
+
* The live source is used to stream the live data from the database.
|
|
79
|
+
*/
|
|
80
|
+
readonly live: LiveSource<EntityRow<S>>;
|
|
81
|
+
/**
|
|
82
|
+
* The backfill stream is used to stream the backfill data from the database.
|
|
83
|
+
*/
|
|
84
|
+
readonly backfill?: BackfillStream<EntityRow<S>>;
|
|
85
|
+
/**
|
|
86
|
+
* The transform is used to transform the rows before they are published.
|
|
87
|
+
*/
|
|
88
|
+
readonly transform?: Transform<EntityRow<S>>;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
export type ConnectorDefinition<
|
|
92
|
+
Entities extends ReadonlyArray<
|
|
93
|
+
EntityDefinition<EntitySchema>
|
|
94
|
+
> = ReadonlyArray<EntityDefinition<EntitySchema>>,
|
|
95
|
+
Events extends ReadonlyArray<EventDefinition<EntitySchema>> = ReadonlyArray<
|
|
96
|
+
EventDefinition<EntitySchema>
|
|
97
|
+
>,
|
|
98
|
+
> = {
|
|
99
|
+
readonly name: string;
|
|
100
|
+
readonly entities: Entities;
|
|
101
|
+
readonly events: Events;
|
|
102
|
+
};
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export { defineConnector, defineEntity, defineEvent } from "./core/builder";
|
|
2
|
+
export { ConnectorError } from "./core/errors";
|
|
3
|
+
export type {
|
|
4
|
+
BackfillStream,
|
|
5
|
+
Batch,
|
|
6
|
+
ConnectorDefinition,
|
|
7
|
+
Cursor,
|
|
8
|
+
EntityDefinition,
|
|
9
|
+
EntityKey,
|
|
10
|
+
EntityRow,
|
|
11
|
+
EntitySchema,
|
|
12
|
+
EntityType,
|
|
13
|
+
EventDefinition,
|
|
14
|
+
IngestionState,
|
|
15
|
+
LiveSource,
|
|
16
|
+
LiveStream,
|
|
17
|
+
StreamState,
|
|
18
|
+
Transform,
|
|
19
|
+
WebhookStream,
|
|
20
|
+
} from "./core/types";
|
|
21
|
+
export { runConnector } from "./ingestion/engine";
|
|
22
|
+
export { StateStore, StateStoreInMemory } from "./ingestion/state-store";
|
|
23
|
+
export { Publisher } from "./publisher/service";
|
|
24
|
+
export { WingsPublisherLayer } from "./publisher/wings";
|
|
25
|
+
export { makePullStream } from "./streams/pull-stream";
|
|
26
|
+
export { makeWebhookQueue } from "./streams/webhook-queue";
|
|
27
|
+
export { buildWebhookRouter } from "./webhook/server";
|
|
28
|
+
export type { WebhookRoute } from "./webhook/types";
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import { Effect, Queue, Ref, Stream } from "effect";
|
|
2
|
+
import type { ConnectorError } from "../core/errors";
|
|
3
|
+
import type {
|
|
4
|
+
Batch,
|
|
5
|
+
ConnectorDefinition,
|
|
6
|
+
Cursor,
|
|
7
|
+
EntityDefinition,
|
|
8
|
+
EntityRow,
|
|
9
|
+
EntitySchema,
|
|
10
|
+
EventDefinition,
|
|
11
|
+
IngestionState,
|
|
12
|
+
LiveSource,
|
|
13
|
+
Transform,
|
|
14
|
+
WebhookStream,
|
|
15
|
+
} from "../core/types";
|
|
16
|
+
import { Publisher } from "../publisher/service";
|
|
17
|
+
import { StateStore } from "./state-store";
|
|
18
|
+
|
|
19
|
+
type TaggedBatch<T> = {
|
|
20
|
+
readonly source: "live" | "backfill";
|
|
21
|
+
readonly batch: Batch<T>;
|
|
22
|
+
};
|
|
23
|
+
|
|
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),
|
|
32
|
+
);
|
|
33
|
+
const eventRuns = connector.events.map((event) =>
|
|
34
|
+
runEvent(event, initialCutoff),
|
|
35
|
+
);
|
|
36
|
+
// main runner
|
|
37
|
+
yield* Effect.all([...entityRuns, ...eventRuns], {
|
|
38
|
+
concurrency: "unbounded",
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
const createInitialState = (cutoff: Cursor): IngestionState<Cursor> => ({
|
|
43
|
+
backfill: { cutoff },
|
|
44
|
+
live: { cutoff },
|
|
45
|
+
});
|
|
46
|
+
|
|
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);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const runEntity = <S extends EntitySchema>(
|
|
60
|
+
entity: EntityDefinition<S>,
|
|
61
|
+
initialCutoff: Cursor,
|
|
62
|
+
): 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,
|
|
73
|
+
});
|
|
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)),
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
const merged = Stream.merge(liveTagged, backfillTagged);
|
|
138
|
+
yield* processTaggedStream(merged, entity.name, entity.transform, stateRef);
|
|
139
|
+
});
|
|
140
|
+
|
|
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,
|
|
173
|
+
);
|
|
174
|
+
});
|
|
175
|
+
|
|
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
|
+
}),
|
|
229
|
+
);
|
|
@@ -0,0 +1,36 @@
|
|
|
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
|
+
);
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
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
|
+
|
|
6
|
+
export type PublishAck = {
|
|
7
|
+
readonly success: boolean;
|
|
8
|
+
};
|
|
9
|
+
|
|
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") {}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import type { PartitionValue } from "@useairfoil/wings";
|
|
2
|
+
import * as Wings from "@useairfoil/wings";
|
|
3
|
+
import { Effect, Layer } from "effect";
|
|
4
|
+
import { ConnectorError } from "../core/errors";
|
|
5
|
+
import type { ConnectorDefinition } from "../core/types";
|
|
6
|
+
import { Publisher } from "./service";
|
|
7
|
+
|
|
8
|
+
type Rows = Record<string, unknown>;
|
|
9
|
+
|
|
10
|
+
export type WingsPublisherConfig = {
|
|
11
|
+
readonly connector: ConnectorDefinition;
|
|
12
|
+
/** Map of entity/event name to Wings topic. */
|
|
13
|
+
readonly topics: Record<string, Wings.Cluster.Topic.Topic>;
|
|
14
|
+
/** per-stream partition value (key is entity/event name). */
|
|
15
|
+
readonly partitionValues?: Record<string, PartitionValue>;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/** Publisher entry for a single entity/event. */
|
|
19
|
+
type PublisherEntry = {
|
|
20
|
+
/** Wings publisher for the entity/event. */
|
|
21
|
+
readonly publisher: Wings.WingsClient.Publisher;
|
|
22
|
+
/** Partition field name (if any). */
|
|
23
|
+
readonly partitionField?: string;
|
|
24
|
+
/** Partition value (if any). */
|
|
25
|
+
readonly partitionValue?: PartitionValue;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const buildRecordBatch = (rows: ReadonlyArray<Rows>) => {
|
|
29
|
+
// Convert JSON rows into an Arrow RecordBatch for Wings.
|
|
30
|
+
const table = Wings.tableFromJSON(Array.from(rows));
|
|
31
|
+
const [batch] = table.batches;
|
|
32
|
+
|
|
33
|
+
if (!batch) {
|
|
34
|
+
throw new ConnectorError({ message: "No rows to publish" });
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return batch;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export const WingsPublisherLayer = (
|
|
41
|
+
config: WingsPublisherConfig,
|
|
42
|
+
): Layer.Layer<Publisher, ConnectorError, Wings.WingsClient.WingsClient> =>
|
|
43
|
+
Layer.effect(Publisher)(
|
|
44
|
+
Effect.gen(function* () {
|
|
45
|
+
const entries = new Map<string, PublisherEntry>();
|
|
46
|
+
|
|
47
|
+
// create and store a wings publisher for each entity/event.
|
|
48
|
+
for (const def of [
|
|
49
|
+
...config.connector.entities,
|
|
50
|
+
...config.connector.events,
|
|
51
|
+
]) {
|
|
52
|
+
const topic = config.topics[def.name];
|
|
53
|
+
if (!topic) {
|
|
54
|
+
return yield* Effect.fail(
|
|
55
|
+
new ConnectorError({ message: `Missing topic for ${def.name}` }),
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const partitionIndex =
|
|
60
|
+
topic.partitionKey !== undefined
|
|
61
|
+
? topic.schema.fields.findIndex(
|
|
62
|
+
(field) => field.id === topic.partitionKey,
|
|
63
|
+
)
|
|
64
|
+
: undefined;
|
|
65
|
+
|
|
66
|
+
const partitionField =
|
|
67
|
+
partitionIndex !== undefined && partitionIndex >= 0
|
|
68
|
+
? topic.schema.fields[partitionIndex]?.name
|
|
69
|
+
: undefined;
|
|
70
|
+
|
|
71
|
+
const publisher = yield* Wings.WingsClient.publisher({
|
|
72
|
+
topic,
|
|
73
|
+
partitionValue: config.partitionValues?.[def.name],
|
|
74
|
+
}).pipe(
|
|
75
|
+
Effect.mapError(
|
|
76
|
+
(error) =>
|
|
77
|
+
new ConnectorError({ message: error.message, cause: error }),
|
|
78
|
+
),
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
entries.set(def.name, {
|
|
82
|
+
publisher,
|
|
83
|
+
partitionField,
|
|
84
|
+
partitionValue: config.partitionValues?.[def.name],
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
publish: ({ name, batch }) =>
|
|
90
|
+
Effect.gen(function* () {
|
|
91
|
+
const entry = entries.get(name);
|
|
92
|
+
if (!entry) {
|
|
93
|
+
return yield* Effect.fail(
|
|
94
|
+
new ConnectorError({ message: `Unknown stream ${name}` }),
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (batch.rows.length === 0) {
|
|
99
|
+
return { success: true };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const recordBatch = buildRecordBatch(batch.rows);
|
|
103
|
+
const result = yield* entry.publisher
|
|
104
|
+
.push({
|
|
105
|
+
batch: recordBatch,
|
|
106
|
+
partitionValue: entry.partitionValue,
|
|
107
|
+
})
|
|
108
|
+
.pipe(
|
|
109
|
+
Effect.mapError(
|
|
110
|
+
(error) =>
|
|
111
|
+
new ConnectorError({
|
|
112
|
+
message: error.message,
|
|
113
|
+
cause: error,
|
|
114
|
+
}),
|
|
115
|
+
),
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
success: !!(result.result && result.result.$case === "accepted"),
|
|
120
|
+
};
|
|
121
|
+
}),
|
|
122
|
+
};
|
|
123
|
+
}),
|
|
124
|
+
);
|
|
@@ -0,0 +1,55 @@
|
|
|
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
|
+
);
|
|
@@ -0,0 +1,15 @@
|
|
|
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
|
+
});
|
|
@@ -0,0 +1,76 @@
|
|
|
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>) =>
|
|
16
|
+
Effect.gen(function* () {
|
|
17
|
+
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
|
+
),
|
|
25
|
+
);
|
|
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
|
+
}),
|
|
41
|
+
),
|
|
42
|
+
);
|
|
43
|
+
yield* route.handle(payload, request, rawBody);
|
|
44
|
+
// unsafeJson serializes synchronously — no Effect, no HttpBodyError
|
|
45
|
+
return HttpServerResponse.jsonUnsafe({ ok: true });
|
|
46
|
+
}).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
|
+
Effect.catchCause((cause) =>
|
|
56
|
+
Effect.logWarning(`Webhook handler error: ${Cause.pretty(cause)}`).pipe(
|
|
57
|
+
Effect.andThen(
|
|
58
|
+
Effect.succeed(
|
|
59
|
+
HttpServerResponse.jsonUnsafe(
|
|
60
|
+
{ ok: false, error: "Webhook handler failed" },
|
|
61
|
+
{ status: 500 },
|
|
62
|
+
),
|
|
63
|
+
),
|
|
64
|
+
),
|
|
65
|
+
),
|
|
66
|
+
),
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
export const buildWebhookRouter = <TPayload>(
|
|
70
|
+
routes: ReadonlyArray<WebhookRoute<TPayload>>,
|
|
71
|
+
) =>
|
|
72
|
+
HttpRouter.addAll(
|
|
73
|
+
routes.map((route) =>
|
|
74
|
+
HttpRouter.route("POST", route.path, makeHandler(route)),
|
|
75
|
+
),
|
|
76
|
+
);
|