@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
package/README.md CHANGED
@@ -1,497 +1,182 @@
1
- # @useairfoil/connector-kit
1
+ # Connector Kit
2
2
 
3
- Toolkit for building Airfoil producer connectors with Effect.
4
-
5
- `@useairfoil/connector-kit` gives connector authors the pieces needed to define, run, test, and publish resource-first producer connectors:
6
-
7
- - `Connector.define(...)` describes a connector.
8
- - `Resource.entity(...)` describes a Wings table-backed resource.
9
- - `Fetch.page(...)` and `Fetch.changes(...)` describe backfill and polling fetches.
10
- - `Resource.webhook(...)` lets a resource normalize provider webhook payloads into mutations.
11
- - `Webhook.route(...)` defines connector-level HTTP webhook routes.
12
- - `Ingestion.run(...)` runs resource ingestion and checkpoints after accepted publishes.
13
- - `Publisher.layerConsole` logs accepted batches locally.
14
- - `Publisher.layerWings(...)` publishes resource mutations into Wings tables.
15
- - `ConnectorApp.check(...)` validates selected resources before provisioning.
16
- - `ConnectorApp.start(...)` starts a complete Node HTTP connector process.
17
- - `Auth.makeTokenCache(...)` caches short-lived provider access tokens in memory.
18
-
19
- The package is intentionally Layer-oriented: connector code defines resources and routes, while entrypoints provide runtime dependencies such as publishers, state stores, telemetry, and API clients.
3
+ Use this package to build an Airfoil producer connector.
20
4
 
21
5
  ## Install
22
6
 
23
7
  ```bash
24
- pnpm add @useairfoil/connector-kit effect@^4.0.0-rc.108
25
- ```
26
-
27
- This repo currently uses the Effect v4 release candidate. In workspace packages, prefer the workspace catalog (`"effect": "catalog:"`) so all packages stay on the supported RC version.
28
-
29
- ## Package Shape
30
-
31
- The package root exposes the common connector authoring API:
32
-
33
- ```ts
34
- import {
35
- Auth,
36
- Connector,
37
- ConnectorApp,
38
- ConnectorError,
39
- Cursor,
40
- Fetch,
41
- Ingestion,
42
- Manifest,
43
- Metrics,
44
- Publisher,
45
- Resource,
46
- RuntimeConfig,
47
- StateStore,
48
- Status,
49
- Telemetry,
50
- Webhook,
51
- } from "@useairfoil/connector-kit";
52
- ```
53
-
54
- Subpath exports are available for runtime domains:
55
-
56
- - `@useairfoil/connector-kit/auth`
57
- - `@useairfoil/connector-kit/ingestion`
58
- - `@useairfoil/connector-kit/connector-app`
59
- - `@useairfoil/connector-kit/manifest`
60
- - `@useairfoil/connector-kit/metrics`
61
- - `@useairfoil/connector-kit/publisher`
62
- - `@useairfoil/connector-kit/runtime-config`
63
- - `@useairfoil/connector-kit/state-store`
64
- - `@useairfoil/connector-kit/status`
65
- - `@useairfoil/connector-kit/webhook`
66
- - `@useairfoil/connector-kit/errors`
67
-
68
- Core definition helpers and `Telemetry` are intentionally root-only.
69
-
70
- ## Provider Credentials
71
-
72
- `Auth.makeTokenCache(...)` wraps a provider token exchange with Effect's cache. Acquisition stays lazy, concurrent callers share one exchange, and failures are not cached. The default refresh point is halfway through the provider lifetime.
73
-
74
- ```ts
75
- import { Auth } from "@useairfoil/connector-kit";
76
- import { Duration, Effect, Redacted } from "effect";
77
-
78
- const tokenCache =
79
- yield *
80
- Auth.makeTokenCache({
81
- acquire: exchangeCredentials.pipe(
82
- Effect.map(({ accessToken, expiresInSeconds }) => ({
83
- value: Redacted.make(accessToken),
84
- expiresIn: Duration.seconds(expiresInSeconds),
85
- })),
86
- ),
87
- });
88
-
89
- const accessToken = yield * tokenCache.get;
90
- ```
91
-
92
- The cache is process-local and does not use `StateStore`. It does not add retries or background refresh. The first request after the refresh point performs the next exchange.
93
-
94
- ## Manifests
95
-
96
- Connector manifests are pure metadata for UI, Wings deployment, and validation. Put user-facing connector config in `src/manifest.ts` and keep platform-owned runtime config, such as ports, Wings host, table names, and OTEL flags, as normal Effect `Config` in entrypoints.
97
-
98
- ```ts
99
- import * as Manifest from "@useairfoil/connector-kit/manifest";
100
-
101
- export const ExampleConfigDef = Manifest.defineConfig({
102
- apiBaseUrl: Manifest.string({
103
- runtimeKey: "EXAMPLE_API_BASE_URL",
104
- required: false,
105
- default: "https://api.example.test",
106
- }),
107
- apiToken: Manifest.secret({ runtimeKey: "EXAMPLE_API_TOKEN" }),
108
- maxRetries: Manifest.number({
109
- runtimeKey: "EXAMPLE_MAX_RETRIES",
110
- default: 5,
111
- integer: true,
112
- minimum: 0,
113
- }),
114
- webhookSecret: Manifest.optional(Manifest.secret({ runtimeKey: "EXAMPLE_WEBHOOK_SECRET" })),
115
- });
116
-
117
- export type ExampleConfig = Manifest.ConfigValuesOf<typeof ExampleConfigDef>;
118
-
119
- export const manifest = Manifest.define({
120
- name: "producer-example",
121
- title: "Example",
122
- config: ExampleConfigDef.spec,
123
- resources: [{ name: "posts", capabilities: ["backfill", "webhook"] }],
124
- });
8
+ pnpm add @useairfoil/connector-kit effect@^4
125
9
  ```
126
10
 
127
- `ExampleConfigDef.config` is the runtime Effect config. `ExampleConfigDef.spec` is serializable manifest metadata. `required` defaults to `true`. Number fields may set `integer` and `minimum`; Effect Schema applies the same constraints to runtime and form values. Use `required: false` with a default when the form may omit a field but the runtime must always receive a concrete value. Use `Manifest.optional(...)` when the runtime result should be an `Option`. Secret fields cannot declare defaults, so optional secrets use `Manifest.optional(Manifest.secret(...))`.
128
-
129
- Use `Manifest.configSchema(manifest)` for form validation and `Schema.toStandardSchemaV1(...)` for standard-schema form resolvers. The schema accepts normal JSON values and browser form-shaped values: number fields may arrive as strings, boolean fields may arrive as booleans or `"true"`/`"false"`, optional fields may be omitted or submitted as `""`, and required strings reject `""`.
11
+ ## Usage
130
12
 
131
- Use `Manifest.decodeConfig(manifest, input)` as the canonical server-side decoder. It rejects undeclared fields, treats `""` on a declared optional field as not supplied, and then applies manifest defaults. Therefore clearing or omitting a defaulted optional control means “use the manifest default”; it does not disable that setting. Model disabling as an explicit select/boolean/nullable contract instead of overloading an empty string. An optional field without a default remains absent.
13
+ - `Connector`, `Resource`, `Fetch`, and `Cursor` define the data flow.
14
+ - `Manifest` defines user config.
15
+ - `ConnectorApp` checks config and runs the HTTP server.
16
+ - `Ingestor` writes batches to the console or Wings.
17
+ - `StateStore` keeps ingestion progress.
18
+ - `Webhook` defines webhook routes.
132
19
 
133
- Frontend/backend validation example:
20
+ Here is a small resource:
134
21
 
135
22
  ```ts
136
- import * as Manifest from "@useairfoil/connector-kit/manifest";
137
- import { manifest as polarManifest } from "@useairfoil/producer-polar/manifest";
138
- import { Schema } from "effect";
139
-
140
- const configSchema = Manifest.configSchema(polarManifest);
141
- const formSchema = Schema.toStandardSchemaV1(configSchema);
142
-
143
- const result = await formSchema["~standard"].validate({
144
- accessToken: "polar_oat_xxx",
145
- apiBaseUrl: "https://api.polar.sh/v1/",
146
- organizationId: "",
147
- });
148
-
149
- if ("issues" in result) {
150
- // Show validation issues in your form.
151
- } else {
152
- // Store by manifest field name: accessToken, apiBaseUrl, organizationId.
153
- // Secrets should be persisted server-side and not echoed back to the browser.
154
- const values = result.value;
155
- }
156
- ```
157
-
158
- Render fields from `manifest.config`: use `field.name` as the form/storage key, `field.description` for help text, `field.secret` for password inputs/redaction, `field.values` for selects, `field.default` for the documented fallback, `field.required` for required markers, and number `integer` and `minimum` values for input constraints. The UI may show a default as an initial value or clearly labelled placeholder, but clearing it still resolves to that default during canonical decoding. Decode submitted values with `Manifest.decodeConfig(...)`, then map the canonical logical object to its flat runtime document with `Manifest.toRuntimeDocument(...)`. Each `field.runtimeKey` is an Effect Config key, not an instruction to create one Pod environment variable.
159
-
160
- Hosted connectors receive the runtime document as a read-only JSON file. `AIRFOIL_CONFIG_PATH` selects the file, and `RuntimeConfig.layerHosted()` adds the file beneath Effect's existing environment provider. The file is required and must contain valid JSON; each connector's Effect Config validates the fields it reads. Local sandboxes use Effect's default environment provider. Platform-owned config such as Wings host, namespace, table names, PostgreSQL settings, ports, and OTEL settings stays outside the manifest and outside the connector JSON.
23
+ import { Connector, Cursor, Fetch, Iceberg, Resource } from "@useairfoil/connector-kit";
24
+ import { Effect, Schema } from "effect";
161
25
 
162
- Hosted HTTP runtimes use `AIRFOIL_HTTP_PORT` (default `8080`). Wings table routing is one atomic `AIRFOIL_TABLE_BINDINGS` JSON object keyed by connector resource name. `Publisher.layerWingsConfig(connector)` rejects missing or unknown resources and validates every binding before ingestion starts:
163
-
164
- ```env
165
- AIRFOIL_TABLE_BINDINGS={"posts":"namespaces/default/tables/posts"}
166
- ```
167
-
168
- An object binding may add a typed runtime partition, for example `{"name":"namespaces/default/tables/posts","partition":{"type":"string","value":"tenant-a"}}`. Signed and unsigned 64-bit partition values use decimal strings; byte partitions use base64.
169
-
170
- ### Pre-provision Checks
171
-
172
- The dashboard backend imports each connector package and runs `ConnectorApp.check(...)` after manifest decoding but before persisting secrets or creating database and Kubernetes resources. Validate submitted resource names against `manifest.resources` at the request boundary before calling the typed check API. Replace the backend process provider with the submitted runtime document so unrelated backend environment variables cannot satisfy connector configuration:
173
-
174
- ```ts
175
- import { ConnectorApp, Manifest } from "@useairfoil/connector-kit";
176
- import { PolarConnector, manifest } from "@useairfoil/producer-polar";
177
- import { ConfigProvider, Effect } from "effect";
178
- import { FetchHttpClient } from "effect/unstable/http";
179
-
180
- const values = yield * Manifest.decodeConfig(manifest, submittedConfig);
181
- const runtimeDocument = Manifest.toRuntimeDocument(manifest, values);
182
- const provider = ConfigProvider.fromUnknown(runtimeDocument, { preserveEmptyStrings: true });
183
-
184
- const result =
185
- yield *
186
- ConnectorApp.check(
187
- PolarConnector.PolarConnector,
188
- PolarConnector.layerConfig(PolarConnector.PolarConfigDef.config),
189
- { resources: ["customers", "orders"] },
190
- ).pipe(Effect.provide(ConfigProvider.layer(provider)), Effect.provide(FetchHttpClient.layer));
191
- ```
192
-
193
- Every resource declares a required, read-only `check` effect. Layer/configuration failures are returned for every selected resource; resource-specific failures are returned only under that resource name. Expected failures are values (`{ _tag: "error", message }`), while defects and interruption remain Effect failures. Provision only when all selected results are `{ _tag: "ok" }`. Checks must never publish, checkpoint, or mutate the upstream provider.
194
-
195
- `RuntimeConfig.PlatformRuntimeKey` is the canonical shared-key contract used by hosted config, StateStore, telemetry, Wings, and PostgreSQL bootstrap code. `Manifest.defineConfig` rejects only those exact shared values; it does not impose connector-specific naming, uniqueness, or metadata policy. Connector packages are first-party code, so their table, port, field, and resource naming remains the connector author's responsibility.
196
-
197
- ## Resource Model
198
-
199
- Resources emit mutations, not raw rows. Connector Kit currently supports `Resource.entity(...)` for Wings table-backed resources.
26
+ const PostSchema = Schema.Struct({
27
+ id: Schema.String.pipe(Iceberg.field(1)),
28
+ updatedAt: Schema.String.pipe(Iceberg.field(2)),
29
+ }).pipe(Iceberg.identifier("id"));
200
30
 
201
- ```ts
202
31
  const Posts = Resource.entity({
203
32
  name: "posts",
204
- schema: PostSchema,
205
- key: "id",
33
+ rowSchema: PostSchema,
206
34
  version: "updatedAt",
207
- check: api.fetchPosts({ page: 1, limit: 1 }).pipe(Effect.asVoid),
35
+
36
+ check: Effect.void,
208
37
  backfill: Fetch.page({
209
38
  pageCursor: Cursor.number(),
210
39
  cutoff: Cursor.isoDateTime(),
211
- fetch: ({ pageCursor }) =>
212
- api.fetchPosts({ page: typeof pageCursor === "number" ? pageCursor : 1 }).pipe(
213
- Effect.map((page) => ({
214
- mutations: page.items.map(Resource.upsert),
215
- nextPageCursor: page.hasMore ? page.nextPage : page.page,
216
- hasMore: page.hasMore,
217
- })),
218
- ),
219
- }),
220
- });
221
- ```
222
-
223
- `key` and `version` autocomplete from the decoded schema row type. Invalid field names are rejected at compile time.
224
-
225
- Mutations are created with:
226
-
227
- ```ts
228
- Resource.upsert(row);
229
-
230
- Resource.delete({
231
- key: "post_123",
232
- version: "2026-01-01T00:00:00.000Z",
233
- });
234
- ```
235
-
236
- ## Fetches
237
-
238
- `Fetch.page(...)` is for paginated backfill. It receives the stored page cursor and the resource cutoff.
239
-
240
- `Fetch.changes(...)` is for polling incremental changes. It receives the stored changes cursor and can be configured with an interval.
241
-
242
- Cursor helpers:
243
-
244
- - `Cursor.string()`
245
- - `Cursor.number()`
246
- - `Cursor.isoDateTime()`
247
- - `Cursor.nowIsoDateTime`
248
-
249
- Date cursor values are accepted at API boundaries and normalized to ISO strings before checkpoint writes.
250
-
251
- ## Webhooks
252
-
253
- Webhooks are connector-level routes with schema-validated payloads. A route dispatches to resource-owned webhook handlers by calling `to(resource, payload)`.
254
-
255
- ```ts
256
- const Posts = Resource.entity({
257
- name: "posts",
258
- schema: PostSchema,
259
- key: "id",
260
- version: "updatedAt",
261
- check: Effect.void,
262
- webhook: Resource.webhook({
263
- schema: PostEventSchema,
264
- handler: ({ payload }) => Effect.succeed([Resource.upsert(payload.data)]),
40
+ fetch: () =>
41
+ Effect.succeed({
42
+ rows: [],
43
+ nextPageCursor: 1,
44
+ hasMore: false,
45
+ }),
265
46
  }),
266
47
  });
267
48
 
268
- const route = Webhook.route({
269
- path: "/webhooks/example",
270
- ackMode: "after-publish",
271
- schema: ProviderWebhookSchema,
272
- handler: ({ request, rawBody, payload, to }) =>
273
- Effect.gen(function* () {
274
- yield* verifySignature({ request, rawBody });
275
- yield* to(Posts, payload);
276
- return HttpServerResponse.jsonUnsafe({ ok: true });
277
- }),
49
+ const connector = Connector.define({
50
+ name: "producer-example",
51
+ resources: [Posts],
278
52
  });
279
53
  ```
280
54
 
281
- Route behavior:
282
-
283
- - reads the raw body once
284
- - parses JSON once
285
- - validates the route payload schema
286
- - passes `request`, `rawBody`, typed `payload`, and `to(...)` to the handler
287
- - returns `400` for invalid body reads, invalid JSON, or invalid route payloads
288
- - returns `500` for unexpected handler/runtime/publisher failures
289
-
290
- Signature verification is connector-owned and should use `rawBody`.
291
-
292
- `ackMode: "after-publish"` publishes collected mutations before the response is completed. `ackMode: "after-enqueue"` enqueues collected mutations and lets the background webhook consumer publish them.
293
-
294
- ## Connector Definition
55
+ When one provider log covers several resources, such as an events API, give
56
+ the connector a single `changes` feed instead of `changes` on each resource. The
57
+ feed reads the log once per run and returns rows by resource name:
295
58
 
296
59
  ```ts
297
60
  const connector = Connector.define({
298
61
  name: "producer-example",
299
- title: "Producer Example",
300
- resources: [Posts],
301
- webhooks: [route],
62
+ resources: [Posts, Comments],
63
+ changes: Fetch.feed({
64
+ resources: [Posts, Comments],
65
+ cursor: Cursor.string(),
66
+ interval: "5 minutes",
67
+ fetch: ({ cursor }) =>
68
+ readEvents(cursor).pipe(
69
+ Effect.map((page) => ({
70
+ rows: { posts: page.posts, comments: page.comments },
71
+ cursor: page.next,
72
+ // Runs again without waiting for the interval.
73
+ hasMore: page.hasMore,
74
+ })),
75
+ ),
76
+ }),
302
77
  });
303
78
  ```
304
79
 
305
- ## Ingestion
306
-
307
- `Ingestion.run(...)` contains the runtime engine. `ConnectorApp.start(...)` is the usual entrypoint for runnable producer CLIs.
80
+ A resource's own `changes` can return `hasMore` the same way, to split a long
81
+ read across runs.
308
82
 
309
- Current runtime behavior:
83
+ Each resource in the feed stores the feed cursor, so status shows it per
84
+ resource. A resource in the feed cannot also define its own `changes`.
310
85
 
311
- - runs resource backfill, changes, and webhooks concurrently
312
- - initializes the same initial cutoff for backfill and changes
313
- - publishes through `Publisher.Publisher`
314
- - checkpoints only after an accepted publish ACK
315
- - does not checkpoint rejected publishes
316
- - allows empty accepted batches to advance state
317
- - stores `lastSuccessAt` with each durable checkpoint
318
- - persists resource state through `StateStore.StateStore`
319
- - parks a source when an expected failure reaches the ingestion engine, after any dependency retries
320
- - keeps other sources, webhooks, and HTTP routes running when one source is parked
86
+ `fetch` also gets `selected`, the resources that run. Skip the work for the
87
+ others.
321
88
 
322
- Parked sources resume after process restart or configuration redeployment. Defects and interruption are not isolated and still stop the runtime.
89
+ `Iceberg.identifier` names the row key. Put it last on the schema. Wings
90
+ upserts rows with the same key, and replaces the whole row.
323
91
 
324
- `Ingestion.run` depends only on the typed Airfoil `StateStore`; it does not expose the underlying Effect `KeyValueStore`. Provide `StateStore.layerMemory` for development/tests. Production entrypoints provide `StateStore.layerSql()`, which privately composes Effect's `KeyValueStore.layerSql` and scopes every key with the non-empty `AIRFOIL_CONNECTOR_INSTANCE_ID`. The platform ID is opaque and is not required to be a UUID.
92
+ So changes and webhooks return full rows. When a webhook leaves fields out,
93
+ read the record again. For a delete, send only the key, version, and
94
+ `_deleted: true`; the other columns become null. Send `_deleted: false` on other
95
+ rows only when the provider can restore records, so a restore comes back.
96
+ Otherwise leave it out.
325
97
 
326
- State keys use plain colon-delimited segments and are intentionally inspectable; they are not escaped or encoded. The trusted backend must issue connector instance IDs without `:`, and connector authors must keep resource names free of `:`. Connector Kit currently relies on that naming contract rather than adding runtime validation.
98
+ For local runs, use `StateStore.layerMemory` and `Ingestor.layerConsole`. For a
99
+ hosted run, use `RuntimeConfig.layerHosted()`, PostgreSQL state, and
100
+ `Ingestor.layerWingsConfig(connector)`.
327
101
 
328
- The shared SQL table defaults to `_airfoil_connectors_state`. Set `AIRFOIL_STATE_TABLE` only to override that default. `POSTGRES_CONNECTION_STRING` is consumed by the PostgreSQL client layer, not by `StateStore` itself:
102
+ ## Configuration
329
103
 
330
- ```ts
331
- import { PgClient } from "@effect/sql-pg";
332
- import { RuntimeConfig, StateStore } from "@useairfoil/connector-kit";
333
- import { Config, Layer, Schema } from "effect";
334
-
335
- const PostgresLive = PgClient.layerConfig({
336
- url: Config.schema(
337
- Schema.Redacted(Schema.NonEmptyString),
338
- RuntimeConfig.PlatformRuntimeKey.postgresConnectionString,
339
- ),
340
- maxConnections: Config.succeed(2),
341
- });
104
+ | Variable | Required | Default |
105
+ | ------------------------------- | -------- | --------------------------- |
106
+ | `AIRFOIL_CONFIG_PATH` | yes | none |
107
+ | `WINGS_URI` | yes | none |
108
+ | `AIRFOIL_CATALOG` | yes | none |
109
+ | `AIRFOIL_TABLE_BINDINGS` | yes | none |
110
+ | `AIRFOIL_CONNECTOR_INSTANCE_ID` | yes | none |
111
+ | `POSTGRES_CONNECTION_STRING` | yes | none |
112
+ | `AIRFOIL_HTTP_PORT` | no | `8080` |
113
+ | `AIRFOIL_STATE_TABLE` | no | `_airfoil_connectors_state` |
342
114
 
343
- const StateStoreLive = StateStore.layerSql().pipe(Layer.provide(PostgresLive));
115
+ ```env
116
+ AIRFOIL_TABLE_BINDINGS={"posts":{"namespace":["default"],"name":"posts","location":"s3://warehouse/default/posts"}}
344
117
  ```
345
118
 
346
- The platform injects the instance ID and Secret-backed PostgreSQL connection string used by `PgClient`, plus an optional table-name override. Connector business code receives `StateStore` but does not receive the connector instance ID, raw SQL client, or global clear/size operations.
119
+ Only the resources in `AIRFOIL_TABLE_BINDINGS` run. List at least one, and use
120
+ only the connector's resource names.
347
121
 
348
- ## Publisher
122
+ Use only lowercase letters, numbers, and underscores in table names. S3 Tables
123
+ catalogs reject other characters, such as `-`.
349
124
 
350
- `Publisher.Publisher` is the Effect service boundary for publishing resource mutation batches.
125
+ The HTTP server has `/health`, `/status`, and `/metrics` endpoints.
351
126
 
352
- `Publisher.layerConsole` accepts batches and logs them locally.
127
+ ## Demo data
353
128
 
354
- `Publisher.layerWings(...)` publishes mutations to Wings tables:
129
+ A connector with a demo has a `demo` command. It writes made-up rows to
130
+ the bound tables, then exits. It needs no provider credentials and no
131
+ PostgreSQL state.
355
132
 
356
- ```ts
357
- const publisherLayer = Publisher.layerWings({
358
- connector,
359
- tables: {
360
- posts: "namespaces/default/tables/posts",
361
- },
362
- });
363
- ```
364
-
365
- The `tables` map is keyed by resource name. Values can be table names or objects with a table name and optional partition value.
366
-
367
- ```ts
368
- import * as Wings from "@useairfoil/wings";
369
-
370
- Publisher.layerWings({
371
- connector,
372
- tables: {
373
- posts: {
374
- name: "namespaces/default/tables/posts",
375
- partitionValue: Wings.PartitionValue.string("account_123"),
376
- },
377
- },
378
- });
133
+ ```bash
134
+ producer-stripe demo --size 128MiB --seed 42
379
135
  ```
380
136
 
381
- Hosted entrypoints should use `Publisher.layerWingsConfig(connector)` instead. It loads the exact resource map from `AIRFOIL_TABLE_BINDINGS`, delegates partition decoding and table compatibility to Wings, and applies Connector Kit's manifest-resource validation before ingestion starts.
382
-
383
- The Wings publisher resolves table metadata during layer construction, validates resource key/version/partition compatibility, sends upserts with full rows, and sends deletes with key/version-only rows.
384
-
385
- ## Minimal Example
137
+ - `--size` is the uncompressed Arrow size to write, such as `512KiB`, `128MiB`,
138
+ or `1GiB`. The run can go over by one chunk of groups, about 1 MiB with the
139
+ current connectors.
140
+ - `--seed` picks the data. Dates depend on when the run starts, so two runs
141
+ with the same seed match only if they start at the same time and bind the
142
+ same tables.
143
+ - Setup tables, written once, such as a product catalog, don't grow. If only
144
+ those are bound, the run writes them once and stops below the size. Demos
145
+ with limited history, such as daily reports, also stop when they run out.
146
+ - It needs `AIRFOIL_CONFIG_PATH`, `WINGS_URI`, `AIRFOIL_CATALOG`, and
147
+ `AIRFOIL_TABLE_BINDINGS`. An empty `{}` config file is enough.
148
+
149
+ A demo lists its `setup` and `group` tables and builds their rows with
150
+ `Demo.define`. Build keys from the group index, so they are unique and other
151
+ rows can point to them. Required fields a row leaves out get placeholder values
152
+ from the row schema. Every row is checked against its row schema before it's
153
+ written.
154
+
155
+ ## Iceberg field IDs
156
+
157
+ Give each field a unique ID. Keep its ID if you rename it, and never reuse an ID
158
+ after removing a field. Use `Iceberg.field` for struct fields. Put list element
159
+ and map key/value IDs on their schemas with `.annotate({ fieldId })`.
386
160
 
387
161
  ```ts
388
- import { NodeRuntime } from "@effect/platform-node";
389
- import {
390
- Connector,
391
- ConnectorApp,
392
- Cursor,
393
- Fetch,
394
- Publisher,
395
- Resource,
396
- StateStore,
397
- Telemetry,
398
- } from "@useairfoil/connector-kit";
399
- import { Effect, Layer, Schema } from "effect";
400
- import { FetchHttpClient } from "effect/unstable/http";
401
-
402
- const PostSchema = Schema.Struct({
403
- id: Schema.Number,
404
- title: Schema.String,
405
- updatedAt: Schema.String,
406
- });
407
-
408
- type Post = Schema.Schema.Type<typeof PostSchema>;
409
-
410
- const Posts = Resource.entity({
411
- name: "posts",
412
- schema: PostSchema,
413
- key: "id",
414
- version: "updatedAt",
415
- check: Effect.void,
416
- backfill: Fetch.page({
417
- pageCursor: Cursor.number(),
418
- cutoff: Cursor.isoDateTime(),
419
- fetch: () =>
420
- Effect.succeed({
421
- mutations: [
422
- Resource.upsert({ id: 1, title: "Hello", updatedAt: "2026-01-01T00:00:00.000Z" }),
423
- ],
424
- nextPageCursor: 1,
425
- hasMore: false,
426
- }),
427
- }),
428
- });
429
-
430
- const connector = Connector.define({
431
- name: "producer-example",
432
- resources: [Posts],
433
- });
434
-
435
- const BootstrapLayer = FetchHttpClient.layer;
436
-
437
- const TelemetryLayer = Telemetry.layerOtlp().pipe(Layer.provide(BootstrapLayer));
438
- const RuntimeLayer = Layer.mergeAll(
439
- BootstrapLayer,
440
- StateStore.layerMemory,
441
- Publisher.layerConsole,
442
- TelemetryLayer,
443
- );
162
+ import { Iceberg } from "@useairfoil/connector-kit";
163
+ import { Schema } from "effect";
444
164
 
445
- Effect.scoped(ConnectorApp.start(connector, { port: 8080 })).pipe(
446
- Effect.provide(RuntimeLayer),
447
- NodeRuntime.runMain,
448
- );
165
+ const Row = Schema.Struct({
166
+ id: Schema.String.pipe(Iceberg.field(1, { description: "Row ID." })),
167
+ tags: Schema.Array(Schema.String.annotate({ fieldId: 101 })).pipe(
168
+ Iceberg.field(2, { description: "Tags for the row." }),
169
+ ),
170
+ })
171
+ .annotate({ description: "Example rows." })
172
+ .pipe(Iceberg.identifier("id"));
449
173
  ```
450
174
 
451
- ## Telemetry
452
-
453
- `Telemetry` contains connector-kit span names, span attributes, error annotation helpers, OTLP tracing layers, and OTLP metrics layers. HTTP connector runtimes expose shallow process health at `GET /health`, Prometheus metrics at `GET /metrics`, and durable sync state at `GET /status` by default. Provider, Wings, and PostgreSQL failures do not make `/health` fail.
454
-
455
- `/status` includes `lastSuccessAt` for each checkpointed backfill or changes source. Use `time() - airfoil_connector_last_success_timestamp` to monitor source freshness without interpreting provider cursors. OTLP receives the canonical `airfoil.connector.last_success.timestamp` name; the Prometheus endpoint replaces dots with underscores.
456
-
457
- Common entry points:
458
-
459
- - `Telemetry.layer(config, options?)`
460
- - `Telemetry.layerConfig(config, options?)`
461
- - `Telemetry.layerOtlpTracing(options?)` for OTLP traces
462
- - `Telemetry.layerOtlpMetrics(options?)` for OTLP metrics
463
- - `Telemetry.layerOtlp(options?)` for both traces and metrics
464
- - `Telemetry.layerMetricsConsoleDump(interval?)`
465
-
466
- Default redacted headers:
467
-
468
- - `authorization`
469
- - `cookie`
470
- - `set-cookie`
471
- - `x-api-key`
472
- - `/api[-_]?key/i`
473
- - `/secret/i`
474
- - `/signature/i`
475
- - `/token/i`
476
-
477
- Telemetry environment variables:
478
-
479
- | Variable | Read by | Description |
480
- | ----------------------------- | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
481
- | `OTEL_ENABLED` | Connector Kit | Enables OTLP export when `true`. Defaults to `false`. |
482
- | `OTEL_EXPORTER_OTLP_ENDPOINT` | Connector Kit | OTLP base URL, for example `http://localhost:4318`. Connector Kit appends `/v1/traces` and `/v1/metrics` as needed. Required when `OTEL_ENABLED=true`. |
483
- | `OTEL_EXPORTER_OTLP_HEADERS` | Connector Kit | Optional comma-separated headers, for example `Authorization=Bearer <token>,X-Axiom-Dataset=<dataset>`. |
484
- | `OTEL_SERVICE_NAME` | Effect | Service name resource attribute, for example `producer-shopify`. |
485
- | `OTEL_SERVICE_VERSION` | Effect | Optional service version resource attribute. |
486
- | `OTEL_RESOURCE_ATTRIBUTES` | Effect | Operator-owned identity, for example `service.instance.id=instance-1,airfoil.connector.revision=4,airfoil.team.id=team-acme`. |
487
-
488
- ## Testing
489
-
490
- For tests, the most common setup is:
491
-
492
- - `StateStore.layerMemory` for state
493
- - a small in-memory `Publisher.Publisher` test layer
494
- - `ConnectorApp.check(...)` with an in-memory `ConfigProvider` for pre-provision validation
495
- - `Ingestion.run(...)` or `ConnectorApp.start(...)` inside `Effect.scoped`
175
+ Hosted connectors create missing tables from these bindings. To create one
176
+ manually, use the connector's `create-table <namespace.table> --catalog <id>`
177
+ command. Use `--resource <name>` if the table name differs from the resource.
496
178
 
497
- Keep HTTP recording concerns outside connector logic by providing a VCR-backed `HttpClient` Layer.
179
+ Both paths use `commitTable` and check the schema after creation. The catalog's
180
+ `createTable` can change field IDs. Set `location` in the binding, or pass
181
+ `--location <uri>` to the command, if the catalog cannot choose a location or
182
+ you want to choose it yourself.
@@ -0,0 +1,39 @@
1
+ import { Schema } from "effect";
2
+ //#region src/iceberg/annotations.d.ts
3
+ declare module "effect/Schema" {
4
+ namespace Annotations {
5
+ interface Annotations {
6
+ readonly fieldId?: number | undefined;
7
+ readonly icebergIdentifierFields?: ReadonlyArray<string> | undefined;
8
+ }
9
+ }
10
+ }
11
+ /** Sets a field's Iceberg ID, and any other annotations. */
12
+ declare const field: <A = unknown>(fieldId: number, annotations?: Schema.Annotations.Key<A>) => <S extends Schema.Schema<A>>(schema: S) => S["Rebuild"];
13
+ /** Adds an offset to a base ID for reusable schemas. */
14
+ declare const ids: (baseId: number) => (offset: number) => number;
15
+ type KnownField<S extends Schema.Top> = keyof S["Type"] & string;
16
+ /** A row field that cannot be null or undefined. */
17
+ type RequiredField<S extends Schema.Top> = [KnownField<S>] extends [never] ? string : { readonly [Field in KnownField<S>]-?: undefined extends S["Type"][Field] ? never : null extends S["Type"][Field] ? never : Field; }[KnownField<S>];
18
+ /** A row schema whose identifier fields are `Key`. Only the type has it. */
19
+ interface Identified<Key extends string> {
20
+ readonly "~iceberg.identifier": Key;
21
+ }
22
+ /**
23
+ * Marks the fields that identify a row. Wings upserts rows by them.
24
+ *
25
+ * Put it last. A later `.annotate()` keeps the runtime annotation, but the type
26
+ * loses the key, so `Resource.entity` stops compiling.
27
+ *
28
+ * @example
29
+ * ```ts
30
+ * const Post = Schema.Struct({
31
+ * id: Schema.String.pipe(Iceberg.field(1)),
32
+ * updatedAt: Schema.String.pipe(Iceberg.field(2)),
33
+ * }).pipe(Iceberg.identifier("id"));
34
+ * ```
35
+ */
36
+ declare const identifier: <S extends Schema.Top, const Key extends RequiredField<S>>(...keys: readonly [Key, ...ReadonlyArray<Key>]) => (schema: S) => S["Rebuild"] & Identified<Key>;
37
+ //#endregion
38
+ export { ids as a, identifier as i, RequiredField as n, field as r, Identified as t };
39
+ //# sourceMappingURL=annotations-BE74YmAO.d.mts.map