@useairfoil/connector-kit 0.4.0 → 0.5.1
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/METRICS.md +78 -0
- package/README.md +450 -88
- package/dist/auth/index.d.ts +2 -0
- package/dist/auth/index.js +3 -0
- package/dist/auth-DvU_CZ7C.js +42 -0
- package/dist/auth-DvU_CZ7C.js.map +1 -0
- package/dist/chunk-Bp6m_JJh.js +13 -0
- package/dist/connector-app/index.d.ts +14 -0
- package/dist/connector-app/index.js +11 -0
- package/dist/connector-app-d7Z-SZLQ.js +64 -0
- package/dist/connector-app-d7Z-SZLQ.js.map +1 -0
- package/dist/constants-BRhIX_rz.js +27 -0
- package/dist/constants-BRhIX_rz.js.map +1 -0
- package/dist/errors/index.d.ts +2 -0
- package/dist/errors/index.js +3 -0
- package/dist/errors-DHWylqRZ.js +8 -0
- package/dist/errors-DHWylqRZ.js.map +1 -0
- package/dist/index-Byr0Lf0C.d.ts +25 -0
- package/dist/index-CBT-JX-T.d.ts +173 -0
- package/dist/index-CTHVudvQ.d.ts +41 -0
- package/dist/index-CTLYQ74P.d.ts +80 -0
- package/dist/index-Coh_SkBs.d.ts +95 -0
- package/dist/index-D54ySAKn.d.ts +14 -0
- package/dist/index-DtJX_tic.d.ts +153 -0
- package/dist/index-J42AZaXU.d.ts +38 -0
- package/dist/index-fIIBVAai.d.ts +25 -0
- package/dist/index-o1-Z3n7V.d.ts +55 -0
- package/dist/index.d.ts +14 -165
- package/dist/index.js +50 -228
- package/dist/index.js.map +1 -1
- package/dist/ingestion/index.d.ts +6 -0
- package/dist/ingestion/index.js +10 -0
- package/dist/ingestion-BQo4cbGw.js +200 -0
- package/dist/ingestion-BQo4cbGw.js.map +1 -0
- package/dist/manifest/index.d.ts +2 -0
- package/dist/manifest/index.js +4 -0
- package/dist/manifest-ByE6YPG0.js +221 -0
- package/dist/manifest-ByE6YPG0.js.map +1 -0
- package/dist/metrics-CVAk1ALR.d.ts +53 -0
- package/dist/metrics-CWPy2Aqc.js +96 -0
- package/dist/metrics-CWPy2Aqc.js.map +1 -0
- package/dist/metrics.d.ts +4 -0
- package/dist/metrics.js +3 -0
- package/dist/publisher/index.d.ts +5 -0
- package/dist/publisher/index.js +6 -0
- package/dist/publisher-BYT3RLVO.js +131 -0
- package/dist/publisher-BYT3RLVO.js.map +1 -0
- package/dist/runtime-config/index.d.ts +2 -0
- package/dist/runtime-config/index.js +4 -0
- package/dist/runtime-config-BpB-d_bF.js +51 -0
- package/dist/runtime-config-BpB-d_bF.js.map +1 -0
- package/dist/server-CXTQ7odl.js +183 -0
- package/dist/server-CXTQ7odl.js.map +1 -0
- package/dist/service-DuFiQwid.js +191 -0
- package/dist/service-DuFiQwid.js.map +1 -0
- package/dist/service-IE5qaf9x.d.ts +32 -0
- package/dist/state-store/index.d.ts +3 -0
- package/dist/state-store/index.js +5 -0
- package/dist/state-store-yl6LiTQ0.js +198 -0
- package/dist/state-store-yl6LiTQ0.js.map +1 -0
- package/dist/status/index.d.ts +5 -0
- package/dist/status/index.js +6 -0
- package/dist/status-B-oZBc33.js +84 -0
- package/dist/status-B-oZBc33.js.map +1 -0
- package/dist/types-B35J5YPu.d.ts +174 -0
- package/dist/webhook/index.d.ts +6 -0
- package/dist/webhook/index.js +8 -0
- package/dist/webhook-DI-Afxkg.js +13 -0
- package/dist/webhook-DI-Afxkg.js.map +1 -0
- package/package.json +66 -8
- package/src/auth/index.ts +1 -0
- package/src/auth/token-cache.ts +54 -0
- package/src/connector-app/check.ts +76 -0
- package/src/connector-app/index.ts +2 -0
- package/src/connector-app/start.ts +42 -0
- package/src/core/builder.ts +81 -9
- package/src/core/index.ts +22 -0
- package/src/core/types.ts +204 -76
- package/src/errors/index.ts +1 -0
- package/src/index.ts +13 -28
- package/src/ingestion/engine.ts +409 -174
- package/src/ingestion/index.ts +1 -0
- package/src/manifest/config.ts +232 -0
- package/src/manifest/index.ts +14 -0
- package/src/manifest/manifest.ts +31 -0
- package/src/manifest/number-schema.ts +17 -0
- package/src/manifest/schema.ts +160 -0
- package/src/metrics.ts +128 -0
- package/src/publisher/console.ts +18 -0
- package/src/publisher/index.ts +7 -0
- package/src/publisher/instrumented.ts +68 -0
- package/src/publisher/service.ts +30 -13
- package/src/publisher/wings.ts +159 -72
- package/src/runtime/context.ts +15 -0
- package/src/runtime-config/constants.ts +27 -0
- package/src/runtime-config/error.ts +13 -0
- package/src/runtime-config/index.ts +3 -0
- package/src/runtime-config/layer.ts +56 -0
- package/src/state-store/index.ts +3 -0
- package/src/state-store/keys.ts +22 -0
- package/src/state-store/layer-key-value-store.ts +132 -0
- package/src/state-store/layer-memory.ts +11 -0
- package/src/state-store/layer-sql.ts +50 -0
- package/src/state-store/schema.ts +57 -0
- package/src/state-store/service.ts +51 -0
- package/src/state-store/state.ts +17 -0
- package/src/status/index.ts +2 -0
- package/src/status/route.ts +77 -0
- package/src/status/types.ts +33 -0
- package/src/telemetry.ts +288 -0
- package/src/webhook/index.ts +8 -0
- package/src/webhook/server.ts +209 -39
- package/src/webhook/types.ts +6 -17
- package/src/ingestion/state-store.ts +0 -36
- package/src/streams/pull-stream.ts +0 -54
- package/src/streams/webhook-queue.ts +0 -14
- /package/src/{core/errors.ts → errors/connector-error.ts} +0 -0
package/src/ingestion/engine.ts
CHANGED
|
@@ -1,203 +1,438 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { DateTime, Effect, Layer, Queue } from "effect";
|
|
2
|
+
import { HttpRouter, type HttpServer, HttpServerResponse } from "effect/unstable/http";
|
|
3
|
+
import * as Observability from "effect/unstable/observability";
|
|
2
4
|
|
|
3
|
-
import type { ConnectorError } from "../core/errors";
|
|
4
5
|
import type {
|
|
5
|
-
Batch,
|
|
6
6
|
ConnectorDefinition,
|
|
7
7
|
Cursor,
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
EventDefinition,
|
|
12
|
-
IngestionState,
|
|
13
|
-
LiveSource,
|
|
14
|
-
Transform,
|
|
15
|
-
WebhookStream,
|
|
8
|
+
ResourceDefinition,
|
|
9
|
+
ResourceState,
|
|
10
|
+
WebhookRoute,
|
|
16
11
|
} from "../core/types";
|
|
12
|
+
import type { StateOperation, StateSource } from "../state-store/schema";
|
|
17
13
|
|
|
14
|
+
import { ConnectorError } from "../errors";
|
|
15
|
+
import * as Metrics from "../metrics";
|
|
16
|
+
import { publishBatch } from "../publisher/instrumented";
|
|
18
17
|
import { Publisher } from "../publisher/service";
|
|
19
|
-
import { StateStore } from "
|
|
18
|
+
import { StateStore } from "../state-store";
|
|
19
|
+
import { deriveSyncState, normalizeCursor } from "../state-store/state";
|
|
20
|
+
import { statusRoute } from "../status";
|
|
21
|
+
import { Attr, SpanName, annotateError } from "../telemetry";
|
|
22
|
+
import { router, WebhookQueue, type QueuedWebhookBatch } from "../webhook/server";
|
|
20
23
|
|
|
21
|
-
type
|
|
22
|
-
readonly
|
|
23
|
-
readonly batch: Batch<T>;
|
|
24
|
+
type RunBaseOptions = {
|
|
25
|
+
readonly initialCutoff?: Cursor.Value;
|
|
24
26
|
};
|
|
25
27
|
|
|
26
|
-
export
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
28
|
+
export type RunOptions = RunBaseOptions & {
|
|
29
|
+
readonly webhook?: {
|
|
30
|
+
readonly routes: ReadonlyArray<WebhookRoute>;
|
|
31
|
+
readonly healthPath?: HttpRouter.PathInput;
|
|
32
|
+
readonly metricsPath?: HttpRouter.PathInput | false;
|
|
33
|
+
readonly statusPath?: HttpRouter.PathInput | false;
|
|
34
|
+
readonly disableHttpLogger?: boolean;
|
|
35
|
+
};
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
type RunNoWebhookOptions = RunBaseOptions & {
|
|
39
|
+
readonly webhook?: undefined;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
type RunWebhookOptions = RunOptions & {
|
|
43
|
+
readonly webhook: NonNullable<RunOptions["webhook"]>;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Runs all connector ingestion loops. Callers provide the Publisher and
|
|
48
|
+
* StateStore layers; webhook mode additionally requires an HttpServer layer.
|
|
49
|
+
*/
|
|
50
|
+
export function run<const Resources extends ReadonlyArray<ResourceDefinition>>(
|
|
51
|
+
connector: ConnectorDefinition<Resources>,
|
|
52
|
+
options?: RunNoWebhookOptions,
|
|
53
|
+
): Effect.Effect<void, ConnectorError, StateStore | Publisher>;
|
|
54
|
+
export function run<const Resources extends ReadonlyArray<ResourceDefinition>>(
|
|
55
|
+
connector: ConnectorDefinition<Resources>,
|
|
56
|
+
options: RunWebhookOptions,
|
|
57
|
+
): Effect.Effect<void, ConnectorError, StateStore | Publisher | HttpServer.HttpServer>;
|
|
58
|
+
export function run(connector: ConnectorDefinition, options?: RunOptions) {
|
|
59
|
+
const queueLayer = makeWebhookQueueLayer(connector.name);
|
|
60
|
+
const runtimeLayer = options?.webhook
|
|
61
|
+
? Layer.mergeAll(
|
|
62
|
+
queueLayer,
|
|
63
|
+
makeWebhookServerLayer(connector, options.webhook).pipe(Layer.provide(queueLayer)),
|
|
64
|
+
)
|
|
65
|
+
: Layer.empty;
|
|
66
|
+
|
|
67
|
+
return Effect.gen(function* () {
|
|
68
|
+
const initialCutoff =
|
|
69
|
+
options?.initialCutoff ?? (yield* DateTime.now.pipe(Effect.map(DateTime.formatIso)));
|
|
70
|
+
yield* Effect.logInfo("Connector started").pipe(
|
|
71
|
+
Effect.annotateLogs({
|
|
72
|
+
[Attr.connectorName]: connector.name,
|
|
73
|
+
resources: connector.resources.length,
|
|
74
|
+
}),
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
const sourceRun = runIngestion(connector, initialCutoff);
|
|
78
|
+
const webhookRun = options?.webhook
|
|
79
|
+
? runWebhookRuntime(connector, options.webhook.routes)
|
|
80
|
+
: Effect.void;
|
|
81
|
+
|
|
82
|
+
return yield* Effect.all([sourceRun, webhookRun], { concurrency: "unbounded" }).pipe(
|
|
83
|
+
Effect.asVoid,
|
|
84
|
+
);
|
|
85
|
+
}).pipe(Effect.provide(runtimeLayer));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const makeWebhookQueueLayer = (connector: string): Layer.Layer<WebhookQueue> =>
|
|
89
|
+
Layer.effect(WebhookQueue)(
|
|
90
|
+
Effect.gen(function* () {
|
|
91
|
+
const queue = yield* Queue.bounded<QueuedWebhookBatch>(1024);
|
|
92
|
+
yield* Metrics.setWebhookQueueDepth(connector, 0);
|
|
93
|
+
return WebhookQueue.of({ queue });
|
|
94
|
+
}),
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
const runWebhookRuntime = (connector: ConnectorDefinition, routes: ReadonlyArray<WebhookRoute>) => {
|
|
98
|
+
const hasAfterEnqueue = routes.some((route) => route.ackMode === "after-enqueue");
|
|
99
|
+
return Effect.all(
|
|
100
|
+
[hasAfterEnqueue ? runWebhookQueueConsumer(connector) : Effect.void, Effect.never],
|
|
101
|
+
{
|
|
36
102
|
concurrency: "unbounded",
|
|
37
|
-
}
|
|
103
|
+
},
|
|
104
|
+
).pipe(Effect.asVoid);
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const runWebhookQueueConsumer = (connector: ConnectorDefinition) =>
|
|
108
|
+
Effect.gen(function* () {
|
|
109
|
+
const queue = yield* WebhookQueue;
|
|
110
|
+
while (true) {
|
|
111
|
+
const batch = yield* Queue.take(queue.queue);
|
|
112
|
+
const depth = yield* Queue.size(queue.queue);
|
|
113
|
+
yield* Metrics.setWebhookQueueDepth(connector.name, depth);
|
|
114
|
+
yield* publishBatch({
|
|
115
|
+
connector: connector.name,
|
|
116
|
+
resource: batch.resource,
|
|
117
|
+
source: "webhook",
|
|
118
|
+
batch: batch.batch,
|
|
119
|
+
});
|
|
120
|
+
}
|
|
38
121
|
});
|
|
39
122
|
|
|
40
|
-
const
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
123
|
+
const makeWebhookServerLayer = (
|
|
124
|
+
connector: ConnectorDefinition,
|
|
125
|
+
options: {
|
|
126
|
+
readonly routes: ReadonlyArray<WebhookRoute>;
|
|
127
|
+
readonly healthPath?: HttpRouter.PathInput;
|
|
128
|
+
readonly metricsPath?: HttpRouter.PathInput | false;
|
|
129
|
+
readonly statusPath?: HttpRouter.PathInput | false;
|
|
130
|
+
readonly disableHttpLogger?: boolean;
|
|
131
|
+
},
|
|
132
|
+
): Layer.Layer<never, never, HttpServer.HttpServer> => {
|
|
133
|
+
const healthPath: HttpRouter.PathInput = options.healthPath ?? "/health";
|
|
134
|
+
const metricsPath = options.metricsPath === undefined ? "/metrics" : options.metricsPath;
|
|
135
|
+
const statusPath = options.statusPath === undefined ? "/status" : options.statusPath;
|
|
136
|
+
const app = Layer.mergeAll(
|
|
137
|
+
router(options.routes, { connectorName: connector.name }),
|
|
138
|
+
HttpRouter.add("GET", healthPath, Effect.succeed(HttpServerResponse.text("ok"))),
|
|
139
|
+
...(metricsPath === false
|
|
140
|
+
? []
|
|
141
|
+
: [Observability.PrometheusMetrics.layerHttp({ path: metricsPath })]),
|
|
142
|
+
...(statusPath === false ? [] : [statusRoute(connector, statusPath)]),
|
|
143
|
+
);
|
|
44
144
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
initialCutoff: Cursor,
|
|
48
|
-
): Effect.Effect<Ref.Ref<IngestionState<Cursor>>, ConnectorError, StateStore> =>
|
|
49
|
-
Effect.gen(function* () {
|
|
50
|
-
// Load persisted state or initialize a new one for this stream.
|
|
51
|
-
const store = yield* StateStore;
|
|
52
|
-
const existing = yield* store.getState(key);
|
|
53
|
-
const initial = existing ?? createInitialState(initialCutoff);
|
|
54
|
-
return yield* Ref.make(initial);
|
|
145
|
+
return HttpRouter.serve(app, {
|
|
146
|
+
disableLogger: options.disableHttpLogger ?? true,
|
|
55
147
|
});
|
|
148
|
+
};
|
|
56
149
|
|
|
57
|
-
const
|
|
58
|
-
|
|
59
|
-
initialCutoff: Cursor,
|
|
150
|
+
const runIngestion = (
|
|
151
|
+
connector: ConnectorDefinition,
|
|
152
|
+
initialCutoff: Cursor.Value,
|
|
60
153
|
): Effect.Effect<void, ConnectorError, StateStore | Publisher> =>
|
|
61
|
-
Effect.
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
});
|
|
154
|
+
Effect.forEach(
|
|
155
|
+
connector.resources,
|
|
156
|
+
(resource) => runResourceSources(connector, resource, initialCutoff),
|
|
157
|
+
{
|
|
158
|
+
concurrency: "unbounded",
|
|
159
|
+
},
|
|
160
|
+
).pipe(Effect.asVoid);
|
|
161
|
+
|
|
162
|
+
const initializeResourceState = (
|
|
163
|
+
existing: ResourceState | undefined,
|
|
164
|
+
initialCutoff: Cursor.Value,
|
|
165
|
+
): ResourceState => ({
|
|
166
|
+
...existing,
|
|
167
|
+
changes: existing?.changes ?? { cursor: initialCutoff },
|
|
168
|
+
backfill: existing?.backfill ?? {
|
|
169
|
+
cutoff: initialCutoff,
|
|
170
|
+
completed: false,
|
|
171
|
+
},
|
|
172
|
+
});
|
|
81
173
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
|
190
|
+
? isolateSourceFailure(
|
|
191
|
+
runBackfill(connector, resource, initialCutoff),
|
|
192
|
+
connector,
|
|
193
|
+
resource,
|
|
194
|
+
"backfill",
|
|
195
|
+
)
|
|
196
|
+
: Effect.void,
|
|
197
|
+
resource.changes
|
|
198
|
+
? isolateSourceFailure(
|
|
199
|
+
runChanges(connector, resource, initialCutoff),
|
|
200
|
+
connector,
|
|
201
|
+
resource,
|
|
202
|
+
"changes",
|
|
203
|
+
)
|
|
204
|
+
: Effect.void,
|
|
205
|
+
];
|
|
206
|
+
|
|
207
|
+
return initializeRuntimeStatus(connector, resource, initialCutoff).pipe(
|
|
208
|
+
Effect.andThen(Effect.all(runs, { concurrency: "unbounded" })),
|
|
209
|
+
Effect.asVoid,
|
|
210
|
+
Effect.catch(() =>
|
|
211
|
+
Metrics.setSyncState({ connector: connector.name, resource: resource.name }, "error").pipe(
|
|
212
|
+
Effect.andThen(Effect.logError("Connector resource initialization failed")),
|
|
213
|
+
Effect.annotateLogs({
|
|
214
|
+
[Attr.connectorName]: connector.name,
|
|
215
|
+
resource: resource.name,
|
|
95
216
|
}),
|
|
217
|
+
Effect.andThen(Effect.never),
|
|
96
218
|
),
|
|
97
|
-
)
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
// backfill. That first batch establishes the cutoff timestamp and seeds the
|
|
101
|
-
// seen set so backfill can de-dupe correctly.
|
|
102
|
-
// Queue-backed streams are single-consumer; splitting with take/drop would
|
|
103
|
-
// consume and discard elements. Take the first element directly from the
|
|
104
|
-
// queue, then let Stream.fromQueue continue from element #2.
|
|
105
|
-
if (isWebhookStream(entity.live)) {
|
|
106
|
-
const firstBatch = yield* Queue.take(entity.live.queue);
|
|
107
|
-
yield* updateSeen(firstBatch.rows);
|
|
108
|
-
yield* processTaggedStream(
|
|
109
|
-
Stream.make({ source: "live" as const, batch: firstBatch }),
|
|
110
|
-
entity.name,
|
|
111
|
-
entity.transform,
|
|
112
|
-
stateRef,
|
|
113
|
-
);
|
|
114
|
-
|
|
115
|
-
// liveStream is Stream.fromQueue on the same queue, continues from element #2.
|
|
116
|
-
const liveTailTagged = Stream.map(liveStream, tagLive).pipe(
|
|
117
|
-
Stream.tap(({ batch }) => updateSeen(batch.rows)),
|
|
118
|
-
);
|
|
119
|
-
const merged = Stream.merge(liveTailTagged, backfillTagged);
|
|
120
|
-
yield* processTaggedStream(merged, entity.name, entity.transform, stateRef);
|
|
121
|
-
return;
|
|
122
|
-
}
|
|
219
|
+
),
|
|
220
|
+
);
|
|
221
|
+
};
|
|
123
222
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
223
|
+
const recordResourceError =
|
|
224
|
+
(
|
|
225
|
+
connector: ConnectorDefinition,
|
|
226
|
+
resource: ResourceDefinition,
|
|
227
|
+
source: StateSource,
|
|
228
|
+
operation: StateOperation,
|
|
229
|
+
) =>
|
|
230
|
+
<A, R>(
|
|
231
|
+
effect: Effect.Effect<A, ConnectorError, R>,
|
|
232
|
+
): Effect.Effect<A, ConnectorError, R | StateStore> =>
|
|
233
|
+
effect.pipe(
|
|
234
|
+
Effect.tapError(() =>
|
|
235
|
+
Metrics.setSyncState({ connector: connector.name, resource: resource.name }, "error").pipe(
|
|
236
|
+
Effect.andThen(
|
|
237
|
+
StateStore.pipe(
|
|
238
|
+
Effect.flatMap((store) => store.setResourceError(resource.name, source, operation)),
|
|
239
|
+
),
|
|
240
|
+
),
|
|
241
|
+
),
|
|
242
|
+
),
|
|
128
243
|
);
|
|
129
244
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
245
|
+
const isolateSourceFailure = <A, R>(
|
|
246
|
+
effect: Effect.Effect<A, ConnectorError, R>,
|
|
247
|
+
connector: ConnectorDefinition,
|
|
248
|
+
resource: ResourceDefinition,
|
|
249
|
+
source: StateSource,
|
|
250
|
+
): Effect.Effect<A, never, R> =>
|
|
251
|
+
// Only typed source errors are isolated. Defects and interruption still stop the runtime.
|
|
252
|
+
effect.pipe(
|
|
253
|
+
Effect.catch(() =>
|
|
254
|
+
Effect.logError("Connector source parked").pipe(
|
|
255
|
+
Effect.annotateLogs({
|
|
256
|
+
[Attr.connectorName]: connector.name,
|
|
257
|
+
resource: resource.name,
|
|
258
|
+
source,
|
|
259
|
+
}),
|
|
260
|
+
Effect.andThen(Effect.never),
|
|
261
|
+
),
|
|
262
|
+
),
|
|
263
|
+
);
|
|
133
264
|
|
|
134
|
-
const
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
265
|
+
const refreshRuntimeStatus = Effect.fnUntraced(function* (
|
|
266
|
+
connector: ConnectorDefinition,
|
|
267
|
+
resource: ResourceDefinition,
|
|
268
|
+
initialCutoff: Cursor.Value,
|
|
269
|
+
) {
|
|
270
|
+
const state = yield* getInitializedState(resource.name, initialCutoff);
|
|
271
|
+
yield* Metrics.setSyncState(
|
|
272
|
+
{ connector: connector.name, resource: resource.name },
|
|
273
|
+
deriveSyncState(resource, state),
|
|
274
|
+
);
|
|
275
|
+
return state;
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
const initializeRuntimeStatus = Effect.fnUntraced(function* (
|
|
279
|
+
connector: ConnectorDefinition,
|
|
280
|
+
resource: ResourceDefinition,
|
|
281
|
+
initialCutoff: Cursor.Value,
|
|
282
|
+
) {
|
|
283
|
+
const state = yield* getInitializedState(resource.name, initialCutoff);
|
|
284
|
+
yield* Metrics.setSyncState(
|
|
285
|
+
{ connector: connector.name, resource: resource.name },
|
|
286
|
+
deriveSyncState(resource, state),
|
|
287
|
+
);
|
|
288
|
+
if (resource.backfill) {
|
|
289
|
+
yield* Metrics.setLastSuccessTimestamp(
|
|
290
|
+
{ connector: connector.name, resource: resource.name, source: "backfill" },
|
|
291
|
+
state.backfill?.lastSuccessAt,
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
if (resource.changes) {
|
|
295
|
+
yield* Metrics.setLastSuccessTimestamp(
|
|
296
|
+
{ connector: connector.name, resource: resource.name, source: "changes" },
|
|
297
|
+
state.changes?.lastSuccessAt,
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
const runBackfill = Effect.fnUntraced(function* (
|
|
303
|
+
connector: ConnectorDefinition,
|
|
304
|
+
resource: ResourceDefinition,
|
|
305
|
+
initialCutoff: Cursor.Value,
|
|
306
|
+
) {
|
|
307
|
+
if (!resource.backfill) return;
|
|
308
|
+
const store = yield* StateStore;
|
|
309
|
+
let state = yield* getInitializedState(resource.name, initialCutoff);
|
|
310
|
+
|
|
311
|
+
if (state.backfill?.completed === true) {
|
|
312
|
+
// The checkpoint may have committed before the previous process cleared
|
|
313
|
+
// its error marker. It may also be hidden by a newer changes error.
|
|
314
|
+
yield* store
|
|
315
|
+
.clearResourceError(resource.name, "backfill")
|
|
316
|
+
.pipe(recordResourceError(connector, resource, "backfill", "checkpoint"));
|
|
317
|
+
yield* refreshRuntimeStatus(connector, resource, initialCutoff);
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
while (state.backfill?.completed !== true) {
|
|
322
|
+
const backfill = state.backfill ?? { cutoff: initialCutoff, completed: false };
|
|
323
|
+
const clearsVisibleError = state.lastError?.source === "backfill";
|
|
324
|
+
const page = yield* resource.backfill
|
|
325
|
+
.fetch({
|
|
326
|
+
pageCursor: backfill.pageCursor,
|
|
327
|
+
cutoff: backfill.cutoff,
|
|
328
|
+
})
|
|
329
|
+
.pipe(recordResourceError(connector, resource, "backfill", "fetch"));
|
|
330
|
+
|
|
331
|
+
yield* publishBatch({
|
|
332
|
+
connector: connector.name,
|
|
333
|
+
resource: resource.name,
|
|
334
|
+
source: "backfill",
|
|
335
|
+
batch: {
|
|
336
|
+
cursor: page.nextPageCursor ?? backfill.cutoff,
|
|
337
|
+
mutations: page.mutations,
|
|
338
|
+
},
|
|
339
|
+
}).pipe(recordResourceError(connector, resource, "backfill", "publish"));
|
|
340
|
+
|
|
341
|
+
const lastSuccessAt = yield* DateTime.now.pipe(Effect.map(DateTime.formatIso));
|
|
342
|
+
state = {
|
|
343
|
+
...state,
|
|
344
|
+
backfill: {
|
|
345
|
+
cutoff: backfill.cutoff,
|
|
346
|
+
pageCursor: page.nextPageCursor,
|
|
347
|
+
completed: !page.hasMore,
|
|
348
|
+
lastSuccessAt,
|
|
349
|
+
},
|
|
350
|
+
};
|
|
351
|
+
|
|
352
|
+
// Checkpoint only after publish succeeds so a crash cannot skip an
|
|
353
|
+
// unacknowledged page; replay therefore remains at-least-once.
|
|
354
|
+
yield* Effect.withSpan(
|
|
355
|
+
store
|
|
356
|
+
.setBackfillState(resource.name, {
|
|
357
|
+
cutoff: normalizeCursor(backfill.cutoff),
|
|
358
|
+
pageCursor:
|
|
359
|
+
page.nextPageCursor === undefined ? undefined : normalizeCursor(page.nextPageCursor),
|
|
360
|
+
completed: !page.hasMore,
|
|
361
|
+
lastSuccessAt,
|
|
362
|
+
})
|
|
363
|
+
.pipe(
|
|
364
|
+
Effect.andThen(
|
|
365
|
+
Metrics.setLastSuccessTimestamp(
|
|
366
|
+
{ connector: connector.name, resource: resource.name, source: "backfill" },
|
|
367
|
+
lastSuccessAt,
|
|
368
|
+
),
|
|
369
|
+
),
|
|
370
|
+
Effect.andThen(store.clearResourceError(resource.name, "backfill")),
|
|
371
|
+
Effect.tapError((error) => annotateError("state_set", error)),
|
|
372
|
+
),
|
|
373
|
+
SpanName.stateSet,
|
|
374
|
+
{ attributes: { [Attr.stateKey]: resource.name } },
|
|
375
|
+
).pipe(recordResourceError(connector, resource, "backfill", "checkpoint"));
|
|
376
|
+
|
|
377
|
+
// Completion or clearing an error can change the resource-wide sync state.
|
|
378
|
+
if (clearsVisibleError || !page.hasMore) {
|
|
379
|
+
state = yield* refreshRuntimeStatus(connector, resource, initialCutoff);
|
|
150
380
|
}
|
|
381
|
+
}
|
|
382
|
+
});
|
|
151
383
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
384
|
+
const runChanges = Effect.fnUntraced(function* (
|
|
385
|
+
connector: ConnectorDefinition,
|
|
386
|
+
resource: ResourceDefinition,
|
|
387
|
+
initialCutoff: Cursor.Value,
|
|
388
|
+
) {
|
|
389
|
+
if (!resource.changes) return;
|
|
390
|
+
const store = yield* StateStore;
|
|
158
391
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
: { ...state, backfill: { ...state.backfill, current: cursor } };
|
|
167
|
-
|
|
168
|
-
const resolveLiveStream = <T>(source: LiveSource<T>): Stream.Stream<Batch<T>, ConnectorError> =>
|
|
169
|
-
isWebhookStream(source) ? source.stream : source;
|
|
170
|
-
|
|
171
|
-
const isWebhookStream = <T>(source: LiveSource<T>): source is WebhookStream<T> =>
|
|
172
|
-
typeof source === "object" && source !== null && "queue" in source && "stream" in source;
|
|
173
|
-
|
|
174
|
-
const processTaggedStream = <T extends Record<string, unknown>>(
|
|
175
|
-
stream: Stream.Stream<TaggedBatch<T>, ConnectorError>,
|
|
176
|
-
name: string,
|
|
177
|
-
transform: Transform<T> | undefined,
|
|
178
|
-
stateRef: Ref.Ref<IngestionState<Cursor>>,
|
|
179
|
-
): Effect.Effect<void, ConnectorError, StateStore | Publisher> =>
|
|
180
|
-
Stream.runForEach(stream, ({ source, batch }) =>
|
|
181
|
-
Effect.gen(function* () {
|
|
182
|
-
// Optional per-row transformation.
|
|
183
|
-
const rows = transform ? yield* Effect.forEach(batch.rows, transform) : batch.rows;
|
|
184
|
-
|
|
185
|
-
// Publish before updating cursor state.
|
|
186
|
-
const publisher = yield* Publisher;
|
|
187
|
-
yield* publisher.publish({
|
|
188
|
-
name,
|
|
189
|
-
batch: {
|
|
190
|
-
cursor: batch.cursor,
|
|
191
|
-
rows,
|
|
192
|
-
},
|
|
193
|
-
});
|
|
392
|
+
while (true) {
|
|
393
|
+
const state = yield* getInitializedState(resource.name, initialCutoff);
|
|
394
|
+
const cursor = state.changes?.cursor ?? initialCutoff;
|
|
395
|
+
const clearsVisibleError = state.lastError?.source === "changes";
|
|
396
|
+
const page = yield* resource.changes
|
|
397
|
+
.fetch({ cursor })
|
|
398
|
+
.pipe(recordResourceError(connector, resource, "changes", "fetch"));
|
|
194
399
|
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
400
|
+
yield* publishBatch({
|
|
401
|
+
connector: connector.name,
|
|
402
|
+
resource: resource.name,
|
|
403
|
+
source: "changes",
|
|
404
|
+
batch: {
|
|
405
|
+
cursor: page.cursor,
|
|
406
|
+
mutations: page.mutations,
|
|
407
|
+
},
|
|
408
|
+
}).pipe(recordResourceError(connector, resource, "changes", "publish"));
|
|
199
409
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
410
|
+
const lastSuccessAt = yield* DateTime.now.pipe(Effect.map(DateTime.formatIso));
|
|
411
|
+
// Advance the durable cursor only after Wings accepts the page.
|
|
412
|
+
yield* Effect.withSpan(
|
|
413
|
+
store
|
|
414
|
+
.setChangesState(resource.name, {
|
|
415
|
+
cursor: normalizeCursor(page.cursor),
|
|
416
|
+
lastSuccessAt,
|
|
417
|
+
})
|
|
418
|
+
.pipe(
|
|
419
|
+
Effect.andThen(
|
|
420
|
+
Metrics.setLastSuccessTimestamp(
|
|
421
|
+
{ connector: connector.name, resource: resource.name, source: "changes" },
|
|
422
|
+
lastSuccessAt,
|
|
423
|
+
),
|
|
424
|
+
),
|
|
425
|
+
Effect.andThen(store.clearResourceError(resource.name, "changes")),
|
|
426
|
+
Effect.tapError((error) => annotateError("state_set", error)),
|
|
427
|
+
),
|
|
428
|
+
SpanName.stateSet,
|
|
429
|
+
{ attributes: { [Attr.stateKey]: resource.name } },
|
|
430
|
+
).pipe(recordResourceError(connector, resource, "changes", "checkpoint"));
|
|
431
|
+
|
|
432
|
+
if (clearsVisibleError) {
|
|
433
|
+
yield* refreshRuntimeStatus(connector, resource, initialCutoff);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
yield* Effect.sleep(resource.changes.interval ?? "1 minute");
|
|
437
|
+
}
|
|
438
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { run, type RunOptions } from "./engine";
|