@useairfoil/connector-kit 0.6.0 → 0.7.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.
Files changed (182) hide show
  1. package/LICENSE.txt +201 -0
  2. package/METRICS.md +38 -42
  3. package/README.md +111 -444
  4. package/dist/annotations-vzSzXtr-.d.mts +34 -0
  5. package/dist/arrow-flight-DDQ0ktx9.mjs +251 -0
  6. package/dist/arrow-flight-DDQ0ktx9.mjs.map +1 -0
  7. package/dist/auth/index.d.mts +2 -0
  8. package/dist/{auth-DvU_CZ7C.js → auth/index.mjs} +6 -8
  9. package/dist/auth/index.mjs.map +1 -0
  10. package/dist/connector-app/index.d.mts +2 -0
  11. package/dist/connector-app/index.mjs +2 -0
  12. package/dist/connector-app-C6_dZnhE.mjs +256 -0
  13. package/dist/connector-app-C6_dZnhE.mjs.map +1 -0
  14. package/dist/{constants-D113Ut1x.js → constants-v3UX-hf6.mjs} +5 -5
  15. package/dist/constants-v3UX-hf6.mjs.map +1 -0
  16. package/dist/demo/index.d.mts +2 -0
  17. package/dist/demo/index.mjs +2 -0
  18. package/dist/demo-DqTJ7TWI.mjs +204 -0
  19. package/dist/demo-DqTJ7TWI.mjs.map +1 -0
  20. package/dist/errors/index.d.mts +2 -0
  21. package/dist/errors/index.mjs +2 -0
  22. package/dist/{errors-DHWylqRZ.js → errors-pT0YVJU5.mjs} +2 -3
  23. package/dist/errors-pT0YVJU5.mjs.map +1 -0
  24. package/dist/iceberg/index.d.mts +3 -0
  25. package/dist/iceberg/index.mjs +3 -0
  26. package/dist/iceberg-Cj2PwlNm.mjs +30 -0
  27. package/dist/iceberg-Cj2PwlNm.mjs.map +1 -0
  28. package/dist/index-BTAbexBp.d.mts +55 -0
  29. package/dist/{index-DXa26Ry5.d.ts → index-BX8G7d41.d.mts} +1 -2
  30. package/dist/{index-VpHaZXZw.d.ts → index-Byr8NTdG.d.mts} +11 -13
  31. package/dist/index-CV6V6fyx.d.mts +79 -0
  32. package/dist/{index-C5Ma7V7t.d.ts → index-CZKx2Yce.d.mts} +72 -37
  33. package/dist/{index-DYpEMJf-.d.ts → index-CgvFomO6.d.mts} +6 -7
  34. package/dist/{index-bfjH7Yb0.d.ts → index-DeTwIUKl.d.mts} +9 -10
  35. package/dist/{index-DKoRwyFn.d.ts → index-DkVNO6ks.d.mts} +2 -5
  36. package/dist/{index-BKw7jA2a.d.ts → index-dHS59EZl.d.mts} +10 -8
  37. package/dist/{index-CR9HJDdr.d.ts → index-eFuoMZZ3.d.mts} +18 -20
  38. package/dist/{index-ZSuiZX5H.d.ts → index-fHIJJoRe.d.mts} +25 -30
  39. package/dist/index-uFa2wwYo.d.mts +27 -0
  40. package/dist/index.d.mts +16 -0
  41. package/dist/index.mjs +45 -0
  42. package/dist/index.mjs.map +1 -0
  43. package/dist/ingestion/index.d.mts +2 -0
  44. package/dist/ingestion/index.mjs +2 -0
  45. package/dist/ingestion-DoCUwm5N.mjs +275 -0
  46. package/dist/ingestion-DoCUwm5N.mjs.map +1 -0
  47. package/dist/ingestor/index.d.mts +3 -0
  48. package/dist/ingestor/index.mjs +4 -0
  49. package/dist/ingestor-DjCEzBzZ.mjs +26 -0
  50. package/dist/ingestor-DjCEzBzZ.mjs.map +1 -0
  51. package/dist/instrumented-Bq1WX2L5.mjs +29 -0
  52. package/dist/instrumented-Bq1WX2L5.mjs.map +1 -0
  53. package/dist/manifest/index.d.mts +2 -0
  54. package/dist/manifest/{index.js → index.mjs} +2 -4
  55. package/dist/{manifest-P7kBnch9.js → manifest-CABeIhAG.mjs} +14 -19
  56. package/dist/manifest-CABeIhAG.mjs.map +1 -0
  57. package/dist/{telemetry-j_nxwYfh.js → metrics-C901IIYZ.mjs} +172 -26
  58. package/dist/metrics-C901IIYZ.mjs.map +1 -0
  59. package/dist/{metrics-C4V1pm6v.d.ts → metrics-DoJ0WeKu.d.mts} +18 -13
  60. package/dist/metrics.d.mts +2 -0
  61. package/dist/metrics.mjs +2 -0
  62. package/dist/{chunk-Bp6m_JJh.js → rolldown-runtime-D7D4PA-g.mjs} +4 -4
  63. package/dist/runtime-config/index.d.mts +2 -0
  64. package/dist/runtime-config/index.mjs +3 -0
  65. package/dist/runtime-config-Bn8Fivt1.mjs +100 -0
  66. package/dist/runtime-config-Bn8Fivt1.mjs.map +1 -0
  67. package/dist/server-D1TTN0_D.mjs +144 -0
  68. package/dist/server-D1TTN0_D.mjs.map +1 -0
  69. package/dist/service-BRoy_EId.d.mts +27 -0
  70. package/dist/service-jWxxfEht.mjs +7 -0
  71. package/dist/service-jWxxfEht.mjs.map +1 -0
  72. package/dist/state-store/index.d.mts +2 -0
  73. package/dist/state-store/index.mjs +2 -0
  74. package/dist/{state-store-BdYulQOY.js → state-store-DhSrKVEH.mjs} +15 -22
  75. package/dist/state-store-DhSrKVEH.mjs.map +1 -0
  76. package/dist/status/{index.d.ts → index.d.mts} +1 -4
  77. package/dist/status/{index.js → index.mjs} +2 -6
  78. package/dist/{status-BTTd7AUR.js → status-4h6Oxh-O.mjs} +14 -16
  79. package/dist/status-4h6Oxh-O.mjs.map +1 -0
  80. package/dist/table-CxM09xf7.mjs +307 -0
  81. package/dist/table-CxM09xf7.mjs.map +1 -0
  82. package/dist/types-NtSv6gwl.d.mts +220 -0
  83. package/dist/webhook/index.d.mts +3 -0
  84. package/dist/webhook/index.mjs +12 -0
  85. package/dist/webhook/index.mjs.map +1 -0
  86. package/package.json +72 -58
  87. package/src/catalog/retry.ts +25 -0
  88. package/src/catalog/table.ts +237 -0
  89. package/src/connector-app/create-table.ts +47 -0
  90. package/src/connector-app/demo.ts +47 -0
  91. package/src/connector-app/index.ts +3 -0
  92. package/src/connector-app/setup.ts +69 -0
  93. package/src/connector-app/start.ts +1 -1
  94. package/src/core/builder.ts +15 -23
  95. package/src/core/index.ts +11 -1
  96. package/src/core/types.ts +114 -39
  97. package/src/demo/definition.ts +68 -0
  98. package/src/demo/generate.ts +167 -0
  99. package/src/demo/index.ts +3 -0
  100. package/src/demo/run.ts +172 -0
  101. package/src/iceberg/annotations.ts +49 -0
  102. package/src/iceberg/error.ts +9 -0
  103. package/src/iceberg/index.ts +3 -0
  104. package/src/iceberg/table.ts +461 -0
  105. package/src/index.ts +3 -1
  106. package/src/ingestion/engine.ts +240 -60
  107. package/src/ingestor/arrow-flight.ts +141 -0
  108. package/src/ingestor/arrow.ts +248 -0
  109. package/src/ingestor/console.ts +13 -0
  110. package/src/ingestor/index.ts +13 -0
  111. package/src/ingestor/instrumented.ts +61 -0
  112. package/src/ingestor/service.ts +30 -0
  113. package/src/manifest/config.ts +3 -3
  114. package/src/metrics.ts +66 -20
  115. package/src/runtime-config/config.ts +11 -8
  116. package/src/runtime-config/constants.ts +3 -2
  117. package/src/runtime-config/index.ts +2 -7
  118. package/src/runtime-config/layer.ts +1 -1
  119. package/src/runtime-config/table-bindings.ts +50 -136
  120. package/src/state-store/layer-key-value-store.ts +8 -3
  121. package/src/state-store/layer-memory.ts +1 -1
  122. package/src/state-store/layer-sql.ts +4 -4
  123. package/src/state-store/schema.ts +2 -2
  124. package/src/state-store/state.ts +14 -2
  125. package/src/status/route.ts +2 -2
  126. package/src/telemetry.ts +20 -28
  127. package/src/webhook/server.ts +39 -40
  128. package/dist/auth/index.d.ts +0 -2
  129. package/dist/auth/index.js +0 -3
  130. package/dist/auth-DvU_CZ7C.js.map +0 -1
  131. package/dist/connector-app/index.d.ts +0 -14
  132. package/dist/connector-app/index.js +0 -12
  133. package/dist/connector-app-BljHd0Pe.js +0 -64
  134. package/dist/connector-app-BljHd0Pe.js.map +0 -1
  135. package/dist/constants-D113Ut1x.js.map +0 -1
  136. package/dist/errors/index.d.ts +0 -2
  137. package/dist/errors/index.js +0 -3
  138. package/dist/errors-DHWylqRZ.js.map +0 -1
  139. package/dist/index-BA8ASHKr.d.ts +0 -31
  140. package/dist/index.d.ts +0 -14
  141. package/dist/index.js +0 -60
  142. package/dist/index.js.map +0 -1
  143. package/dist/ingestion/index.d.ts +0 -6
  144. package/dist/ingestion/index.js +0 -11
  145. package/dist/ingestion-QBGKrFr9.js +0 -200
  146. package/dist/ingestion-QBGKrFr9.js.map +0 -1
  147. package/dist/manifest/index.d.ts +0 -2
  148. package/dist/manifest-P7kBnch9.js.map +0 -1
  149. package/dist/metrics-inkCAxKq.js +0 -136
  150. package/dist/metrics-inkCAxKq.js.map +0 -1
  151. package/dist/metrics.d.ts +0 -4
  152. package/dist/metrics.js +0 -5
  153. package/dist/publisher/index.d.ts +0 -6
  154. package/dist/publisher/index.js +0 -8
  155. package/dist/publisher-BdWJOfer.js +0 -138
  156. package/dist/publisher-BdWJOfer.js.map +0 -1
  157. package/dist/runtime-config/index.d.ts +0 -4
  158. package/dist/runtime-config/index.js +0 -5
  159. package/dist/runtime-config-IbyCVpei.js +0 -60
  160. package/dist/runtime-config-IbyCVpei.js.map +0 -1
  161. package/dist/server-D-eLX3yN.js +0 -183
  162. package/dist/server-D-eLX3yN.js.map +0 -1
  163. package/dist/service-CUjGhe-7.d.ts +0 -32
  164. package/dist/service-DiP-XETo.js +0 -8
  165. package/dist/service-DiP-XETo.js.map +0 -1
  166. package/dist/state-store/index.d.ts +0 -3
  167. package/dist/state-store/index.js +0 -5
  168. package/dist/state-store-BdYulQOY.js.map +0 -1
  169. package/dist/status-BTTd7AUR.js.map +0 -1
  170. package/dist/table-bindings-Dp1DhTeS.js +0 -106
  171. package/dist/table-bindings-Dp1DhTeS.js.map +0 -1
  172. package/dist/telemetry-j_nxwYfh.js.map +0 -1
  173. package/dist/types-jVD5Ys43.d.ts +0 -174
  174. package/dist/webhook/index.d.ts +0 -7
  175. package/dist/webhook/index.js +0 -9
  176. package/dist/webhook-MhFcLW-o.js +0 -13
  177. package/dist/webhook-MhFcLW-o.js.map +0 -1
  178. package/src/publisher/console.ts +0 -18
  179. package/src/publisher/index.ts +0 -7
  180. package/src/publisher/instrumented.ts +0 -75
  181. package/src/publisher/service.ts +0 -37
  182. package/src/publisher/wings.ts +0 -204
package/src/index.ts CHANGED
@@ -1,10 +1,12 @@
1
1
  export * from "./core";
2
2
  export * as Auth from "./auth";
3
3
  export * as ConnectorApp from "./connector-app";
4
+ export * as Demo from "./demo";
5
+ export * as Iceberg from "./iceberg";
4
6
  export * as Ingestion from "./ingestion";
7
+ export * as Ingestor from "./ingestor";
5
8
  export * as Manifest from "./manifest";
6
9
  export * as Metrics from "./metrics";
7
- export * as Publisher from "./publisher";
8
10
  export * as RuntimeConfig from "./runtime-config";
9
11
  export * as StateStore from "./state-store";
10
12
  export * as Status from "./status";
@@ -1,8 +1,9 @@
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";
1
+ import { Array as Arr, DateTime, Effect, Layer, Order, Queue } from "effect";
2
+ import { HttpRouter, type HttpServer, HttpServerResponse } from "effect/http";
3
+ import * as Observability from "effect/observability";
4
4
 
5
5
  import type {
6
+ ChangesFeed,
6
7
  ConnectorDefinition,
7
8
  Cursor,
8
9
  ResourceDefinition,
@@ -12,11 +13,11 @@ import type {
12
13
  import type { StateOperation, StateSource } from "../state-store/schema";
13
14
 
14
15
  import { ConnectorError } from "../errors";
16
+ import { ingestBatch } from "../ingestor/instrumented";
17
+ import { Ingestor } from "../ingestor/service";
15
18
  import * as Metrics from "../metrics";
16
- import { publishBatch } from "../publisher/instrumented";
17
- import { Publisher } from "../publisher/service";
18
19
  import { StateStore } from "../state-store";
19
- import { deriveSyncState, normalizeCursor } from "../state-store/state";
20
+ import { deriveSyncState, hasChanges, normalizeCursor } from "../state-store/state";
20
21
  import { statusRoute } from "../status";
21
22
  import { Attr, SpanName, annotateError } from "../telemetry";
22
23
  import { router, WebhookQueue, type QueuedWebhookBatch } from "../webhook/server";
@@ -44,47 +45,70 @@ type RunWebhookOptions = RunOptions & {
44
45
  };
45
46
 
46
47
  /**
47
- * Runs all connector ingestion loops. Callers provide the Publisher and
48
+ * Runs all connector ingestion loops. Callers provide the Ingestor and
48
49
  * StateStore layers; webhook mode additionally requires an HttpServer layer.
49
50
  */
50
51
  export function run<const Resources extends ReadonlyArray<ResourceDefinition>>(
51
52
  connector: ConnectorDefinition<Resources>,
52
53
  options?: RunNoWebhookOptions,
53
- ): Effect.Effect<void, ConnectorError, StateStore | Publisher>;
54
+ ): Effect.Effect<void, ConnectorError, StateStore | Ingestor>;
54
55
  export function run<const Resources extends ReadonlyArray<ResourceDefinition>>(
55
56
  connector: ConnectorDefinition<Resources>,
56
57
  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
-
58
+ ): Effect.Effect<void, ConnectorError, StateStore | Ingestor | HttpServer.HttpServer>;
59
+ export function run(definition: ConnectorDefinition, options?: RunOptions) {
67
60
  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
- );
61
+ // Check the whole feed, even the resources that don't run.
62
+ if (definition.changes) yield* validateFeed(definition, definition.changes);
63
+ const ingestor = yield* Ingestor;
64
+ const connector = selectResources(definition, ingestor.resourceNames);
65
+ const queueLayer = makeWebhookQueueLayer(connector.name);
66
+ const runtimeLayer = options?.webhook
67
+ ? Layer.mergeAll(
68
+ queueLayer,
69
+ makeWebhookServerLayer(connector, options.webhook).pipe(Layer.provide(queueLayer)),
70
+ )
71
+ : Layer.empty;
76
72
 
77
- const sourceRun = runIngestion(connector, initialCutoff);
78
- const webhookRun = options?.webhook
79
- ? runWebhookRuntime(connector, options.webhook.routes)
80
- : Effect.void;
73
+ return yield* Effect.gen(function* () {
74
+ const initialCutoff =
75
+ options?.initialCutoff ?? (yield* DateTime.now.pipe(Effect.map(DateTime.formatIso)));
76
+ yield* Effect.logInfo("Connector started").pipe(
77
+ Effect.annotateLogs({
78
+ [Attr.connectorName]: connector.name,
79
+ resources: connector.resources.length,
80
+ }),
81
+ );
81
82
 
82
- return yield* Effect.all([sourceRun, webhookRun], { concurrency: "unbounded" }).pipe(
83
- Effect.asVoid,
84
- );
85
- }).pipe(Effect.provide(runtimeLayer));
83
+ const sourceRun = runIngestion(connector, initialCutoff);
84
+ const webhookRun = options?.webhook
85
+ ? runWebhookRuntime(connector, options.webhook.routes)
86
+ : Effect.void;
87
+
88
+ return yield* Effect.all([sourceRun, webhookRun], { concurrency: "unbounded" }).pipe(
89
+ Effect.asVoid,
90
+ );
91
+ }).pipe(Effect.provide(runtimeLayer));
92
+ });
86
93
  }
87
94
 
95
+ const selectResources = (
96
+ connector: ConnectorDefinition,
97
+ names: ReadonlyArray<string> | undefined,
98
+ ): ConnectorDefinition => {
99
+ if (names === undefined) return connector;
100
+ const isSelected = (resource: ResourceDefinition) => names.includes(resource.name);
101
+ const { changes, ...rest } = connector;
102
+ const feedResources = changes?.resources.filter(isSelected) ?? [];
103
+ return {
104
+ ...rest,
105
+ resources: connector.resources.filter(isSelected),
106
+ ...(changes && feedResources.length > 0
107
+ ? { changes: { ...changes, resources: feedResources } }
108
+ : {}),
109
+ };
110
+ };
111
+
88
112
  const makeWebhookQueueLayer = (connector: string): Layer.Layer<WebhookQueue> =>
89
113
  Layer.effect(WebhookQueue)(
90
114
  Effect.gen(function* () {
@@ -111,7 +135,7 @@ const runWebhookQueueConsumer = (connector: ConnectorDefinition) =>
111
135
  const batch = yield* Queue.take(queue.queue);
112
136
  const depth = yield* Queue.size(queue.queue);
113
137
  yield* Metrics.setWebhookQueueDepth(connector.name, depth);
114
- yield* publishBatch({
138
+ yield* ingestBatch({
115
139
  connector: connector.name,
116
140
  resource: batch.resource,
117
141
  source: "webhook",
@@ -134,7 +158,10 @@ const makeWebhookServerLayer = (
134
158
  const metricsPath = options.metricsPath === undefined ? "/metrics" : options.metricsPath;
135
159
  const statusPath = options.statusPath === undefined ? "/status" : options.statusPath;
136
160
  const app = Layer.mergeAll(
137
- router(options.routes, { connectorName: connector.name }),
161
+ router(options.routes, {
162
+ connectorName: connector.name,
163
+ resourceNames: connector.resources.map((resource) => resource.name),
164
+ }),
138
165
  HttpRouter.add("GET", healthPath, Effect.succeed(HttpServerResponse.text("ok"))),
139
166
  ...(metricsPath === false
140
167
  ? []
@@ -150,14 +177,57 @@ const makeWebhookServerLayer = (
150
177
  const runIngestion = (
151
178
  connector: ConnectorDefinition,
152
179
  initialCutoff: Cursor.Value,
153
- ): Effect.Effect<void, ConnectorError, StateStore | Publisher> =>
154
- Effect.forEach(
155
- connector.resources,
156
- (resource) => runResourceSources(connector, resource, initialCutoff),
157
- {
158
- concurrency: "unbounded",
159
- },
160
- ).pipe(Effect.asVoid);
180
+ ): Effect.Effect<void, ConnectorError, StateStore | Ingestor> =>
181
+ Effect.gen(function* () {
182
+ const feed = connector.changes;
183
+ yield* Effect.all(
184
+ [
185
+ Effect.forEach(
186
+ connector.resources,
187
+ (resource) => runResourceSources(connector, resource, initialCutoff),
188
+ { concurrency: "unbounded" },
189
+ ),
190
+ feed ? runChangesFeed(connector, feed, initialCutoff) : Effect.void,
191
+ ],
192
+ { concurrency: "unbounded" },
193
+ );
194
+ });
195
+
196
+ const bySavedAt = Order.mapInput(
197
+ Order.String,
198
+ (state: NonNullable<ResourceState["changes"]>) => state.lastSuccessAt ?? "",
199
+ );
200
+
201
+ const validateFeed = (connector: ConnectorDefinition, feed: ChangesFeed) =>
202
+ Effect.forEach(feed.resources, (member, index) => {
203
+ if (feed.resources.findIndex((item) => item.name === member.name) !== index) {
204
+ return Effect.fail(
205
+ new ConnectorError({ message: `Resource ${member.name} is in the changes feed twice` }),
206
+ );
207
+ }
208
+ const resource = connector.resources.find((item) => item.name === member.name);
209
+ if (!resource) {
210
+ return Effect.fail(
211
+ new ConnectorError({
212
+ message: `Changes feed resource ${member.name} is not a resource of connector ${connector.name}`,
213
+ }),
214
+ );
215
+ }
216
+ if (resource.changes) {
217
+ return Effect.fail(
218
+ new ConnectorError({
219
+ message: `Resource ${member.name} has its own changes and is also in the changes feed`,
220
+ }),
221
+ );
222
+ }
223
+ return Effect.void;
224
+ }).pipe(
225
+ Effect.andThen(
226
+ feed.resources.length === 0
227
+ ? Effect.fail(new ConnectorError({ message: "The changes feed has no resources" }))
228
+ : Effect.void,
229
+ ),
230
+ );
161
231
 
162
232
  const initializeResourceState = (
163
233
  existing: ResourceState | undefined,
@@ -207,9 +277,9 @@ const runResourceSources = (
207
277
  return initializeRuntimeStatus(connector, resource, initialCutoff).pipe(
208
278
  Effect.andThen(Effect.all(runs, { concurrency: "unbounded" })),
209
279
  Effect.asVoid,
210
- Effect.catch(() =>
280
+ Effect.catch((error) =>
211
281
  Metrics.setSyncState({ connector: connector.name, resource: resource.name }, "error").pipe(
212
- Effect.andThen(Effect.logError("Connector resource initialization failed")),
282
+ Effect.andThen(Effect.logError("Connector resource initialization failed", error)),
213
283
  Effect.annotateLogs({
214
284
  [Attr.connectorName]: connector.name,
215
285
  resource: resource.name,
@@ -250,8 +320,8 @@ const isolateSourceFailure = <A, R>(
250
320
  ): Effect.Effect<A, never, R> =>
251
321
  // Only typed source errors are isolated. Defects and interruption still stop the runtime.
252
322
  effect.pipe(
253
- Effect.catch(() =>
254
- Effect.logError("Connector source parked").pipe(
323
+ Effect.catch((error) =>
324
+ Effect.logError("Connector source parked", error).pipe(
255
325
  Effect.annotateLogs({
256
326
  [Attr.connectorName]: connector.name,
257
327
  resource: resource.name,
@@ -270,8 +340,9 @@ const refreshRuntimeStatus = Effect.fnUntraced(function* (
270
340
  const state = yield* getInitializedState(resource.name, initialCutoff);
271
341
  yield* Metrics.setSyncState(
272
342
  { connector: connector.name, resource: resource.name },
273
- deriveSyncState(resource, state),
343
+ deriveSyncState(connector, resource, state),
274
344
  );
345
+
275
346
  return state;
276
347
  });
277
348
 
@@ -283,15 +354,16 @@ const initializeRuntimeStatus = Effect.fnUntraced(function* (
283
354
  const state = yield* getInitializedState(resource.name, initialCutoff);
284
355
  yield* Metrics.setSyncState(
285
356
  { connector: connector.name, resource: resource.name },
286
- deriveSyncState(resource, state),
357
+ deriveSyncState(connector, resource, state),
287
358
  );
359
+
288
360
  if (resource.backfill) {
289
361
  yield* Metrics.setLastSuccessTimestamp(
290
362
  { connector: connector.name, resource: resource.name, source: "backfill" },
291
363
  state.backfill?.lastSuccessAt,
292
364
  );
293
365
  }
294
- if (resource.changes) {
366
+ if (hasChanges(connector, resource)) {
295
367
  yield* Metrics.setLastSuccessTimestamp(
296
368
  { connector: connector.name, resource: resource.name, source: "changes" },
297
369
  state.changes?.lastSuccessAt,
@@ -328,15 +400,15 @@ const runBackfill = Effect.fnUntraced(function* (
328
400
  })
329
401
  .pipe(recordResourceError(connector, resource, "backfill", "fetch"));
330
402
 
331
- yield* publishBatch({
403
+ yield* ingestBatch({
332
404
  connector: connector.name,
333
405
  resource: resource.name,
334
406
  source: "backfill",
335
407
  batch: {
336
408
  cursor: page.nextPageCursor ?? backfill.cutoff,
337
- mutations: page.mutations,
409
+ rows: page.rows,
338
410
  },
339
- }).pipe(recordResourceError(connector, resource, "backfill", "publish"));
411
+ }).pipe(recordResourceError(connector, resource, "backfill", "ingest"));
340
412
 
341
413
  const lastSuccessAt = yield* DateTime.now.pipe(Effect.map(DateTime.formatIso));
342
414
  state = {
@@ -349,7 +421,7 @@ const runBackfill = Effect.fnUntraced(function* (
349
421
  },
350
422
  };
351
423
 
352
- // Checkpoint only after publish succeeds so a crash cannot skip an
424
+ // Checkpoint only after ingestion succeeds so a crash cannot skip an
353
425
  // unacknowledged page; replay therefore remains at-least-once.
354
426
  yield* Effect.withSpan(
355
427
  store
@@ -397,18 +469,18 @@ const runChanges = Effect.fnUntraced(function* (
397
469
  .fetch({ cursor })
398
470
  .pipe(recordResourceError(connector, resource, "changes", "fetch"));
399
471
 
400
- yield* publishBatch({
472
+ yield* ingestBatch({
401
473
  connector: connector.name,
402
474
  resource: resource.name,
403
475
  source: "changes",
404
476
  batch: {
405
477
  cursor: page.cursor,
406
- mutations: page.mutations,
478
+ rows: page.rows,
407
479
  },
408
- }).pipe(recordResourceError(connector, resource, "changes", "publish"));
480
+ }).pipe(recordResourceError(connector, resource, "changes", "ingest"));
409
481
 
410
482
  const lastSuccessAt = yield* DateTime.now.pipe(Effect.map(DateTime.formatIso));
411
- // Advance the durable cursor only after Wings accepts the page.
483
+ // Advance the durable cursor only after the engine acknowledges the page.
412
484
  yield* Effect.withSpan(
413
485
  store
414
486
  .setChangesState(resource.name, {
@@ -433,6 +505,114 @@ const runChanges = Effect.fnUntraced(function* (
433
505
  yield* refreshRuntimeStatus(connector, resource, initialCutoff);
434
506
  }
435
507
 
436
- yield* Effect.sleep(resource.changes.interval ?? "1 minute");
508
+ if (!page.hasMore) {
509
+ yield* Effect.sleep(resource.changes.interval ?? "1 minute");
510
+ }
437
511
  }
438
512
  });
513
+
514
+ // Every member resource stores the feed cursor. All rows are ingested before any
515
+ // cursor is written. If a crash stops the writes partway, the next run starts
516
+ // from the oldest cursor.
517
+ const runChangesFeed = (
518
+ connector: ConnectorDefinition,
519
+ feed: ChangesFeed,
520
+ initialCutoff: Cursor.Value,
521
+ ): Effect.Effect<void, never, StateStore | Ingestor> =>
522
+ Effect.gen(function* () {
523
+ const store = yield* StateStore;
524
+ const members = feed.resources;
525
+ const selected = new Set(members.map((resource) => resource.name));
526
+ // Any failure stops the whole feed, so it is an error for every resource in it.
527
+ const recordFeedError =
528
+ (operation: StateOperation) =>
529
+ <A, R>(effect: Effect.Effect<A, ConnectorError, R>) =>
530
+ effect.pipe(
531
+ Effect.tapError(() =>
532
+ Effect.forEach(
533
+ members,
534
+ (resource) =>
535
+ Metrics.setSyncState(
536
+ { connector: connector.name, resource: resource.name },
537
+ "error",
538
+ ).pipe(Effect.andThen(store.setResourceError(resource.name, "changes", operation))),
539
+ { discard: true },
540
+ ),
541
+ ),
542
+ );
543
+
544
+ while (true) {
545
+ const states = yield* Effect.forEach(members, (resource) =>
546
+ store.getResourceState(resource.name),
547
+ );
548
+ // A resource that was off has an older cursor. Resume from the oldest, so it
549
+ // gets the changes it missed. The others read some changes again.
550
+ const saved = states.flatMap((state) => (state?.changes ? [state.changes] : []));
551
+ const cursor = Arr.isReadonlyArrayNonEmpty(saved)
552
+ ? Arr.min(saved, bySavedAt).cursor
553
+ : initialCutoff;
554
+ const result = yield* feed.fetch({ cursor, selected }).pipe(recordFeedError("fetch"));
555
+
556
+ yield* Effect.forEach(
557
+ members,
558
+ (resource) =>
559
+ ingestBatch({
560
+ connector: connector.name,
561
+ resource: resource.name,
562
+ source: "changes",
563
+ batch: {
564
+ cursor: result.cursor,
565
+ rows: result.rows[resource.name] ?? [],
566
+ },
567
+ }),
568
+ { discard: true },
569
+ ).pipe(recordFeedError("ingest"));
570
+
571
+ const lastSuccessAt = yield* DateTime.now.pipe(Effect.map(DateTime.formatIso));
572
+ yield* Effect.forEach(
573
+ members,
574
+ (resource) =>
575
+ Effect.withSpan(
576
+ store
577
+ .setChangesState(resource.name, {
578
+ cursor: normalizeCursor(result.cursor),
579
+ lastSuccessAt,
580
+ })
581
+ .pipe(
582
+ Effect.andThen(
583
+ Metrics.setLastSuccessTimestamp(
584
+ { connector: connector.name, resource: resource.name, source: "changes" },
585
+ lastSuccessAt,
586
+ ),
587
+ ),
588
+ Effect.andThen(store.clearResourceError(resource.name, "changes")),
589
+ Effect.tapError((error) => annotateError("state_set", error)),
590
+ ),
591
+ SpanName.stateSet,
592
+ { attributes: { [Attr.stateKey]: resource.name } },
593
+ ),
594
+ { discard: true },
595
+ ).pipe(recordFeedError("checkpoint"));
596
+
597
+ yield* Effect.forEach(members, (resource, index) =>
598
+ states[index]?.lastError?.source === "changes"
599
+ ? refreshRuntimeStatus(connector, resource, initialCutoff)
600
+ : Effect.void,
601
+ );
602
+
603
+ if (!result.hasMore) {
604
+ yield* Effect.sleep(feed.interval ?? "1 minute");
605
+ }
606
+ }
607
+ }).pipe(
608
+ // Only typed errors park the feed. Defects and interruption still stop the runtime.
609
+ Effect.catch((error) =>
610
+ Effect.logError("Connector changes feed parked", error).pipe(
611
+ Effect.annotateLogs({
612
+ [Attr.connectorName]: connector.name,
613
+ resources: feed.resources.map((resource) => resource.name).join(","),
614
+ }),
615
+ Effect.andThen(Effect.never),
616
+ ),
617
+ ),
618
+ );
@@ -0,0 +1,141 @@
1
+ import type { RecordBatch, TypeMap } from "apache-arrow";
2
+ import type { TableIdentifier } from "iceberg-js";
3
+
4
+ import { CatalogManager } from "@useairfoil/api";
5
+ import { Config, Effect, Layer } from "effect";
6
+ import { FetchHttpClient } from "effect/http";
7
+
8
+ import type { TableSource } from "../core/types";
9
+
10
+ import { ConnectorError } from "../errors";
11
+ import { compileTable } from "../iceberg/table";
12
+ import * as RuntimeConfig from "../runtime-config";
13
+ import { makeRowEncoder, type RowEncoder } from "./arrow";
14
+ import { Ingestor, type IngestorService } from "./service";
15
+
16
+ /** One catalog and an Iceberg table for each resource that runs. */
17
+ export type ArrowFlightIngestorConfig<Source extends TableSource = TableSource> = {
18
+ readonly connector: Source;
19
+ readonly catalog: string;
20
+ readonly tables: RuntimeConfig.ResolvedTableBindings<Source>;
21
+ };
22
+
23
+ type IngestorEntry = {
24
+ readonly encode: RowEncoder;
25
+ readonly push: (batch: RecordBatch<TypeMap>) => Effect.Effect<void, ConnectorError>;
26
+ };
27
+
28
+ type PreparedTable = {
29
+ readonly resource: string;
30
+ readonly identifier: TableIdentifier;
31
+ readonly encode: RowEncoder;
32
+ };
33
+
34
+ const connectorError = (message: string, cause?: unknown) => new ConnectorError({ message, cause });
35
+
36
+ /** Compiles local schemas and opens one Arrow Flight stream per bound resource. */
37
+ export const layerArrowFlight = <const Source extends TableSource>(
38
+ config: ArrowFlightIngestorConfig<Source>,
39
+ ): Layer.Layer<Ingestor, ConnectorError, CatalogManager.CatalogManager> =>
40
+ Layer.effect(Ingestor)(
41
+ Effect.gen(function* () {
42
+ const manager = yield* CatalogManager.CatalogManager;
43
+ const bindings: Partial<Record<string, RuntimeConfig.TableBinding>> = config.tables;
44
+
45
+ const prepared: Array<PreparedTable> = [];
46
+ for (const resource of config.connector.resources) {
47
+ const binding = bindings[resource.name];
48
+ if (!binding) continue;
49
+
50
+ const { namespace, name } = binding;
51
+ const identifier = { namespace, name };
52
+ const { schema } = yield* compileTable(resource.rowSchema).pipe(
53
+ Effect.mapError((cause) =>
54
+ connectorError(`Invalid schema for ${resource.name}: ${cause.message}`, cause),
55
+ ),
56
+ );
57
+
58
+ const encode = yield* makeRowEncoder(schema, resource.version);
59
+ prepared.push({ resource: resource.name, identifier, encode });
60
+ }
61
+
62
+ const entries = new Map<string, IngestorEntry>();
63
+ for (const { resource, identifier, encode } of prepared) {
64
+ const ingestor = yield* manager
65
+ .ingestor({
66
+ catalog: config.catalog,
67
+ namespace: identifier.namespace,
68
+ table: identifier.name,
69
+ })
70
+ .pipe(
71
+ Effect.mapError((cause) =>
72
+ connectorError(`Failed to open ingestor for ${resource}`, cause),
73
+ ),
74
+ );
75
+
76
+ entries.set(resource, {
77
+ encode,
78
+ push: (batch) =>
79
+ ingestor
80
+ .push(batch)
81
+ .pipe(
82
+ Effect.mapError((cause) =>
83
+ connectorError(`Failed to ingest rows for ${resource}`, cause),
84
+ ),
85
+ ),
86
+ });
87
+ }
88
+
89
+ const service: IngestorService = {
90
+ resourceNames: prepared.map(({ resource }) => resource),
91
+ ingest: ({ resource, batch }) => {
92
+ const entry = entries.get(resource);
93
+
94
+ if (!entry) return Effect.fail(connectorError(`Unknown resource ${resource}`));
95
+ if (batch.rows.length === 0) return Effect.succeed({ rows: 0, bytes: 0 });
96
+
97
+ return entry
98
+ .encode(batch.rows)
99
+ .pipe(
100
+ Effect.flatMap((encoded) =>
101
+ entry
102
+ .push(encoded)
103
+ .pipe(Effect.as({ rows: batch.rows.length, bytes: encoded.data.byteLength })),
104
+ ),
105
+ );
106
+ },
107
+ };
108
+
109
+ return Ingestor.of(service);
110
+ }),
111
+ );
112
+
113
+ /** Configures Arrow Flight ingestion from the hosted URL, catalog and table bindings. */
114
+ export const layerArrowFlightConfig = (connector: TableSource) =>
115
+ Layer.unwrap(
116
+ Effect.gen(function* () {
117
+ const { catalog, uri, flightUri } = yield* Config.all({
118
+ catalog: RuntimeConfig.catalog,
119
+ uri: RuntimeConfig.engineUri,
120
+ flightUri: RuntimeConfig.flightUri,
121
+ }).pipe(
122
+ Effect.mapError((cause) => connectorError("Invalid Arrow Flight configuration", cause)),
123
+ );
124
+
125
+ if (uri.protocol !== "http:" && uri.protocol !== "https:") {
126
+ return yield* Effect.fail(connectorError("Invalid Arrow Flight URL"));
127
+ }
128
+ if (flightUri.protocol !== "http:" && flightUri.protocol !== "https:") {
129
+ return yield* Effect.fail(connectorError("Invalid Arrow Flight URL"));
130
+ }
131
+
132
+ const tables = yield* RuntimeConfig.loadTableBindings(connector).pipe(
133
+ Effect.mapError((cause) => connectorError(cause.message, cause)),
134
+ );
135
+ const manager = CatalogManager.layer({ engineUri: uri.href, flightUri: flightUri.href }).pipe(
136
+ Layer.provide(FetchHttpClient.layer),
137
+ );
138
+
139
+ return layerArrowFlight({ connector, catalog, tables }).pipe(Layer.provide(manager));
140
+ }),
141
+ );