@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.
Files changed (117) hide show
  1. package/METRICS.md +78 -0
  2. package/README.md +450 -88
  3. package/dist/auth/index.d.ts +2 -0
  4. package/dist/auth/index.js +3 -0
  5. package/dist/auth-DvU_CZ7C.js +42 -0
  6. package/dist/auth-DvU_CZ7C.js.map +1 -0
  7. package/dist/chunk-Bp6m_JJh.js +13 -0
  8. package/dist/connector-app/index.d.ts +14 -0
  9. package/dist/connector-app/index.js +11 -0
  10. package/dist/connector-app-d7Z-SZLQ.js +64 -0
  11. package/dist/connector-app-d7Z-SZLQ.js.map +1 -0
  12. package/dist/constants-BRhIX_rz.js +27 -0
  13. package/dist/constants-BRhIX_rz.js.map +1 -0
  14. package/dist/errors/index.d.ts +2 -0
  15. package/dist/errors/index.js +3 -0
  16. package/dist/errors-DHWylqRZ.js +8 -0
  17. package/dist/errors-DHWylqRZ.js.map +1 -0
  18. package/dist/index-Byr0Lf0C.d.ts +25 -0
  19. package/dist/index-CBT-JX-T.d.ts +173 -0
  20. package/dist/index-CTHVudvQ.d.ts +41 -0
  21. package/dist/index-CTLYQ74P.d.ts +80 -0
  22. package/dist/index-Coh_SkBs.d.ts +95 -0
  23. package/dist/index-D54ySAKn.d.ts +14 -0
  24. package/dist/index-DtJX_tic.d.ts +153 -0
  25. package/dist/index-J42AZaXU.d.ts +38 -0
  26. package/dist/index-fIIBVAai.d.ts +25 -0
  27. package/dist/index-o1-Z3n7V.d.ts +55 -0
  28. package/dist/index.d.ts +14 -165
  29. package/dist/index.js +50 -228
  30. package/dist/index.js.map +1 -1
  31. package/dist/ingestion/index.d.ts +6 -0
  32. package/dist/ingestion/index.js +10 -0
  33. package/dist/ingestion-BQo4cbGw.js +200 -0
  34. package/dist/ingestion-BQo4cbGw.js.map +1 -0
  35. package/dist/manifest/index.d.ts +2 -0
  36. package/dist/manifest/index.js +4 -0
  37. package/dist/manifest-ByE6YPG0.js +221 -0
  38. package/dist/manifest-ByE6YPG0.js.map +1 -0
  39. package/dist/metrics-CVAk1ALR.d.ts +53 -0
  40. package/dist/metrics-CWPy2Aqc.js +96 -0
  41. package/dist/metrics-CWPy2Aqc.js.map +1 -0
  42. package/dist/metrics.d.ts +4 -0
  43. package/dist/metrics.js +3 -0
  44. package/dist/publisher/index.d.ts +5 -0
  45. package/dist/publisher/index.js +6 -0
  46. package/dist/publisher-BYT3RLVO.js +131 -0
  47. package/dist/publisher-BYT3RLVO.js.map +1 -0
  48. package/dist/runtime-config/index.d.ts +2 -0
  49. package/dist/runtime-config/index.js +4 -0
  50. package/dist/runtime-config-BpB-d_bF.js +51 -0
  51. package/dist/runtime-config-BpB-d_bF.js.map +1 -0
  52. package/dist/server-CXTQ7odl.js +183 -0
  53. package/dist/server-CXTQ7odl.js.map +1 -0
  54. package/dist/service-DuFiQwid.js +191 -0
  55. package/dist/service-DuFiQwid.js.map +1 -0
  56. package/dist/service-IE5qaf9x.d.ts +32 -0
  57. package/dist/state-store/index.d.ts +3 -0
  58. package/dist/state-store/index.js +5 -0
  59. package/dist/state-store-yl6LiTQ0.js +198 -0
  60. package/dist/state-store-yl6LiTQ0.js.map +1 -0
  61. package/dist/status/index.d.ts +5 -0
  62. package/dist/status/index.js +6 -0
  63. package/dist/status-B-oZBc33.js +84 -0
  64. package/dist/status-B-oZBc33.js.map +1 -0
  65. package/dist/types-B35J5YPu.d.ts +174 -0
  66. package/dist/webhook/index.d.ts +6 -0
  67. package/dist/webhook/index.js +8 -0
  68. package/dist/webhook-DI-Afxkg.js +13 -0
  69. package/dist/webhook-DI-Afxkg.js.map +1 -0
  70. package/package.json +66 -8
  71. package/src/auth/index.ts +1 -0
  72. package/src/auth/token-cache.ts +54 -0
  73. package/src/connector-app/check.ts +76 -0
  74. package/src/connector-app/index.ts +2 -0
  75. package/src/connector-app/start.ts +42 -0
  76. package/src/core/builder.ts +81 -9
  77. package/src/core/index.ts +22 -0
  78. package/src/core/types.ts +204 -76
  79. package/src/errors/index.ts +1 -0
  80. package/src/index.ts +13 -28
  81. package/src/ingestion/engine.ts +409 -174
  82. package/src/ingestion/index.ts +1 -0
  83. package/src/manifest/config.ts +232 -0
  84. package/src/manifest/index.ts +14 -0
  85. package/src/manifest/manifest.ts +31 -0
  86. package/src/manifest/number-schema.ts +17 -0
  87. package/src/manifest/schema.ts +160 -0
  88. package/src/metrics.ts +128 -0
  89. package/src/publisher/console.ts +18 -0
  90. package/src/publisher/index.ts +7 -0
  91. package/src/publisher/instrumented.ts +68 -0
  92. package/src/publisher/service.ts +30 -13
  93. package/src/publisher/wings.ts +159 -72
  94. package/src/runtime/context.ts +15 -0
  95. package/src/runtime-config/constants.ts +27 -0
  96. package/src/runtime-config/error.ts +13 -0
  97. package/src/runtime-config/index.ts +3 -0
  98. package/src/runtime-config/layer.ts +56 -0
  99. package/src/state-store/index.ts +3 -0
  100. package/src/state-store/keys.ts +22 -0
  101. package/src/state-store/layer-key-value-store.ts +132 -0
  102. package/src/state-store/layer-memory.ts +11 -0
  103. package/src/state-store/layer-sql.ts +50 -0
  104. package/src/state-store/schema.ts +57 -0
  105. package/src/state-store/service.ts +51 -0
  106. package/src/state-store/state.ts +17 -0
  107. package/src/status/index.ts +2 -0
  108. package/src/status/route.ts +77 -0
  109. package/src/status/types.ts +33 -0
  110. package/src/telemetry.ts +288 -0
  111. package/src/webhook/index.ts +8 -0
  112. package/src/webhook/server.ts +209 -39
  113. package/src/webhook/types.ts +6 -17
  114. package/src/ingestion/state-store.ts +0 -36
  115. package/src/streams/pull-stream.ts +0 -54
  116. package/src/streams/webhook-queue.ts +0 -14
  117. /package/src/{core/errors.ts → errors/connector-error.ts} +0 -0
@@ -1,203 +1,438 @@
1
- import { Effect, Queue, Ref, Stream } from "effect";
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
- EntityDefinition,
9
- EntityRow,
10
- EntitySchema,
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 "./state-store";
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 TaggedBatch<T> = {
22
- readonly source: "live" | "backfill";
23
- readonly batch: Batch<T>;
24
+ type RunBaseOptions = {
25
+ readonly initialCutoff?: Cursor.Value;
24
26
  };
25
27
 
26
- export const runConnector = (
27
- connector: ConnectorDefinition,
28
- initialCutoff: Cursor,
29
- ): Effect.Effect<void, ConnectorError, StateStore | Publisher> =>
30
- Effect.gen(function* () {
31
- // Start ingestion for every entity and event in parallel.
32
- const entityRuns = connector.entities.map((entity) => runEntity(entity, initialCutoff));
33
- const eventRuns = connector.events.map((event) => runEvent(event, initialCutoff));
34
- // main runner
35
- yield* Effect.all([...entityRuns, ...eventRuns], {
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 createInitialState = (cutoff: Cursor): IngestionState<Cursor> => ({
41
- backfill: { cutoff },
42
- live: { cutoff },
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
- const makeStateRef = (
46
- key: string,
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 runEntity = <S extends EntitySchema>(
58
- entity: EntityDefinition<S>,
59
- initialCutoff: Cursor,
150
+ const runIngestion = (
151
+ connector: ConnectorDefinition,
152
+ initialCutoff: Cursor.Value,
60
153
  ): Effect.Effect<void, ConnectorError, StateStore | Publisher> =>
61
- Effect.gen(function* () {
62
- type Row = EntityRow<S>;
63
- const stateRef = yield* makeStateRef(entity.name, initialCutoff);
64
- // Tracks which primary keys have already been emitted.
65
- const seenRef = yield* Ref.make(new Set<string>());
66
-
67
- const liveStream = resolveLiveStream(entity.live);
68
- const tagLive = (batch: Batch<Row>) => ({
69
- source: "live" as const,
70
- batch,
71
- });
72
- const updateSeen = (rows: ReadonlyArray<Row>) =>
73
- Ref.update(seenRef, (seen) => {
74
- const next = new Set(seen);
75
- for (const row of rows) {
76
- const key = String(row[entity.primaryKey]);
77
- next.add(key);
78
- }
79
- return next;
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
- // Entities are upserts, so live and backfill can overlap. We keep an in-memory
83
- // seen set (primary keys) so backfill does not re-emit rows already observed live.
84
- const backfillTagged = Stream.mapEffect(entity.backfill, (batch) =>
85
- Ref.get(seenRef).pipe(
86
- Effect.map((seen) => {
87
- const filtered = batch.rows.filter((row) => {
88
- const key = String(row[entity.primaryKey]);
89
- return !seen.has(key);
90
- });
91
- return {
92
- source: "backfill" as const,
93
- batch: { cursor: batch.cursor, rows: filtered },
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
- ).pipe(Stream.tap(({ batch }) => updateSeen(batch.rows)));
98
-
99
- // For webhook live sources, we wait for the first live batch before starting
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
- // For pull-based live sources, we can merge immediately because there is no
125
- // webhook cutoff gating and the live stream is not queue-backed.
126
- const liveTagged = Stream.map(liveStream, tagLive).pipe(
127
- Stream.tap(({ batch }) => updateSeen(batch.rows)),
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
- const merged = Stream.merge(liveTagged, backfillTagged);
131
- yield* processTaggedStream(merged, entity.name, entity.transform, stateRef);
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 runEvent = <S extends EntitySchema>(
135
- event: EventDefinition<S>,
136
- initialCutoff: Cursor,
137
- ): Effect.Effect<void, ConnectorError, StateStore | Publisher> =>
138
- Effect.gen(function* () {
139
- type Row = EntityRow<S>;
140
- const stateRef = yield* makeStateRef(event.name, initialCutoff);
141
- const liveStream = resolveLiveStream<Row>(event.live);
142
-
143
- // Events must backfill first to preserve ordering.
144
- if (event.backfill) {
145
- const backfillTagged = Stream.map(event.backfill, (batch) => ({
146
- source: "backfill" as const,
147
- batch,
148
- }));
149
- yield* processTaggedStream(backfillTagged, event.name, event.transform, stateRef);
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
- const liveTagged = Stream.map(liveStream, (batch) => ({
153
- source: "live" as const,
154
- batch,
155
- }));
156
- yield* processTaggedStream(liveTagged, event.name, event.transform, stateRef);
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
- const updateState = (
160
- state: IngestionState<Cursor>,
161
- source: "live" | "backfill",
162
- cursor: Cursor,
163
- ): IngestionState<Cursor> =>
164
- source === "live"
165
- ? { ...state, live: { ...state.live, current: cursor } }
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
- // Persist state only after publish succeeds.
196
- const nextState = yield* Ref.updateAndGet(stateRef, (state) =>
197
- updateState(state, source, batch.cursor),
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
- const store = yield* StateStore;
201
- yield* store.setState(name, nextState);
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";