@useairfoil/connector-kit 0.6.1 → 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 (102) hide show
  1. package/LICENSE.txt +201 -0
  2. package/METRICS.md +27 -30
  3. package/README.md +33 -51
  4. package/dist/{annotations-BE74YmAO.d.mts → annotations-vzSzXtr-.d.mts} +5 -10
  5. package/dist/{wings-I56hGtNv.mjs → arrow-flight-DDQ0ktx9.mjs} +25 -99
  6. package/dist/arrow-flight-DDQ0ktx9.mjs.map +1 -0
  7. package/dist/connector-app/index.d.mts +2 -2
  8. package/dist/connector-app/index.mjs +2 -2
  9. package/dist/connector-app-C6_dZnhE.mjs +256 -0
  10. package/dist/connector-app-C6_dZnhE.mjs.map +1 -0
  11. package/dist/{constants-D-GQzpHZ.mjs → constants-v3UX-hf6.mjs} +3 -2
  12. package/dist/constants-v3UX-hf6.mjs.map +1 -0
  13. package/dist/demo/index.d.mts +1 -1
  14. package/dist/demo/index.mjs +1 -1
  15. package/dist/{demo-B30Vlkk2.mjs → demo-DqTJ7TWI.mjs} +32 -19
  16. package/dist/demo-DqTJ7TWI.mjs.map +1 -0
  17. package/dist/iceberg/index.d.mts +3 -3
  18. package/dist/iceberg/index.mjs +3 -3
  19. package/dist/iceberg-Cj2PwlNm.mjs +30 -0
  20. package/dist/iceberg-Cj2PwlNm.mjs.map +1 -0
  21. package/dist/{index-DJhDp4Xc.d.mts → index-BTAbexBp.d.mts} +13 -16
  22. package/dist/{index-BUHSGm9Q.d.mts → index-Byr8NTdG.d.mts} +2 -2
  23. package/dist/{index-Dk7urvpF.d.mts → index-CV6V6fyx.d.mts} +9 -6
  24. package/dist/{index-NnCutIqM.d.mts → index-CZKx2Yce.d.mts} +29 -19
  25. package/dist/{index-BfXpgxk9.d.mts → index-DeTwIUKl.d.mts} +3 -3
  26. package/dist/{index-CRVAwJ5-.d.mts → index-dHS59EZl.d.mts} +4 -4
  27. package/dist/{index-ygpml2kC.d.mts → index-fHIJJoRe.d.mts} +17 -10
  28. package/dist/index-uFa2wwYo.d.mts +27 -0
  29. package/dist/index.d.mts +11 -11
  30. package/dist/index.mjs +10 -10
  31. package/dist/ingestion/index.d.mts +1 -1
  32. package/dist/ingestion/index.mjs +1 -1
  33. package/dist/{ingestion-q0WwqUSt.mjs → ingestion-DoCUwm5N.mjs} +6 -6
  34. package/dist/{ingestion-q0WwqUSt.mjs.map → ingestion-DoCUwm5N.mjs.map} +1 -1
  35. package/dist/ingestor/index.d.mts +3 -3
  36. package/dist/ingestor/index.mjs +3 -3
  37. package/dist/{ingestor-D4L-HPkg.mjs → ingestor-DjCEzBzZ.mjs} +6 -6
  38. package/dist/ingestor-DjCEzBzZ.mjs.map +1 -0
  39. package/dist/{instrumented-Bhp7fyvh.mjs → instrumented-Bq1WX2L5.mjs} +2 -2
  40. package/dist/{instrumented-Bhp7fyvh.mjs.map → instrumented-Bq1WX2L5.mjs.map} +1 -1
  41. package/dist/manifest/index.mjs +1 -1
  42. package/dist/{manifest-BGKxmKLy.mjs → manifest-CABeIhAG.mjs} +2 -2
  43. package/dist/{manifest-BGKxmKLy.mjs.map → manifest-CABeIhAG.mjs.map} +1 -1
  44. package/dist/{metrics-BaEX__MN.mjs → metrics-C901IIYZ.mjs} +6 -11
  45. package/dist/metrics-C901IIYZ.mjs.map +1 -0
  46. package/dist/{metrics-CVXFL7YT.d.mts → metrics-DoJ0WeKu.d.mts} +2 -2
  47. package/dist/metrics.d.mts +1 -1
  48. package/dist/metrics.mjs +1 -1
  49. package/dist/runtime-config/index.d.mts +2 -2
  50. package/dist/runtime-config/index.mjs +3 -3
  51. package/dist/{runtime-config-Oa55gAew.mjs → runtime-config-Bn8Fivt1.mjs} +11 -8
  52. package/dist/runtime-config-Bn8Fivt1.mjs.map +1 -0
  53. package/dist/{server-CMnxz3h0.mjs → server-D1TTN0_D.mjs} +3 -3
  54. package/dist/{server-CMnxz3h0.mjs.map → server-D1TTN0_D.mjs.map} +1 -1
  55. package/dist/{service-DLkhuxYl.d.mts → service-BRoy_EId.d.mts} +2 -2
  56. package/dist/state-store/index.mjs +1 -1
  57. package/dist/{state-store-uFvHY7Ax.mjs → state-store-DhSrKVEH.mjs} +2 -2
  58. package/dist/{state-store-uFvHY7Ax.mjs.map → state-store-DhSrKVEH.mjs.map} +1 -1
  59. package/dist/status/index.d.mts +1 -1
  60. package/dist/status/index.mjs +1 -1
  61. package/dist/{status-CKb9IhQ9.mjs → status-4h6Oxh-O.mjs} +2 -2
  62. package/dist/{status-CKb9IhQ9.mjs.map → status-4h6Oxh-O.mjs.map} +1 -1
  63. package/dist/{table-V0tgUC5i.mjs → table-CxM09xf7.mjs} +22 -85
  64. package/dist/table-CxM09xf7.mjs.map +1 -0
  65. package/dist/{types-xZtSmrGA.d.mts → types-NtSv6gwl.d.mts} +3 -3
  66. package/dist/webhook/index.d.mts +2 -2
  67. package/dist/webhook/index.mjs +1 -1
  68. package/package.json +19 -18
  69. package/src/catalog/retry.ts +25 -0
  70. package/src/catalog/table.ts +73 -37
  71. package/src/connector-app/create-table.ts +8 -8
  72. package/src/connector-app/demo.ts +24 -19
  73. package/src/connector-app/index.ts +1 -0
  74. package/src/connector-app/setup.ts +69 -0
  75. package/src/core/types.ts +1 -1
  76. package/src/demo/definition.ts +1 -1
  77. package/src/demo/generate.ts +1 -1
  78. package/src/demo/run.ts +60 -30
  79. package/src/iceberg/annotations.ts +3 -16
  80. package/src/iceberg/index.ts +2 -2
  81. package/src/iceberg/table.ts +28 -78
  82. package/src/ingestion/engine.ts +1 -1
  83. package/src/ingestor/{wings.ts → arrow-flight.ts} +25 -27
  84. package/src/ingestor/console.ts +1 -1
  85. package/src/ingestor/index.ts +5 -1
  86. package/src/runtime-config/config.ts +8 -3
  87. package/src/runtime-config/constants.ts +2 -1
  88. package/src/runtime-config/index.ts +1 -1
  89. package/src/runtime-config/table-bindings.ts +6 -4
  90. package/src/telemetry.ts +11 -19
  91. package/dist/connector-app-C-L4QqG6.mjs +0 -123
  92. package/dist/connector-app-C-L4QqG6.mjs.map +0 -1
  93. package/dist/constants-D-GQzpHZ.mjs.map +0 -1
  94. package/dist/demo-B30Vlkk2.mjs.map +0 -1
  95. package/dist/iceberg-D6L8I6vA.mjs +0 -39
  96. package/dist/iceberg-D6L8I6vA.mjs.map +0 -1
  97. package/dist/index-CUPP2A_m.d.mts +0 -27
  98. package/dist/ingestor-D4L-HPkg.mjs.map +0 -1
  99. package/dist/metrics-BaEX__MN.mjs.map +0 -1
  100. package/dist/runtime-config-Oa55gAew.mjs.map +0 -1
  101. package/dist/table-V0tgUC5i.mjs.map +0 -1
  102. package/dist/wings-I56hGtNv.mjs.map +0 -1
@@ -6,30 +6,34 @@ import type {
6
6
  TableSchema,
7
7
  } from "iceberg-js";
8
8
 
9
- import * as Wings from "@useairfoil/wings";
9
+ import { CatalogManager } from "@useairfoil/api";
10
10
  import { Effect, type Schema } from "effect";
11
11
  import { HttpClient } from "effect/http";
12
12
  import { getCurrentSchema } from "iceberg-js";
13
13
 
14
14
  import { ConnectorError } from "../errors";
15
15
  import { IcebergSchemaError } from "../iceberg/error";
16
- import { compileTable, makeCommitRequest } from "../iceberg/table";
16
+ import { compileTable, makeCreateRequest } from "../iceberg/table";
17
+ import { retryCatalogRequest, type TableRetryOptions } from "./retry";
17
18
 
18
19
  type Catalog = Effect.Success<
19
- ReturnType<Wings.CatalogManager.CatalogManagerService["getIcebergCatalog"]>
20
+ ReturnType<CatalogManager.CatalogManagerService["getIcebergCatalog"]>
20
21
  >;
21
22
 
22
23
  type TableOptions = {
23
- readonly catalog: Catalog;
24
+ readonly retry?: TableRetryOptions;
25
+ readonly catalog: Pick<Catalog, "loadTable" | "createTable" | "createNamespaceIfNotExists">;
24
26
  readonly identifier: TableIdentifier;
25
27
  readonly location?: string | undefined;
28
+ readonly properties?: Readonly<Record<string, string>> | undefined;
26
29
  };
27
30
 
28
- type WingsTableOptions = {
29
- readonly wingsUri: string;
31
+ type EngineTableOptions = {
32
+ readonly engineUri: string;
30
33
  readonly catalogId: string;
31
34
  readonly identifier: TableIdentifier;
32
35
  readonly location?: string | undefined;
36
+ readonly properties?: Readonly<Record<string, string>> | undefined;
33
37
  };
34
38
 
35
39
  const tableError = (cause: unknown): ConnectorError => {
@@ -58,21 +62,12 @@ const compareTypes = (
58
62
  if (actual.type !== "struct") return path;
59
63
  return compareFields(expected.fields, actual.fields, path);
60
64
  case "list":
61
- if (
62
- actual.type !== "list" ||
63
- expected["element-id"] !== actual["element-id"] ||
64
- expected["element-required"] !== actual["element-required"]
65
- ) {
65
+ if (actual.type !== "list" || expected["element-required"] !== actual["element-required"]) {
66
66
  return path;
67
67
  }
68
68
  return compareTypes(expected.element, actual.element, `${path}.element`);
69
69
  case "map":
70
- if (
71
- actual.type !== "map" ||
72
- expected["key-id"] !== actual["key-id"] ||
73
- expected["value-id"] !== actual["value-id"] ||
74
- expected["value-required"] !== actual["value-required"]
75
- ) {
70
+ if (actual.type !== "map" || expected["value-required"] !== actual["value-required"]) {
76
71
  return path;
77
72
  }
78
73
  return (
@@ -93,7 +88,7 @@ const compareFields = (
93
88
  const fieldPath = `${path}.${field.name}`;
94
89
  const other = actual.find((candidate) => candidate.name === field.name);
95
90
 
96
- if (!other || field.id !== other.id || field.required !== other.required) return fieldPath;
91
+ if (!other || field.required !== other.required) return fieldPath;
97
92
 
98
93
  const mismatch = compareTypes(field.type, other.type, fieldPath);
99
94
  if (mismatch) return mismatch;
@@ -107,6 +102,21 @@ const verifyTable = (
107
102
  Effect.try({
108
103
  try: () => {
109
104
  if (!Array.isArray(metadata.schemas)) throw new Error("Invalid table metadata");
105
+ if (metadata["format-version"] !== 3) {
106
+ throw new Error(
107
+ "Storage-engine tables must use Iceberg format v3. Migrate the table before ingesting.",
108
+ );
109
+ }
110
+ const partitionSpec = metadata["partition-specs"].find(
111
+ (spec) => spec["spec-id"] === metadata["default-spec-id"],
112
+ );
113
+ if (!partitionSpec) throw new Error("Table has no default partition spec");
114
+ if (partitionSpec.fields.length > 0) {
115
+ throw new Error("Storage-engine tables must be unpartitioned.");
116
+ }
117
+ if (metadata.properties?.["encryption.key-id"] !== undefined) {
118
+ throw new Error("Storage-engine tables must be unencrypted.");
119
+ }
110
120
  const actual = getCurrentSchema(metadata);
111
121
  if (!actual) throw new Error("Table has no current schema");
112
122
 
@@ -117,11 +127,17 @@ const verifyTable = (
117
127
  );
118
128
  }
119
129
 
120
- const expectedIds = new Set(expected["identifier-field-ids"]);
121
- const actualIds = new Set(actual["identifier-field-ids"]);
130
+ const identifierNames = (schema: TableSchema) =>
131
+ new Set(
132
+ schema.fields
133
+ .filter((field) => schema["identifier-field-ids"]?.includes(field.id))
134
+ .map((field) => field.name),
135
+ );
136
+ const expectedNames = identifierNames(expected);
137
+ const actualNames = identifierNames(actual);
122
138
  if (
123
- expectedIds.size !== actualIds.size ||
124
- [...expectedIds].some((id) => !actualIds.has(id))
139
+ expectedNames.size !== actualNames.size ||
140
+ [...expectedNames].some((name) => !actualNames.has(name))
125
141
  ) {
126
142
  throw new Error(
127
143
  "Table identifier fields do not match. Recreate the table or revert the schema change.",
@@ -137,35 +153,40 @@ const verifyTable = (
137
153
  }),
138
154
  });
139
155
 
140
- /** Creates a table with the IDs from its Effect schema, then checks the loaded schema. */
156
+ /** Creates a table through the REST catalog, then checks the loaded schema. */
141
157
  export const createTable = (
142
158
  schema: Schema.Top,
143
159
  options: TableOptions,
144
160
  ): Effect.Effect<TableSchema, ConnectorError> =>
145
161
  Effect.gen(function* () {
146
162
  const table = yield* compileTable(schema);
147
- yield* options.catalog.commitTable(
148
- options.identifier,
149
- makeCommitRequest(table, { location: options.location }),
163
+ yield* options.catalog.createTable(
164
+ { namespace: options.identifier.namespace },
165
+ makeCreateRequest(table, {
166
+ name: options.identifier.name,
167
+ location: options.location,
168
+ properties: options.properties,
169
+ }),
150
170
  );
151
171
 
152
172
  const metadata = yield* options.catalog.loadTable(options.identifier);
153
173
  return yield* verifyTable(metadata, table.schema);
154
174
  }).pipe(Effect.mapError(tableError));
155
175
 
156
- /** Creates a table through a Wings catalog. */
157
- export const createTableFromWings = (
176
+ /** Creates a missing table through an engine catalog and verifies its schema. */
177
+ export const ensureTableFromEngine = (
158
178
  schema: Schema.Top,
159
- options: WingsTableOptions,
179
+ options: EngineTableOptions,
160
180
  ): Effect.Effect<TableSchema, ConnectorError, HttpClient.HttpClient> =>
161
181
  Effect.scoped(
162
182
  Effect.gen(function* () {
163
- const manager = yield* Wings.CatalogManager.make({ baseUrl: options.wingsUri });
183
+ const manager = yield* CatalogManager.make({ engineUri: options.engineUri });
164
184
  const catalog = yield* manager.getIcebergCatalog(options.catalogId);
165
- return yield* createTable(schema, {
185
+ return yield* ensureTable(schema, {
166
186
  catalog,
167
187
  identifier: options.identifier,
168
188
  location: options.location,
189
+ properties: options.properties,
169
190
  });
170
191
  }),
171
192
  ).pipe(Effect.mapError(tableError));
@@ -177,14 +198,29 @@ export const ensureTable = (
177
198
  ): Effect.Effect<TableSchema, ConnectorError> =>
178
199
  Effect.gen(function* () {
179
200
  const table = yield* compileTable(schema);
180
- const metadata = yield* options.catalog.loadTable(options.identifier).pipe(
201
+ const request = <A>(
202
+ effect: Effect.Effect<A, Effect.Error<ReturnType<Catalog["loadConfig"]>>>,
203
+ ) => retryCatalogRequest(effect, options.retry);
204
+ const metadata = yield* request(options.catalog.loadTable(options.identifier)).pipe(
181
205
  Effect.catchIf(
182
206
  (error) => error.status === 404,
183
207
  () =>
184
- options.catalog
185
- .commitTable(
186
- options.identifier,
187
- makeCommitRequest(table, { location: options.location }),
208
+ request(
209
+ options.catalog.createNamespaceIfNotExists({ namespace: options.identifier.namespace }),
210
+ )
211
+ .pipe(
212
+ Effect.andThen(
213
+ request(
214
+ options.catalog.createTable(
215
+ { namespace: options.identifier.namespace },
216
+ makeCreateRequest(table, {
217
+ name: options.identifier.name,
218
+ location: options.location,
219
+ properties: options.properties,
220
+ }),
221
+ ),
222
+ ),
223
+ ),
188
224
  )
189
225
  .pipe(
190
226
  // Another instance may have created the table. Reload and check it.
@@ -192,7 +228,7 @@ export const ensureTable = (
192
228
  (error) => error.status === 409 || error.status === 412,
193
229
  () => Effect.void,
194
230
  ),
195
- Effect.flatMap(() => options.catalog.loadTable(options.identifier)),
231
+ Effect.flatMap(() => request(options.catalog.loadTable(options.identifier))),
196
232
  ),
197
233
  ),
198
234
  );
@@ -1,10 +1,10 @@
1
1
  import { Effect, Option, type Schema } from "effect";
2
2
  import { Argument, CliError, Command, Flag } from "effect/cli";
3
3
 
4
- import { createTableFromWings } from "../catalog/table";
5
- import { wingsUri } from "../runtime-config/config";
4
+ import { ensureTableFromEngine } from "../catalog/table";
5
+ import { engineUri } from "../runtime-config/config";
6
6
 
7
- /** Creates the table command for a connector's row schemas. */
7
+ /** Creates the table command; existing tables must match the connector's row schemas. */
8
8
  export const makeCreateTableCommand = (schemas: Readonly<Record<string, Schema.Top>>) =>
9
9
  Command.make(
10
10
  "create-table",
@@ -35,13 +35,13 @@ export const makeCreateTableCommand = (schemas: Readonly<Record<string, Schema.T
35
35
  });
36
36
  }
37
37
 
38
- const uri = yield* wingsUri;
39
- yield* createTableFromWings(schema, {
40
- wingsUri: uri.href,
38
+ const uri = yield* engineUri;
39
+ yield* ensureTableFromEngine(schema, {
40
+ engineUri: uri.href,
41
41
  catalogId: catalog,
42
42
  identifier: { namespace, name },
43
43
  location: Option.getOrUndefined(location),
44
44
  });
45
- yield* Effect.log(`Created ${table}`);
45
+ yield* Effect.log(`Prepared ${table}`);
46
46
  }),
47
- ).pipe(Command.withDescription("Create an Iceberg table for a connector resource"));
47
+ ).pipe(Command.withDescription("Create or verify an Iceberg table for a connector resource"));
@@ -1,41 +1,46 @@
1
- import { ByteSize, DateTime, Effect, Option } from "effect";
1
+ import { DateTime, Effect, Option, Schema } from "effect";
2
2
  import { Command, Flag } from "effect/cli";
3
+ import humanFormat from "human-format";
3
4
 
4
5
  import { run, type DemoDefinition } from "../demo";
5
- import { layerWingsConfig } from "../ingestor/wings";
6
+ import { layerArrowFlightConfig } from "../ingestor/arrow-flight";
6
7
 
7
- /** Reads sizes such as `512KiB`, `128MiB` or `1GiB`. */
8
- export const parseSize = (input: string): Option.Option<number> => {
9
- if (!/^\d+(KiB|MiB|GiB)$/.test(input)) return Option.none();
10
-
11
- return ByteSize.fromString(input).pipe(
12
- Option.flatMap(ByteSize.toNumber),
13
- Option.filter((bytes) => bytes > 0),
8
+ /** Reads byte sizes such as `512kB`, `128MB` or `1.5GB`. */
9
+ export const parseSize = (input: string): Option.Option<number> =>
10
+ Option.liftThrowable(() => humanFormat.parse.bytes(input))().pipe(
11
+ Option.filter((bytes) => Number.isSafeInteger(bytes) && bytes > 0),
14
12
  );
15
- };
16
13
 
17
14
  /**
18
- * Creates the command that writes the demo data to Wings, then exits.
19
- * It reads Wings and table bindings from Config, so provide `RuntimeConfig.layerHosted()`.
15
+ * Creates the command that writes demo data over Arrow Flight, then exits.
16
+ * It reads hosted config and table bindings, so provide `RuntimeConfig.layerHosted()`.
20
17
  */
21
18
  export const makeDemoCommand = (demo: DemoDefinition) =>
22
19
  Command.make(
23
20
  "demo",
24
21
  {
25
22
  size: Flag.String("size").pipe(
26
- Flag.filterMap(parseSize, () => "a size like 512KiB, 128MiB or 1GiB"),
23
+ Flag.filterMap(parseSize, () => "a size like 512kB, 128MB or 1.5GB"),
24
+ ),
25
+ seed: Flag.Int("seed").pipe(Flag.optional),
26
+ batchSize: Flag.Int("batch-size").pipe(
27
+ Flag.withSchema(Schema.Int.check(Schema.isGreaterThan(0))),
28
+ Flag.withDescription("Maximum rows per ingestion batch (default: 500)"),
29
+ Flag.optional,
27
30
  ),
28
- seed: Flag.Int("seed").pipe(Flag.withDefault(1)),
29
31
  },
30
- ({ size, seed }) =>
32
+ ({ size, seed, batchSize }) =>
31
33
  Effect.gen(function* () {
32
34
  const now = yield* DateTime.nowAsDate;
33
35
 
34
- yield* run(demo, { bytes: size, seed, now }).pipe(
36
+ yield* run(demo, {
37
+ bytes: size,
38
+ seed: Option.getOrUndefined(seed),
39
+ batchSize: Option.getOrUndefined(batchSize),
40
+ now,
41
+ }).pipe(
35
42
  Effect.provide(
36
- layerWingsConfig({
37
- resources: [...demo.setup.tables, ...demo.group.tables],
38
- }),
43
+ layerArrowFlightConfig({ resources: [...demo.setup.tables, ...demo.group.tables] }),
39
44
  ),
40
45
  );
41
46
  }),
@@ -2,3 +2,4 @@ export { check, type CheckOptions, type CheckResult, type ResourceCheckResult }
2
2
  export { makeCreateTableCommand } from "./create-table";
3
3
  export { makeDemoCommand, parseSize } from "./demo";
4
4
  export { start, type App, type StartOptions } from "./start";
5
+ export { makeSetupCommand } from "./setup";
@@ -0,0 +1,69 @@
1
+ import { CatalogManager } from "@useairfoil/api";
2
+ import { Duration, Effect, Option, Schema } from "effect";
3
+ import { Command, Flag } from "effect/cli";
4
+
5
+ import { retryCatalogRequest } from "../catalog/retry";
6
+ import { ensureTable } from "../catalog/table";
7
+ import * as RuntimeConfig from "../runtime-config";
8
+
9
+ /** Creates or verifies all bound tables without loading provider credentials. */
10
+ export const makeSetupCommand = (schemas: Readonly<Record<string, Schema.Top>>) =>
11
+ Command.make(
12
+ "setup",
13
+ {
14
+ checkpointIntervalSec: Flag.Int("checkpoint-interval-sec").pipe(
15
+ Flag.withSchema(Schema.Int.check(Schema.isBetween({ minimum: 0, maximum: 900 }))),
16
+ Flag.withDescription(
17
+ "Checkpoint interval for new tables in seconds (0 disables the interval delay)",
18
+ ),
19
+ Flag.optional,
20
+ ),
21
+ retryInterval: Flag.String("retry-interval").pipe(
22
+ Flag.withDefault("5 seconds"),
23
+ Flag.withSchema(Schema.DurationFromString),
24
+ Flag.filter(
25
+ (duration) => Duration.isPositive(duration) && Duration.isFinite(duration),
26
+ () => "a positive duration, such as 5 seconds",
27
+ ),
28
+ Flag.withDescription("Delay between transient catalog retries"),
29
+ ),
30
+ retries: Flag.Int("retries").pipe(
31
+ Flag.withDefault(10),
32
+ Flag.filter(
33
+ (value) => value >= 0,
34
+ () => "a non-negative number of retries",
35
+ ),
36
+ Flag.withDescription("Maximum retries per catalog request after the initial attempt"),
37
+ ),
38
+ },
39
+ ({ retryInterval, retries, checkpointIntervalSec }) =>
40
+ Effect.gen(function* () {
41
+ const source = {
42
+ resources: Object.entries(schemas).map(([name, rowSchema]) => ({ name, rowSchema })),
43
+ };
44
+ const tables = yield* RuntimeConfig.loadTableBindings(source);
45
+ const uri = yield* RuntimeConfig.engineUri;
46
+ const catalogId = yield* RuntimeConfig.catalog;
47
+ const manager = yield* CatalogManager.make({ engineUri: uri.href });
48
+ const retry = { interval: retryInterval, times: retries };
49
+ const properties = Option.match(checkpointIntervalSec, {
50
+ onNone: () => undefined,
51
+ onSome: (seconds) => ({ "airfoil.checkpoint.interval-sec": String(seconds) }),
52
+ });
53
+ const catalog = yield* retryCatalogRequest(manager.getIcebergCatalog(catalogId), retry);
54
+
55
+ // Sequential setup avoids bursting the catalog's table-creation quota.
56
+ for (const resource of source.resources) {
57
+ const binding = tables[resource.name];
58
+ if (!binding) continue;
59
+ yield* ensureTable(resource.rowSchema, {
60
+ catalog,
61
+ identifier: binding,
62
+ location: binding.location,
63
+ retry,
64
+ properties,
65
+ });
66
+ yield* Effect.logInfo(`Prepared ${[...binding.namespace, binding.name].join(".")}`);
67
+ }
68
+ }),
69
+ ).pipe(Command.withDescription("Create or verify the Iceberg tables in AIRFOIL_TABLE_BINDINGS"));
package/src/core/types.ts CHANGED
@@ -96,7 +96,7 @@ export type ResourceDelete<S extends ResourceSchema, Version extends ResourceFie
96
96
  readonly _deleted: true;
97
97
  };
98
98
 
99
- /** A full row, or a delete. Wings replaces the whole row. */
99
+ /** A full row, or a delete. The engine replaces the whole row. */
100
100
  export type ResourceUpdate<S extends ResourceSchema, Version extends ResourceField<S>> =
101
101
  | (ResourceRow<S> & { readonly _deleted?: boolean })
102
102
  | ResourceDelete<S, Version>;
@@ -48,7 +48,7 @@ export type DemoDefinition<
48
48
  readonly tables: Group;
49
49
  readonly rows: (index: number, context: DemoContext) => DemoRows<Group>;
50
50
  /**
51
- * Groups written per chunk, 100 by default. A run can go over the size by
51
+ * Groups written per chunk, 1000 by default. A run can go over the size by
52
52
  * one chunk, so lower it when a group is big.
53
53
  */
54
54
  readonly groupsPerChunk?: number;
@@ -5,7 +5,7 @@ import { Duration, Effect, Schema, type SchemaRepresentation } from "effect";
5
5
  import { ConnectorError } from "../errors";
6
6
  import { checkHasRepresentation } from "../iceberg/table";
7
7
 
8
- /** What one run shares: the seeded Faker and the time the run started. */
8
+ /** What one run shares: the Faker and the time the run started. */
9
9
  export type DemoContext = {
10
10
  readonly faker: Faker;
11
11
  readonly now: Date;
package/src/demo/run.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { Faker, base, en } from "@faker-js/faker";
2
2
  import { Array as Arr, Effect, Option, Schema, Stream } from "effect";
3
+ import humanFormat from "human-format";
3
4
 
4
5
  import type { DemoDefinition, DemoTable } from "./definition";
5
6
 
@@ -11,9 +12,12 @@ import { compileRow, type DemoContext } from "./generate";
11
12
  export type RunOptions = {
12
13
  /** Stop once this many Arrow bytes are written. */
13
14
  readonly bytes: number;
14
- readonly seed: number;
15
+ /** Omit for random data on each run. */
16
+ readonly seed?: number;
15
17
  /** The time data is made around. Same seed, time and tables give the same rows. */
16
18
  readonly now: Date;
19
+ /** Maximum rows per ingestion batch. Defaults to 500. Must be a positive integer. */
20
+ readonly batchSize?: number;
17
21
  /** Groups per chunk. Overrides the demo's. */
18
22
  readonly groupsPerChunk?: number;
19
23
  };
@@ -24,10 +28,6 @@ export type DemoTotals = IngestResult & {
24
28
 
25
29
  const empty: DemoTotals = { rows: 0, bytes: 0, tables: {} };
26
30
 
27
- // Wings rejects Flight messages over 4 MiB. 500 rows keeps a batch of the
28
- // widest demo rows well under that.
29
- const batchRows = 500;
30
-
31
31
  const sum = (results: ReadonlyArray<IngestResult>): IngestResult => ({
32
32
  rows: results.reduce((total, result) => total + result.rows, 0),
33
33
  bytes: results.reduce((total, result) => total + result.bytes, 0),
@@ -56,11 +56,17 @@ const add = (totals: DemoTotals, written: ReadonlyArray<readonly [string, Ingest
56
56
  */
57
57
  export const run = Effect.fnUntraced(function* (demo: DemoDefinition, options: RunOptions) {
58
58
  const ingestor = yield* Ingestor;
59
+ // Wide rows may need a lower batch size to fit the engine's limits.
60
+ const batchSize = yield* Schema.decodeUnknownEffect(Schema.Int.check(Schema.isGreaterThan(0)))(
61
+ options.batchSize ?? 500,
62
+ ).pipe(
63
+ Effect.mapError((cause) => new ConnectorError({ message: "Invalid demo batch size", cause })),
64
+ );
59
65
 
60
66
  const bound = (table: DemoTable) =>
61
67
  ingestor.resourceNames === undefined || ingestor.resourceNames.includes(table.name);
62
68
 
63
- // A fresh Faker per run, so its sequence depends only on the seed.
69
+ // A fresh Faker per run keeps explicit seeds repeatable.
64
70
  const faker = new Faker({ locale: [en, base] });
65
71
  faker.seed(options.seed);
66
72
  const context: DemoContext = { faker, now: options.now };
@@ -80,27 +86,37 @@ export const run = Effect.fnUntraced(function* (demo: DemoDefinition, options: R
80
86
  tables: Effect.Success<ReturnType<typeof prepare>>,
81
87
  rowsOf: (name: string) => ReadonlyArray<object>,
82
88
  ) =>
83
- Effect.forEach(tables, ({ table, fill, decode }) =>
84
- Effect.forEach(rowsOf(table.name), (row) =>
85
- decode(fill(context, row)).pipe(
86
- Effect.mapError(
87
- (cause) => new ConnectorError({ message: `Invalid demo row for ${table.name}`, cause }),
88
- ),
89
- ),
90
- ).pipe(
91
- Effect.flatMap((rows) =>
92
- Effect.forEach(Arr.chunksOf(rows, batchRows), (batch) =>
93
- ingestBatch({
94
- connector: demo.name,
95
- resource: table.name,
96
- source: "demo",
97
- batch: { rows: batch },
98
- }),
89
+ Effect.gen(function* () {
90
+ // Fill rows in table order before concurrent writes, keeping Faker deterministic.
91
+ const prepared = yield* Effect.forEach(tables, ({ table, fill, decode }) =>
92
+ Effect.forEach(rowsOf(table.name), (row) =>
93
+ decode(fill(context, row)).pipe(
94
+ Effect.mapError(
95
+ (cause) =>
96
+ new ConnectorError({ message: `Invalid demo row for ${table.name}`, cause }),
97
+ ),
99
98
  ),
100
- ),
101
- Effect.map((results) => [table.name, sum(results)] as const),
102
- ),
103
- );
99
+ ).pipe(Effect.map((rows) => ({ table, rows }))),
100
+ );
101
+
102
+ // Share durable flushes with bounded parallel writes.
103
+ return yield* Effect.forEach(
104
+ prepared,
105
+ ({ table, rows }) =>
106
+ Effect.forEach(
107
+ Arr.chunksOf(rows, batchSize),
108
+ (rows) =>
109
+ ingestBatch({
110
+ connector: demo.name,
111
+ resource: table.name,
112
+ source: "demo",
113
+ batch: { rows },
114
+ }),
115
+ { concurrency: 8 },
116
+ ).pipe(Effect.map((results) => [table.name, sum(results)] as const)),
117
+ { concurrency: 4 },
118
+ );
119
+ });
104
120
 
105
121
  const setupTables = yield* prepare(demo.setup.tables);
106
122
  const groupTables = yield* prepare(demo.group.tables);
@@ -112,7 +128,7 @@ export const run = Effect.fnUntraced(function* (demo: DemoDefinition, options: R
112
128
  afterSetup.bytes >= options.bytes || groupTables.length === 0
113
129
  ? afterSetup
114
130
  : yield* Stream.range(0, (demo.group.count ?? Infinity) - 1).pipe(
115
- Stream.grouped(options.groupsPerChunk ?? demo.group.groupsPerChunk ?? 100),
131
+ Stream.grouped(options.groupsPerChunk ?? demo.group.groupsPerChunk ?? 1000),
116
132
  Stream.mapEffect((indexes) => {
117
133
  const groups = indexes.map((index) => demo.group.rows(index, context));
118
134
  return write(groupTables, (name) => groups.flatMap((group) => group[name] ?? []));
@@ -121,7 +137,7 @@ export const run = Effect.fnUntraced(function* (demo: DemoDefinition, options: R
121
137
  Stream.takeUntil((current) => current.bytes >= options.bytes),
122
138
  Stream.tap((current) =>
123
139
  Effect.logInfo("demo progress").pipe(
124
- Effect.annotateLogs({ rows: current.rows, bytes: current.bytes }),
140
+ Effect.annotateLogs({ rows: current.rows, size: humanFormat.bytes(current.bytes) }),
125
141
  ),
126
142
  ),
127
143
  Stream.runLast,
@@ -131,11 +147,25 @@ export const run = Effect.fnUntraced(function* (demo: DemoDefinition, options: R
131
147
  if (totals.bytes < options.bytes) {
132
148
  yield* Effect.logWarning(
133
149
  "The selected tables ran out of rows, so the run wrote less than the size asked for",
134
- ).pipe(Effect.annotateLogs({ bytes: totals.bytes, target: options.bytes }));
150
+ ).pipe(
151
+ Effect.annotateLogs({
152
+ size: humanFormat.bytes(totals.bytes),
153
+ target: humanFormat.bytes(options.bytes),
154
+ }),
155
+ );
135
156
  }
136
157
 
137
158
  yield* Effect.logInfo("demo done").pipe(
138
- Effect.annotateLogs({ rows: totals.rows, bytes: totals.bytes, tables: totals.tables }),
159
+ Effect.annotateLogs({
160
+ rows: totals.rows,
161
+ size: humanFormat.bytes(totals.bytes),
162
+ tables: Object.fromEntries(
163
+ Object.entries(totals.tables).map(([name, result]) => [
164
+ name,
165
+ { rows: result.rows, size: humanFormat.bytes(result.bytes) },
166
+ ]),
167
+ ),
168
+ }),
139
169
  );
140
170
 
141
171
  return totals;
@@ -3,24 +3,11 @@ import { Schema } from "effect";
3
3
  declare module "effect/Schema" {
4
4
  namespace Annotations {
5
5
  interface Annotations {
6
- readonly fieldId?: number | undefined;
7
6
  readonly icebergIdentifierFields?: ReadonlyArray<string> | undefined;
8
7
  }
9
8
  }
10
9
  }
11
10
 
12
- /** Sets a field's Iceberg ID, and any other annotations. */
13
- export const field =
14
- <A = unknown>(fieldId: number, annotations: Schema.Annotations.Key<A> = {}) =>
15
- <S extends Schema.Schema<A>>(schema: S): S["Rebuild"] =>
16
- schema.pipe(Schema.annotateKey({ ...annotations, fieldId }));
17
-
18
- /** Adds an offset to a base ID for reusable schemas. */
19
- export const ids =
20
- (baseId: number) =>
21
- (offset: number): number =>
22
- baseId + offset;
23
-
24
11
  type KnownField<S extends Schema.Top> = keyof S["Type"] & string;
25
12
 
26
13
  /** A row field that cannot be null or undefined. */
@@ -40,7 +27,7 @@ export interface Identified<Key extends string> {
40
27
  }
41
28
 
42
29
  /**
43
- * Marks the fields that identify a row. Wings upserts rows by them.
30
+ * Marks the fields that identify a row. The engine upserts rows by them.
44
31
  *
45
32
  * Put it last. A later `.annotate()` keeps the runtime annotation, but the type
46
33
  * loses the key, so `Resource.entity` stops compiling.
@@ -48,8 +35,8 @@ export interface Identified<Key extends string> {
48
35
  * @example
49
36
  * ```ts
50
37
  * const Post = Schema.Struct({
51
- * id: Schema.String.pipe(Iceberg.field(1)),
52
- * updatedAt: Schema.String.pipe(Iceberg.field(2)),
38
+ * id: Schema.String,
39
+ * updatedAt: Schema.String,
53
40
  * }).pipe(Iceberg.identifier("id"));
54
41
  * ```
55
42
  */
@@ -1,3 +1,3 @@
1
- export { field, identifier, ids } from "./annotations";
1
+ export { identifier } from "./annotations";
2
2
  export { IcebergSchemaError } from "./error";
3
- export { makeCreateTableCommitRequest, type CreateTableCommitOptions } from "./table";
3
+ export { makeCreateTableRequest, type CreateTableOptions } from "./table";