@useairfoil/connector-kit 0.6.0 → 0.6.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 (179) hide show
  1. package/METRICS.md +16 -17
  2. package/README.md +127 -442
  3. package/dist/annotations-BE74YmAO.d.mts +39 -0
  4. package/dist/auth/index.d.mts +2 -0
  5. package/dist/{auth-DvU_CZ7C.js → auth/index.mjs} +6 -8
  6. package/dist/auth/index.mjs.map +1 -0
  7. package/dist/connector-app/index.d.mts +2 -0
  8. package/dist/connector-app/index.mjs +2 -0
  9. package/dist/connector-app-C-L4QqG6.mjs +123 -0
  10. package/dist/connector-app-C-L4QqG6.mjs.map +1 -0
  11. package/dist/{constants-D113Ut1x.js → constants-D-GQzpHZ.mjs} +4 -5
  12. package/dist/constants-D-GQzpHZ.mjs.map +1 -0
  13. package/dist/demo/index.d.mts +2 -0
  14. package/dist/demo/index.mjs +2 -0
  15. package/dist/demo-B30Vlkk2.mjs +191 -0
  16. package/dist/demo-B30Vlkk2.mjs.map +1 -0
  17. package/dist/errors/index.d.mts +2 -0
  18. package/dist/errors/index.mjs +2 -0
  19. package/dist/{errors-DHWylqRZ.js → errors-pT0YVJU5.mjs} +2 -3
  20. package/dist/errors-pT0YVJU5.mjs.map +1 -0
  21. package/dist/iceberg/index.d.mts +3 -0
  22. package/dist/iceberg/index.mjs +3 -0
  23. package/dist/iceberg-D6L8I6vA.mjs +39 -0
  24. package/dist/iceberg-D6L8I6vA.mjs.map +1 -0
  25. package/dist/{index-VpHaZXZw.d.ts → index-BUHSGm9Q.d.mts} +11 -13
  26. package/dist/{index-DXa26Ry5.d.ts → index-BX8G7d41.d.mts} +1 -2
  27. package/dist/{index-bfjH7Yb0.d.ts → index-BfXpgxk9.d.mts} +9 -10
  28. package/dist/{index-BKw7jA2a.d.ts → index-CRVAwJ5-.d.mts} +10 -8
  29. package/dist/index-CUPP2A_m.d.mts +27 -0
  30. package/dist/{index-DYpEMJf-.d.ts → index-CgvFomO6.d.mts} +6 -7
  31. package/dist/index-DJhDp4Xc.d.mts +58 -0
  32. package/dist/index-Dk7urvpF.d.mts +76 -0
  33. package/dist/{index-DKoRwyFn.d.ts → index-DkVNO6ks.d.mts} +2 -5
  34. package/dist/{index-C5Ma7V7t.d.ts → index-NnCutIqM.d.mts} +62 -37
  35. package/dist/{index-CR9HJDdr.d.ts → index-eFuoMZZ3.d.mts} +18 -20
  36. package/dist/{index-ZSuiZX5H.d.ts → index-ygpml2kC.d.mts} +19 -31
  37. package/dist/index.d.mts +16 -0
  38. package/dist/index.mjs +45 -0
  39. package/dist/index.mjs.map +1 -0
  40. package/dist/ingestion/index.d.mts +2 -0
  41. package/dist/ingestion/index.mjs +2 -0
  42. package/dist/ingestion-q0WwqUSt.mjs +275 -0
  43. package/dist/ingestion-q0WwqUSt.mjs.map +1 -0
  44. package/dist/ingestor/index.d.mts +3 -0
  45. package/dist/ingestor/index.mjs +4 -0
  46. package/dist/ingestor-D4L-HPkg.mjs +26 -0
  47. package/dist/ingestor-D4L-HPkg.mjs.map +1 -0
  48. package/dist/instrumented-Bhp7fyvh.mjs +29 -0
  49. package/dist/instrumented-Bhp7fyvh.mjs.map +1 -0
  50. package/dist/manifest/index.d.mts +2 -0
  51. package/dist/manifest/{index.js → index.mjs} +2 -4
  52. package/dist/{manifest-P7kBnch9.js → manifest-BGKxmKLy.mjs} +14 -19
  53. package/dist/manifest-BGKxmKLy.mjs.map +1 -0
  54. package/dist/{telemetry-j_nxwYfh.js → metrics-BaEX__MN.mjs} +170 -19
  55. package/dist/metrics-BaEX__MN.mjs.map +1 -0
  56. package/dist/{metrics-C4V1pm6v.d.ts → metrics-CVXFL7YT.d.mts} +18 -13
  57. package/dist/metrics.d.mts +2 -0
  58. package/dist/metrics.mjs +2 -0
  59. package/dist/{chunk-Bp6m_JJh.js → rolldown-runtime-D7D4PA-g.mjs} +4 -4
  60. package/dist/runtime-config/index.d.mts +2 -0
  61. package/dist/runtime-config/index.mjs +3 -0
  62. package/dist/runtime-config-Oa55gAew.mjs +97 -0
  63. package/dist/runtime-config-Oa55gAew.mjs.map +1 -0
  64. package/dist/server-CMnxz3h0.mjs +144 -0
  65. package/dist/server-CMnxz3h0.mjs.map +1 -0
  66. package/dist/service-DLkhuxYl.d.mts +27 -0
  67. package/dist/service-jWxxfEht.mjs +7 -0
  68. package/dist/service-jWxxfEht.mjs.map +1 -0
  69. package/dist/state-store/index.d.mts +2 -0
  70. package/dist/state-store/index.mjs +2 -0
  71. package/dist/{state-store-BdYulQOY.js → state-store-uFvHY7Ax.mjs} +15 -22
  72. package/dist/state-store-uFvHY7Ax.mjs.map +1 -0
  73. package/dist/status/{index.d.ts → index.d.mts} +1 -4
  74. package/dist/status/{index.js → index.mjs} +2 -6
  75. package/dist/{status-BTTd7AUR.js → status-CKb9IhQ9.mjs} +14 -16
  76. package/dist/status-CKb9IhQ9.mjs.map +1 -0
  77. package/dist/table-V0tgUC5i.mjs +370 -0
  78. package/dist/table-V0tgUC5i.mjs.map +1 -0
  79. package/dist/types-xZtSmrGA.d.mts +220 -0
  80. package/dist/webhook/index.d.mts +3 -0
  81. package/dist/webhook/index.mjs +12 -0
  82. package/dist/webhook/index.mjs.map +1 -0
  83. package/dist/wings-I56hGtNv.mjs +325 -0
  84. package/dist/wings-I56hGtNv.mjs.map +1 -0
  85. package/package.json +64 -51
  86. package/src/catalog/table.ts +201 -0
  87. package/src/connector-app/create-table.ts +47 -0
  88. package/src/connector-app/demo.ts +42 -0
  89. package/src/connector-app/index.ts +2 -0
  90. package/src/connector-app/start.ts +1 -1
  91. package/src/core/builder.ts +15 -23
  92. package/src/core/index.ts +11 -1
  93. package/src/core/types.ts +114 -39
  94. package/src/demo/definition.ts +68 -0
  95. package/src/demo/generate.ts +167 -0
  96. package/src/demo/index.ts +3 -0
  97. package/src/demo/run.ts +142 -0
  98. package/src/iceberg/annotations.ts +62 -0
  99. package/src/iceberg/error.ts +9 -0
  100. package/src/iceberg/index.ts +3 -0
  101. package/src/iceberg/table.ts +511 -0
  102. package/src/index.ts +3 -1
  103. package/src/ingestion/engine.ts +240 -60
  104. package/src/ingestor/arrow.ts +248 -0
  105. package/src/ingestor/console.ts +13 -0
  106. package/src/ingestor/index.ts +9 -0
  107. package/src/ingestor/instrumented.ts +61 -0
  108. package/src/ingestor/service.ts +30 -0
  109. package/src/ingestor/wings.ts +143 -0
  110. package/src/manifest/config.ts +3 -3
  111. package/src/metrics.ts +66 -20
  112. package/src/runtime-config/config.ts +6 -8
  113. package/src/runtime-config/constants.ts +2 -2
  114. package/src/runtime-config/index.ts +2 -7
  115. package/src/runtime-config/layer.ts +1 -1
  116. package/src/runtime-config/table-bindings.ts +48 -136
  117. package/src/state-store/layer-key-value-store.ts +8 -3
  118. package/src/state-store/layer-memory.ts +1 -1
  119. package/src/state-store/layer-sql.ts +4 -4
  120. package/src/state-store/schema.ts +2 -2
  121. package/src/state-store/state.ts +14 -2
  122. package/src/status/route.ts +2 -2
  123. package/src/telemetry.ts +10 -10
  124. package/src/webhook/server.ts +39 -40
  125. package/dist/auth/index.d.ts +0 -2
  126. package/dist/auth/index.js +0 -3
  127. package/dist/auth-DvU_CZ7C.js.map +0 -1
  128. package/dist/connector-app/index.d.ts +0 -14
  129. package/dist/connector-app/index.js +0 -12
  130. package/dist/connector-app-BljHd0Pe.js +0 -64
  131. package/dist/connector-app-BljHd0Pe.js.map +0 -1
  132. package/dist/constants-D113Ut1x.js.map +0 -1
  133. package/dist/errors/index.d.ts +0 -2
  134. package/dist/errors/index.js +0 -3
  135. package/dist/errors-DHWylqRZ.js.map +0 -1
  136. package/dist/index-BA8ASHKr.d.ts +0 -31
  137. package/dist/index.d.ts +0 -14
  138. package/dist/index.js +0 -60
  139. package/dist/index.js.map +0 -1
  140. package/dist/ingestion/index.d.ts +0 -6
  141. package/dist/ingestion/index.js +0 -11
  142. package/dist/ingestion-QBGKrFr9.js +0 -200
  143. package/dist/ingestion-QBGKrFr9.js.map +0 -1
  144. package/dist/manifest/index.d.ts +0 -2
  145. package/dist/manifest-P7kBnch9.js.map +0 -1
  146. package/dist/metrics-inkCAxKq.js +0 -136
  147. package/dist/metrics-inkCAxKq.js.map +0 -1
  148. package/dist/metrics.d.ts +0 -4
  149. package/dist/metrics.js +0 -5
  150. package/dist/publisher/index.d.ts +0 -6
  151. package/dist/publisher/index.js +0 -8
  152. package/dist/publisher-BdWJOfer.js +0 -138
  153. package/dist/publisher-BdWJOfer.js.map +0 -1
  154. package/dist/runtime-config/index.d.ts +0 -4
  155. package/dist/runtime-config/index.js +0 -5
  156. package/dist/runtime-config-IbyCVpei.js +0 -60
  157. package/dist/runtime-config-IbyCVpei.js.map +0 -1
  158. package/dist/server-D-eLX3yN.js +0 -183
  159. package/dist/server-D-eLX3yN.js.map +0 -1
  160. package/dist/service-CUjGhe-7.d.ts +0 -32
  161. package/dist/service-DiP-XETo.js +0 -8
  162. package/dist/service-DiP-XETo.js.map +0 -1
  163. package/dist/state-store/index.d.ts +0 -3
  164. package/dist/state-store/index.js +0 -5
  165. package/dist/state-store-BdYulQOY.js.map +0 -1
  166. package/dist/status-BTTd7AUR.js.map +0 -1
  167. package/dist/table-bindings-Dp1DhTeS.js +0 -106
  168. package/dist/table-bindings-Dp1DhTeS.js.map +0 -1
  169. package/dist/telemetry-j_nxwYfh.js.map +0 -1
  170. package/dist/types-jVD5Ys43.d.ts +0 -174
  171. package/dist/webhook/index.d.ts +0 -7
  172. package/dist/webhook/index.js +0 -9
  173. package/dist/webhook-MhFcLW-o.js +0 -13
  174. package/dist/webhook-MhFcLW-o.js.map +0 -1
  175. package/src/publisher/console.ts +0 -18
  176. package/src/publisher/index.ts +0 -7
  177. package/src/publisher/instrumented.ts +0 -75
  178. package/src/publisher/service.ts +0 -37
  179. package/src/publisher/wings.ts +0 -204
@@ -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 Wings 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,248 @@
1
+ import {
2
+ Bool,
3
+ DateDay,
4
+ Decimal,
5
+ Field,
6
+ FixedSizeBinary,
7
+ Float32,
8
+ Float64,
9
+ Int32,
10
+ Int64,
11
+ LargeBinary,
12
+ List,
13
+ Map_,
14
+ RecordBatch,
15
+ Schema as ArrowSchema,
16
+ Struct,
17
+ TimeMicrosecond,
18
+ TimestampMicrosecond,
19
+ Utf8,
20
+ makeBuilder,
21
+ type DataType,
22
+ type TypeMap,
23
+ } from "apache-arrow";
24
+ import { Effect, Schema } from "effect";
25
+ import {
26
+ parseDecimalType,
27
+ parseFixedType,
28
+ type IcebergType,
29
+ type PrimitiveType,
30
+ type TableSchema,
31
+ } from "iceberg-js";
32
+
33
+ import { ConnectorError } from "../errors";
34
+
35
+ // Nothing converts an Iceberg schema to Arrow for us, so we do it here.
36
+ // Each type also gets a schema for its values, because Arrow does not reject a bad
37
+ // value, it converts it: "1" becomes 1, and 1.5 becomes 1.
38
+
39
+ type RowSchema = Schema.Codec<unknown, unknown>;
40
+
41
+ /** One Iceberg type: the Arrow type, and the values it accepts. */
42
+ type Compiled = {
43
+ readonly type: DataType;
44
+ readonly row: RowSchema;
45
+ };
46
+
47
+ // Arrow pads or cuts binary values to fit.
48
+ const bytes = (length: number) =>
49
+ Schema.Uint8Array.check(
50
+ Schema.makeFilter(
51
+ (value: Uint8Array) => value.byteLength === length || `expected ${length} bytes`,
52
+ ),
53
+ );
54
+
55
+ // Arrow wraps big integers instead of failing, so check the range.
56
+ const int32 = Schema.Number.check(
57
+ Schema.isInt(),
58
+ Schema.isBetween({ minimum: -2_147_483_648, maximum: 2_147_483_647 }),
59
+ );
60
+
61
+ const int64 = Schema.BigInt.check(
62
+ Schema.isBetweenBigInt({ minimum: -(2n ** 63n), maximum: 2n ** 63n - 1n }),
63
+ );
64
+
65
+ // Arrow turns a number outside the 32-bit range into Infinity.
66
+ const float32 = Schema.Finite.check(
67
+ Schema.makeFilter(
68
+ (value: number) => Number.isFinite(Math.fround(value)) || "expected a 32-bit number",
69
+ ),
70
+ );
71
+
72
+ const microsecondsInDay = Schema.BigInt.check(
73
+ Schema.isBetweenBigInt({ minimum: 0n, maximum: 86_399_999_999n }),
74
+ );
75
+
76
+ const primitives: Record<string, Compiled> = {
77
+ boolean: { type: new Bool(), row: Schema.Boolean },
78
+ int: { type: new Int32(), row: int32 },
79
+ long: { type: new Int64(), row: int64 },
80
+ float: { type: new Float32(), row: float32 },
81
+ double: { type: new Float64(), row: Schema.Finite },
82
+ string: { type: new Utf8(), row: Schema.String },
83
+ uuid: { type: new FixedSizeBinary(16), row: bytes(16) },
84
+ binary: { type: new LargeBinary(), row: Schema.Uint8Array },
85
+ date: { type: new DateDay(), row: Schema.Date },
86
+ time: { type: new TimeMicrosecond(), row: microsecondsInDay },
87
+ timestamp: { type: new TimestampMicrosecond(), row: Schema.Date },
88
+ timestamptz: { type: new TimestampMicrosecond("UTC"), row: Schema.Date },
89
+ };
90
+
91
+ const primitive = (type: PrimitiveType): Compiled => {
92
+ const known = primitives[type];
93
+
94
+ if (known) return known;
95
+
96
+ // Nothing checks the catalog metadata, and Arrow takes any precision.
97
+ // Better to fail here than at write time.
98
+ const decimal = parseDecimalType(type);
99
+
100
+ if (decimal) {
101
+ if (decimal.precision < 1 || decimal.precision > 38 || decimal.scale > decimal.precision) {
102
+ throw new Error(`Invalid Iceberg decimal type ${type}`);
103
+ }
104
+
105
+ return {
106
+ type: new Decimal(decimal.scale, decimal.precision),
107
+ row: Schema.instanceOf(Uint32Array).check(
108
+ Schema.makeFilter((value: Uint32Array) => value.length === 4 || "expected four words"),
109
+ ),
110
+ };
111
+ }
112
+
113
+ const fixed = parseFixedType(type);
114
+
115
+ if (fixed) {
116
+ if (fixed.length < 1) throw new Error(`Invalid Iceberg fixed type ${type}`);
117
+
118
+ return { type: new FixedSizeBinary(fixed.length), row: bytes(fixed.length) };
119
+ }
120
+
121
+ throw new Error(`Unsupported Iceberg type ${type}`);
122
+ };
123
+
124
+ /** Absent and null both become null in Arrow. */
125
+ const optional = (row: RowSchema) => Schema.optional(Schema.NullOr(row));
126
+
127
+ const fieldMetadata = (id: number): Map<string, string> =>
128
+ new Map([["PARQUET:field_id", String(id)]]);
129
+
130
+ /** Same as `primitive`, plus struct, list and map. */
131
+ const compileType = (type: IcebergType): Compiled => {
132
+ if (typeof type === "string") return primitive(type);
133
+
134
+ if (type.type === "struct") {
135
+ const fields = type.fields.map((field) => ({ ...field, compiled: compileType(field.type) }));
136
+
137
+ return {
138
+ type: new Struct(
139
+ fields.map(
140
+ (field) =>
141
+ new Field(field.name, field.compiled.type, !field.required, fieldMetadata(field.id)),
142
+ ),
143
+ ),
144
+ row: Schema.Struct(
145
+ Object.fromEntries(
146
+ fields.map((field) => [
147
+ field.name,
148
+ field.required ? field.compiled.row : optional(field.compiled.row),
149
+ ]),
150
+ ),
151
+ ),
152
+ };
153
+ }
154
+
155
+ if (type.type === "list") {
156
+ const element = compileType(type.element);
157
+ const required = type["element-required"];
158
+
159
+ return {
160
+ type: new List(
161
+ new Field("element", element.type, !required, fieldMetadata(type["element-id"])),
162
+ ),
163
+ row: Schema.Array(required ? element.row : Schema.NullishOr(element.row)),
164
+ };
165
+ }
166
+
167
+ const key = compileType(type.key);
168
+ const value = compileType(type.value);
169
+ const required = type["value-required"];
170
+ const entries = new Struct([
171
+ new Field("key", key.type, false, fieldMetadata(type["key-id"])),
172
+ new Field("value", value.type, !required, fieldMetadata(type["value-id"])),
173
+ ]);
174
+
175
+ return {
176
+ type: new Map_(new Field("entries", entries, false)),
177
+ row: Schema.ReadonlyMap(key.row, required ? value.row : Schema.NullishOr(value.row)),
178
+ };
179
+ };
180
+
181
+ export type RowEncoder = (
182
+ rows: ReadonlyArray<object>,
183
+ ) => Effect.Effect<RecordBatch<TypeMap>, ConnectorError>;
184
+
185
+ export const makeRowEncoder = (
186
+ tableSchema: TableSchema,
187
+ version: string,
188
+ ): Effect.Effect<RowEncoder, ConnectorError> =>
189
+ Effect.try({
190
+ try: () => {
191
+ const fields = tableSchema.fields.map((field) => ({
192
+ id: field.id,
193
+ name: field.name,
194
+ ...compileType(field.type),
195
+ }));
196
+ const fieldNames = new Set(fields.map((field) => field.name));
197
+ const identifierIds = new Set(tableSchema["identifier-field-ids"]);
198
+ const keys = tableSchema.fields
199
+ .filter((field) => identifierIds.has(field.id))
200
+ .map((field) => field.name);
201
+ // Other columns may be missing: a delete only sends the key and version.
202
+ const required = new Set([...keys, version]);
203
+
204
+ if (!fieldNames.has(version)) throw new Error(`Missing version column ${version}`);
205
+
206
+ const struct = new Struct(
207
+ fields.map((field) => new Field(field.name, field.type, true, fieldMetadata(field.id))),
208
+ );
209
+ const schema = new ArrowSchema(struct.children);
210
+
211
+ const decodeRow = Schema.decodeUnknownSync(
212
+ Schema.Struct(
213
+ Object.fromEntries(
214
+ fields.map((field) => [
215
+ field.name,
216
+ required.has(field.name) ? field.row : optional(field.row),
217
+ ]),
218
+ ),
219
+ ),
220
+ { onExcessProperty: "error" },
221
+ );
222
+
223
+ // Schema says which field is wrong. This adds the row number.
224
+ const decodeAt = (row: object, index: number) => {
225
+ try {
226
+ return decodeRow(row) as Record<string, unknown>;
227
+ } catch (cause) {
228
+ const detail = cause instanceof Error ? cause.message : String(cause);
229
+ throw new Error(`row ${index}: ${detail}`, { cause });
230
+ }
231
+ };
232
+
233
+ return (rows: ReadonlyArray<object>) =>
234
+ Effect.try({
235
+ try: () => {
236
+ const builder = makeBuilder({ type: struct, nullValues: [null, undefined] });
237
+
238
+ for (const [index, row] of rows.entries()) {
239
+ builder.append(decodeAt(row, index) as Struct<TypeMap>["TValue"]);
240
+ }
241
+
242
+ return new RecordBatch(schema, builder.finish().flush());
243
+ },
244
+ catch: (cause) => new ConnectorError({ message: "Failed to encode rows", cause }),
245
+ });
246
+ },
247
+ catch: (cause) => new ConnectorError({ message: "Failed to convert Iceberg schema", cause }),
248
+ });
@@ -0,0 +1,13 @@
1
+ import { Effect, Layer } from "effect";
2
+
3
+ import { Ingestor } from "./service";
4
+
5
+ /** Logs batches without sending them to Wings. */
6
+ export const layerConsole = Layer.succeed(Ingestor)({
7
+ ingest: ({ resource, source, batch }) =>
8
+ Effect.logInfo(`[ingestor] -> Source: ${source} | Resource: ${resource}`).pipe(
9
+ Effect.annotateLogs({ rows: batch.rows.length, cursor: batch.cursor, source }),
10
+ // Nothing is encoded, so there are no bytes to count.
11
+ Effect.as({ rows: batch.rows.length, bytes: 0 }),
12
+ ),
13
+ });