@useairfoil/connector-kit 0.5.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 (110) hide show
  1. package/METRICS.md +78 -0
  2. package/README.md +190 -21
  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/connector-app/index.d.ts +14 -9
  8. package/dist/connector-app/index.js +9 -6
  9. package/dist/connector-app-d7Z-SZLQ.js +64 -0
  10. package/dist/connector-app-d7Z-SZLQ.js.map +1 -0
  11. package/dist/constants-BRhIX_rz.js +27 -0
  12. package/dist/constants-BRhIX_rz.js.map +1 -0
  13. package/dist/errors/index.d.ts +1 -1
  14. package/dist/{index-DK9YcR6W.d.ts → index-Byr0Lf0C.d.ts} +4 -4
  15. package/dist/index-CBT-JX-T.d.ts +173 -0
  16. package/dist/index-CTHVudvQ.d.ts +41 -0
  17. package/dist/index-CTLYQ74P.d.ts +80 -0
  18. package/dist/index-Coh_SkBs.d.ts +95 -0
  19. package/dist/{index-DyuT2jzP.d.ts → index-D54ySAKn.d.ts} +1 -1
  20. package/dist/{index-BlhJCpEk.d.ts → index-DtJX_tic.d.ts} +39 -10
  21. package/dist/{index-8KnVpoZp.d.ts → index-J42AZaXU.d.ts} +11 -5
  22. package/dist/{index-CQJsFJs3.d.ts → index-fIIBVAai.d.ts} +6 -4
  23. package/dist/index-o1-Z3n7V.d.ts +55 -0
  24. package/dist/index.d.ts +14 -9
  25. package/dist/index.js +14 -8
  26. package/dist/index.js.map +1 -1
  27. package/dist/ingestion/index.d.ts +5 -5
  28. package/dist/ingestion/index.js +7 -4
  29. package/dist/ingestion-BQo4cbGw.js +200 -0
  30. package/dist/ingestion-BQo4cbGw.js.map +1 -0
  31. package/dist/manifest/index.d.ts +2 -0
  32. package/dist/manifest/index.js +4 -0
  33. package/dist/manifest-ByE6YPG0.js +221 -0
  34. package/dist/manifest-ByE6YPG0.js.map +1 -0
  35. package/dist/metrics-CVAk1ALR.d.ts +53 -0
  36. package/dist/metrics-CWPy2Aqc.js +96 -0
  37. package/dist/metrics-CWPy2Aqc.js.map +1 -0
  38. package/dist/metrics.d.ts +4 -0
  39. package/dist/metrics.js +3 -0
  40. package/dist/publisher/index.d.ts +4 -4
  41. package/dist/publisher/index.js +3 -2
  42. package/dist/{publisher-DusgYvaV.js → publisher-BYT3RLVO.js} +2 -2
  43. package/dist/{publisher-DusgYvaV.js.map → publisher-BYT3RLVO.js.map} +1 -1
  44. package/dist/runtime-config/index.d.ts +2 -0
  45. package/dist/runtime-config/index.js +4 -0
  46. package/dist/runtime-config-BpB-d_bF.js +51 -0
  47. package/dist/runtime-config-BpB-d_bF.js.map +1 -0
  48. package/dist/server-CXTQ7odl.js +183 -0
  49. package/dist/server-CXTQ7odl.js.map +1 -0
  50. package/dist/{telemetry-p0dE5Mdu.js → service-DuFiQwid.js} +30 -10
  51. package/dist/service-DuFiQwid.js.map +1 -0
  52. package/dist/{service-B7g_lHMI.d.ts → service-IE5qaf9x.d.ts} +3 -3
  53. package/dist/state-store/index.d.ts +3 -4
  54. package/dist/state-store/index.js +4 -2
  55. package/dist/state-store-yl6LiTQ0.js +198 -0
  56. package/dist/state-store-yl6LiTQ0.js.map +1 -0
  57. package/dist/status/index.d.ts +5 -0
  58. package/dist/status/index.js +6 -0
  59. package/dist/status-B-oZBc33.js +84 -0
  60. package/dist/status-B-oZBc33.js.map +1 -0
  61. package/dist/{types-ZNrA-Dn0.d.ts → types-B35J5YPu.d.ts} +20 -6
  62. package/dist/webhook/index.d.ts +5 -4
  63. package/dist/webhook/index.js +5 -3
  64. package/dist/{webhook-BSA-tXtU.js → webhook-DI-Afxkg.js} +2 -2
  65. package/dist/{webhook-BSA-tXtU.js.map → webhook-DI-Afxkg.js.map} +1 -1
  66. package/package.json +36 -7
  67. package/src/auth/index.ts +1 -0
  68. package/src/auth/token-cache.ts +54 -0
  69. package/src/connector-app/check.ts +76 -0
  70. package/src/connector-app/index.ts +1 -0
  71. package/src/connector-app/start.ts +4 -0
  72. package/src/core/builder.ts +8 -3
  73. package/src/core/types.ts +20 -2
  74. package/src/index.ts +5 -0
  75. package/src/ingestion/engine.ts +246 -133
  76. package/src/manifest/config.ts +232 -0
  77. package/src/manifest/index.ts +14 -0
  78. package/src/manifest/manifest.ts +31 -0
  79. package/src/manifest/number-schema.ts +17 -0
  80. package/src/manifest/schema.ts +160 -0
  81. package/src/metrics.ts +128 -0
  82. package/src/publisher/instrumented.ts +68 -0
  83. package/src/runtime-config/constants.ts +27 -0
  84. package/src/runtime-config/error.ts +13 -0
  85. package/src/runtime-config/index.ts +3 -0
  86. package/src/runtime-config/layer.ts +56 -0
  87. package/src/state-store/index.ts +2 -1
  88. package/src/state-store/keys.ts +22 -0
  89. package/src/state-store/layer-key-value-store.ts +132 -0
  90. package/src/state-store/layer-memory.ts +11 -0
  91. package/src/state-store/layer-sql.ts +50 -0
  92. package/src/state-store/schema.ts +57 -0
  93. package/src/state-store/service.ts +39 -6
  94. package/src/state-store/state.ts +17 -0
  95. package/src/status/index.ts +2 -0
  96. package/src/status/route.ts +77 -0
  97. package/src/status/types.ts +33 -0
  98. package/src/telemetry.ts +46 -4
  99. package/src/webhook/server.ts +54 -19
  100. package/dist/connector-app-CvqbfN6R.js +0 -32
  101. package/dist/connector-app-CvqbfN6R.js.map +0 -1
  102. package/dist/index-DaNW63Mv.d.ts +0 -21
  103. package/dist/ingestion-CMCroK7Y.js +0 -171
  104. package/dist/ingestion-CMCroK7Y.js.map +0 -1
  105. package/dist/server-DAeFWT4o.js +0 -113
  106. package/dist/server-DAeFWT4o.js.map +0 -1
  107. package/dist/state-store-DG_4QpWG.js +0 -31
  108. package/dist/state-store-DG_4QpWG.js.map +0 -1
  109. package/dist/telemetry-p0dE5Mdu.js.map +0 -1
  110. package/src/state-store/memory.ts +0 -25
@@ -1,70 +1,36 @@
1
- import { DateTime, Effect, Layer, Metric, Queue } from "effect";
1
+ import { DateTime, Effect, Layer, Queue } from "effect";
2
2
  import { HttpRouter, type HttpServer, HttpServerResponse } from "effect/unstable/http";
3
+ import * as Observability from "effect/unstable/observability";
3
4
 
4
5
  import type {
5
6
  ConnectorDefinition,
6
7
  Cursor,
7
- ResourceBatch,
8
8
  ResourceDefinition,
9
9
  ResourceState,
10
10
  WebhookRoute,
11
11
  } from "../core/types";
12
+ import type { StateOperation, StateSource } from "../state-store/schema";
12
13
 
13
14
  import { ConnectorError } from "../errors";
15
+ import * as Metrics from "../metrics";
16
+ import { publishBatch } from "../publisher/instrumented";
14
17
  import { Publisher } from "../publisher/service";
15
18
  import { StateStore } from "../state-store";
16
- import {
17
- Attr,
18
- EventAttr,
19
- EventName,
20
- SpanName,
21
- addCurrentSpanEvent,
22
- annotateError,
23
- } from "../telemetry";
19
+ import { deriveSyncState, normalizeCursor } from "../state-store/state";
20
+ import { statusRoute } from "../status";
21
+ import { Attr, SpanName, annotateError } from "../telemetry";
24
22
  import { router, WebhookQueue, type QueuedWebhookBatch } from "../webhook/server";
25
23
 
26
- const connectorBatchesTotal = Metric.counter("connector_batches_total", {
27
- description: "Total resource batches attempted by connector sources",
28
- });
29
-
30
- const connectorMutationsTotal = Metric.counter("connector_mutations_total", {
31
- description: "Total resource mutations attempted by connector sources",
32
- });
33
-
34
- const connectorBatchSize = Metric.histogram("connector_batch_size", {
35
- description: "Distribution of resource mutation batch sizes",
36
- boundaries: [1, 5, 10, 25, 50, 100, 250, 500, 1000],
37
- });
38
-
39
24
  type RunBaseOptions = {
40
25
  readonly initialCutoff?: Cursor.Value;
41
26
  };
42
27
 
43
- const normalizeCursor = (value: Cursor.Value): Cursor.Value =>
44
- value instanceof Date ? value.toISOString() : value;
45
-
46
- const normalizeResourceState = (state: ResourceState): ResourceState => ({
47
- backfill: state.backfill
48
- ? {
49
- cutoff: normalizeCursor(state.backfill.cutoff),
50
- pageCursor:
51
- state.backfill.pageCursor === undefined
52
- ? undefined
53
- : normalizeCursor(state.backfill.pageCursor),
54
- completed: state.backfill.completed,
55
- }
56
- : undefined,
57
- changes: state.changes
58
- ? {
59
- cursor: normalizeCursor(state.changes.cursor),
60
- }
61
- : undefined,
62
- });
63
-
64
28
  export type RunOptions = RunBaseOptions & {
65
29
  readonly webhook?: {
66
30
  readonly routes: ReadonlyArray<WebhookRoute>;
67
31
  readonly healthPath?: HttpRouter.PathInput;
32
+ readonly metricsPath?: HttpRouter.PathInput | false;
33
+ readonly statusPath?: HttpRouter.PathInput | false;
68
34
  readonly disableHttpLogger?: boolean;
69
35
  };
70
36
  };
@@ -77,6 +43,10 @@ type RunWebhookOptions = RunOptions & {
77
43
  readonly webhook: NonNullable<RunOptions["webhook"]>;
78
44
  };
79
45
 
46
+ /**
47
+ * Runs all connector ingestion loops. Callers provide the Publisher and
48
+ * StateStore layers; webhook mode additionally requires an HttpServer layer.
49
+ */
80
50
  export function run<const Resources extends ReadonlyArray<ResourceDefinition>>(
81
51
  connector: ConnectorDefinition<Resources>,
82
52
  options?: RunNoWebhookOptions,
@@ -86,8 +56,12 @@ export function run<const Resources extends ReadonlyArray<ResourceDefinition>>(
86
56
  options: RunWebhookOptions,
87
57
  ): Effect.Effect<void, ConnectorError, StateStore | Publisher | HttpServer.HttpServer>;
88
58
  export function run(connector: ConnectorDefinition, options?: RunOptions) {
59
+ const queueLayer = makeWebhookQueueLayer(connector.name);
89
60
  const runtimeLayer = options?.webhook
90
- ? Layer.mergeAll(makeWebhookQueueLayer(), makeWebhookServerLayer(options.webhook))
61
+ ? Layer.mergeAll(
62
+ queueLayer,
63
+ makeWebhookServerLayer(connector, options.webhook).pipe(Layer.provide(queueLayer)),
64
+ )
91
65
  : Layer.empty;
92
66
 
93
67
  return Effect.gen(function* () {
@@ -101,7 +75,9 @@ export function run(connector: ConnectorDefinition, options?: RunOptions) {
101
75
  );
102
76
 
103
77
  const sourceRun = runIngestion(connector, initialCutoff);
104
- const webhookRun = options?.webhook ? runWebhookRuntime(options.webhook.routes) : Effect.void;
78
+ const webhookRun = options?.webhook
79
+ ? runWebhookRuntime(connector, options.webhook.routes)
80
+ : Effect.void;
105
81
 
106
82
  return yield* Effect.all([sourceRun, webhookRun], { concurrency: "unbounded" }).pipe(
107
83
  Effect.asVoid,
@@ -109,38 +85,61 @@ export function run(connector: ConnectorDefinition, options?: RunOptions) {
109
85
  }).pipe(Effect.provide(runtimeLayer));
110
86
  }
111
87
 
112
- const makeWebhookQueueLayer = (): Layer.Layer<WebhookQueue> =>
88
+ const makeWebhookQueueLayer = (connector: string): Layer.Layer<WebhookQueue> =>
113
89
  Layer.effect(WebhookQueue)(
114
90
  Effect.gen(function* () {
115
91
  const queue = yield* Queue.bounded<QueuedWebhookBatch>(1024);
92
+ yield* Metrics.setWebhookQueueDepth(connector, 0);
116
93
  return WebhookQueue.of({ queue });
117
94
  }),
118
95
  );
119
96
 
120
- const runWebhookRuntime = (routes: ReadonlyArray<WebhookRoute>) => {
97
+ const runWebhookRuntime = (connector: ConnectorDefinition, routes: ReadonlyArray<WebhookRoute>) => {
121
98
  const hasAfterEnqueue = routes.some((route) => route.ackMode === "after-enqueue");
122
- return Effect.all([hasAfterEnqueue ? runWebhookQueueConsumer : Effect.void, Effect.never], {
123
- concurrency: "unbounded",
124
- }).pipe(Effect.asVoid);
99
+ return Effect.all(
100
+ [hasAfterEnqueue ? runWebhookQueueConsumer(connector) : Effect.void, Effect.never],
101
+ {
102
+ concurrency: "unbounded",
103
+ },
104
+ ).pipe(Effect.asVoid);
125
105
  };
126
106
 
127
- const runWebhookQueueConsumer = Effect.gen(function* () {
128
- const queue = yield* WebhookQueue;
129
- while (true) {
130
- const batch = yield* Queue.take(queue.queue);
131
- yield* publishBatch({ resource: batch.resource, source: "webhook", batch: batch.batch });
132
- }
133
- });
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
+ }
121
+ });
134
122
 
135
- const makeWebhookServerLayer = (options: {
136
- readonly routes: ReadonlyArray<WebhookRoute>;
137
- readonly healthPath?: HttpRouter.PathInput;
138
- readonly disableHttpLogger?: boolean;
139
- }): Layer.Layer<never, never, HttpServer.HttpServer> => {
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> => {
140
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;
141
136
  const app = Layer.mergeAll(
142
- router(options.routes),
137
+ router(options.routes, { connectorName: connector.name }),
143
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)]),
144
143
  );
145
144
 
146
145
  return HttpRouter.serve(app, {
@@ -164,6 +163,7 @@ const initializeResourceState = (
164
163
  existing: ResourceState | undefined,
165
164
  initialCutoff: Cursor.Value,
166
165
  ): ResourceState => ({
166
+ ...existing,
167
167
  changes: existing?.changes ?? { cursor: initialCutoff },
168
168
  backfill: existing?.backfill ?? {
169
169
  cutoff: initialCutoff,
@@ -186,13 +186,119 @@ const runResourceSources = (
186
186
  initialCutoff: Cursor.Value,
187
187
  ) => {
188
188
  const runs = [
189
- resource.backfill ? runBackfill(connector, resource, initialCutoff) : Effect.void,
190
- resource.changes ? runChanges(connector, resource, initialCutoff) : Effect.void,
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,
191
205
  ];
192
206
 
193
- return Effect.all(runs, { concurrency: "unbounded" }).pipe(Effect.asVoid);
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,
216
+ }),
217
+ Effect.andThen(Effect.never),
218
+ ),
219
+ ),
220
+ );
194
221
  };
195
222
 
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
+ ),
243
+ );
244
+
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
+ );
264
+
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
+
196
302
  const runBackfill = Effect.fnUntraced(function* (
197
303
  connector: ConnectorDefinition,
198
304
  resource: ResourceDefinition,
@@ -202,39 +308,76 @@ const runBackfill = Effect.fnUntraced(function* (
202
308
  const store = yield* StateStore;
203
309
  let state = yield* getInitializedState(resource.name, initialCutoff);
204
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
+
205
321
  while (state.backfill?.completed !== true) {
206
322
  const backfill = state.backfill ?? { cutoff: initialCutoff, completed: false };
207
- const page = yield* resource.backfill.fetch({
208
- pageCursor: backfill.pageCursor,
209
- cutoff: backfill.cutoff,
210
- });
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"));
211
330
 
212
331
  yield* publishBatch({
213
- connector,
332
+ connector: connector.name,
214
333
  resource: resource.name,
215
334
  source: "backfill",
216
335
  batch: {
217
336
  cursor: page.nextPageCursor ?? backfill.cutoff,
218
337
  mutations: page.mutations,
219
338
  },
220
- });
339
+ }).pipe(recordResourceError(connector, resource, "backfill", "publish"));
221
340
 
341
+ const lastSuccessAt = yield* DateTime.now.pipe(Effect.map(DateTime.formatIso));
222
342
  state = {
223
343
  ...state,
224
344
  backfill: {
225
345
  cutoff: backfill.cutoff,
226
346
  pageCursor: page.nextPageCursor,
227
347
  completed: !page.hasMore,
348
+ lastSuccessAt,
228
349
  },
229
350
  };
230
351
 
352
+ // Checkpoint only after publish succeeds so a crash cannot skip an
353
+ // unacknowledged page; replay therefore remains at-least-once.
231
354
  yield* Effect.withSpan(
232
355
  store
233
- .setResourceState(resource.name, normalizeResourceState(state))
234
- .pipe(Effect.tapError((error) => annotateError("state_set", error))),
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
+ ),
235
373
  SpanName.stateSet,
236
374
  { attributes: { [Attr.stateKey]: resource.name } },
237
- );
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);
380
+ }
238
381
  }
239
382
  });
240
383
 
@@ -249,77 +392,47 @@ const runChanges = Effect.fnUntraced(function* (
249
392
  while (true) {
250
393
  const state = yield* getInitializedState(resource.name, initialCutoff);
251
394
  const cursor = state.changes?.cursor ?? initialCutoff;
252
- const page = yield* resource.changes.fetch({ cursor });
395
+ const clearsVisibleError = state.lastError?.source === "changes";
396
+ const page = yield* resource.changes
397
+ .fetch({ cursor })
398
+ .pipe(recordResourceError(connector, resource, "changes", "fetch"));
253
399
 
254
400
  yield* publishBatch({
255
- connector,
401
+ connector: connector.name,
256
402
  resource: resource.name,
257
403
  source: "changes",
258
404
  batch: {
259
405
  cursor: page.cursor,
260
406
  mutations: page.mutations,
261
407
  },
262
- });
408
+ }).pipe(recordResourceError(connector, resource, "changes", "publish"));
263
409
 
410
+ const lastSuccessAt = yield* DateTime.now.pipe(Effect.map(DateTime.formatIso));
411
+ // Advance the durable cursor only after Wings accepts the page.
264
412
  yield* Effect.withSpan(
265
413
  store
266
- .setResourceState(
267
- resource.name,
268
- normalizeResourceState({
269
- ...state,
270
- changes: { cursor: page.cursor },
271
- }),
272
- )
273
- .pipe(Effect.tapError((error) => annotateError("state_set", error))),
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
+ ),
274
428
  SpanName.stateSet,
275
429
  { attributes: { [Attr.stateKey]: resource.name } },
276
- );
430
+ ).pipe(recordResourceError(connector, resource, "changes", "checkpoint"));
277
431
 
278
- yield* Effect.sleep(resource.changes.interval ?? "1 minute");
279
- }
280
- });
432
+ if (clearsVisibleError) {
433
+ yield* refreshRuntimeStatus(connector, resource, initialCutoff);
434
+ }
281
435
 
282
- const publishBatch = Effect.fnUntraced(function* (options: {
283
- readonly connector?: ConnectorDefinition;
284
- readonly resource: string;
285
- readonly source: "backfill" | "changes" | "webhook";
286
- readonly batch: ResourceBatch;
287
- }) {
288
- const metric = {
289
- connector: options.connector?.name ?? "unknown",
290
- resource: options.resource,
291
- source: options.source,
292
- };
293
- yield* Metric.update(Metric.withAttributes(connectorBatchesTotal, metric), 1);
294
- yield* Metric.update(
295
- Metric.withAttributes(connectorMutationsTotal, metric),
296
- options.batch.mutations.length,
297
- );
298
- yield* Metric.update(
299
- Metric.withAttributes(connectorBatchSize, metric),
300
- options.batch.mutations.length,
301
- );
302
-
303
- const publisher = yield* Publisher;
304
- const ack = yield* publisher.publish({
305
- resource: options.resource,
306
- source: options.source,
307
- batch: options.batch,
308
- });
309
-
310
- yield* Effect.annotateCurrentSpan({ [Attr.publisherSuccess]: ack.status === "accepted" });
311
- if (ack.status === "rejected") {
312
- yield* Effect.annotateCurrentSpan({ [Attr.errorPhase]: "publish" });
313
- return yield* Effect.fail(
314
- new ConnectorError({
315
- message: `Publisher rejected batch for ${options.resource}: ${ack.reason}`,
316
- }),
317
- );
318
- }
319
-
320
- if (options.batch.cursor !== undefined) {
321
- yield* addCurrentSpanEvent(EventName.batchCheckpoint, {
322
- [EventAttr.batchCursor]: options.batch.cursor,
323
- });
436
+ yield* Effect.sleep(resource.changes.interval ?? "1 minute");
324
437
  }
325
438
  });