@useairfoil/connector-kit 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (182) hide show
  1. package/LICENSE.txt +201 -0
  2. package/METRICS.md +38 -42
  3. package/README.md +111 -444
  4. package/dist/annotations-vzSzXtr-.d.mts +34 -0
  5. package/dist/arrow-flight-DDQ0ktx9.mjs +251 -0
  6. package/dist/arrow-flight-DDQ0ktx9.mjs.map +1 -0
  7. package/dist/auth/index.d.mts +2 -0
  8. package/dist/{auth-DvU_CZ7C.js → auth/index.mjs} +6 -8
  9. package/dist/auth/index.mjs.map +1 -0
  10. package/dist/connector-app/index.d.mts +2 -0
  11. package/dist/connector-app/index.mjs +2 -0
  12. package/dist/connector-app-C6_dZnhE.mjs +256 -0
  13. package/dist/connector-app-C6_dZnhE.mjs.map +1 -0
  14. package/dist/{constants-D113Ut1x.js → constants-v3UX-hf6.mjs} +5 -5
  15. package/dist/constants-v3UX-hf6.mjs.map +1 -0
  16. package/dist/demo/index.d.mts +2 -0
  17. package/dist/demo/index.mjs +2 -0
  18. package/dist/demo-DqTJ7TWI.mjs +204 -0
  19. package/dist/demo-DqTJ7TWI.mjs.map +1 -0
  20. package/dist/errors/index.d.mts +2 -0
  21. package/dist/errors/index.mjs +2 -0
  22. package/dist/{errors-DHWylqRZ.js → errors-pT0YVJU5.mjs} +2 -3
  23. package/dist/errors-pT0YVJU5.mjs.map +1 -0
  24. package/dist/iceberg/index.d.mts +3 -0
  25. package/dist/iceberg/index.mjs +3 -0
  26. package/dist/iceberg-Cj2PwlNm.mjs +30 -0
  27. package/dist/iceberg-Cj2PwlNm.mjs.map +1 -0
  28. package/dist/index-BTAbexBp.d.mts +55 -0
  29. package/dist/{index-DXa26Ry5.d.ts → index-BX8G7d41.d.mts} +1 -2
  30. package/dist/{index-VpHaZXZw.d.ts → index-Byr8NTdG.d.mts} +11 -13
  31. package/dist/index-CV6V6fyx.d.mts +79 -0
  32. package/dist/{index-C5Ma7V7t.d.ts → index-CZKx2Yce.d.mts} +72 -37
  33. package/dist/{index-DYpEMJf-.d.ts → index-CgvFomO6.d.mts} +6 -7
  34. package/dist/{index-bfjH7Yb0.d.ts → index-DeTwIUKl.d.mts} +9 -10
  35. package/dist/{index-DKoRwyFn.d.ts → index-DkVNO6ks.d.mts} +2 -5
  36. package/dist/{index-BKw7jA2a.d.ts → index-dHS59EZl.d.mts} +10 -8
  37. package/dist/{index-CR9HJDdr.d.ts → index-eFuoMZZ3.d.mts} +18 -20
  38. package/dist/{index-ZSuiZX5H.d.ts → index-fHIJJoRe.d.mts} +25 -30
  39. package/dist/index-uFa2wwYo.d.mts +27 -0
  40. package/dist/index.d.mts +16 -0
  41. package/dist/index.mjs +45 -0
  42. package/dist/index.mjs.map +1 -0
  43. package/dist/ingestion/index.d.mts +2 -0
  44. package/dist/ingestion/index.mjs +2 -0
  45. package/dist/ingestion-DoCUwm5N.mjs +275 -0
  46. package/dist/ingestion-DoCUwm5N.mjs.map +1 -0
  47. package/dist/ingestor/index.d.mts +3 -0
  48. package/dist/ingestor/index.mjs +4 -0
  49. package/dist/ingestor-DjCEzBzZ.mjs +26 -0
  50. package/dist/ingestor-DjCEzBzZ.mjs.map +1 -0
  51. package/dist/instrumented-Bq1WX2L5.mjs +29 -0
  52. package/dist/instrumented-Bq1WX2L5.mjs.map +1 -0
  53. package/dist/manifest/index.d.mts +2 -0
  54. package/dist/manifest/{index.js → index.mjs} +2 -4
  55. package/dist/{manifest-P7kBnch9.js → manifest-CABeIhAG.mjs} +14 -19
  56. package/dist/manifest-CABeIhAG.mjs.map +1 -0
  57. package/dist/{telemetry-j_nxwYfh.js → metrics-C901IIYZ.mjs} +172 -26
  58. package/dist/metrics-C901IIYZ.mjs.map +1 -0
  59. package/dist/{metrics-C4V1pm6v.d.ts → metrics-DoJ0WeKu.d.mts} +18 -13
  60. package/dist/metrics.d.mts +2 -0
  61. package/dist/metrics.mjs +2 -0
  62. package/dist/{chunk-Bp6m_JJh.js → rolldown-runtime-D7D4PA-g.mjs} +4 -4
  63. package/dist/runtime-config/index.d.mts +2 -0
  64. package/dist/runtime-config/index.mjs +3 -0
  65. package/dist/runtime-config-Bn8Fivt1.mjs +100 -0
  66. package/dist/runtime-config-Bn8Fivt1.mjs.map +1 -0
  67. package/dist/server-D1TTN0_D.mjs +144 -0
  68. package/dist/server-D1TTN0_D.mjs.map +1 -0
  69. package/dist/service-BRoy_EId.d.mts +27 -0
  70. package/dist/service-jWxxfEht.mjs +7 -0
  71. package/dist/service-jWxxfEht.mjs.map +1 -0
  72. package/dist/state-store/index.d.mts +2 -0
  73. package/dist/state-store/index.mjs +2 -0
  74. package/dist/{state-store-BdYulQOY.js → state-store-DhSrKVEH.mjs} +15 -22
  75. package/dist/state-store-DhSrKVEH.mjs.map +1 -0
  76. package/dist/status/{index.d.ts → index.d.mts} +1 -4
  77. package/dist/status/{index.js → index.mjs} +2 -6
  78. package/dist/{status-BTTd7AUR.js → status-4h6Oxh-O.mjs} +14 -16
  79. package/dist/status-4h6Oxh-O.mjs.map +1 -0
  80. package/dist/table-CxM09xf7.mjs +307 -0
  81. package/dist/table-CxM09xf7.mjs.map +1 -0
  82. package/dist/types-NtSv6gwl.d.mts +220 -0
  83. package/dist/webhook/index.d.mts +3 -0
  84. package/dist/webhook/index.mjs +12 -0
  85. package/dist/webhook/index.mjs.map +1 -0
  86. package/package.json +72 -58
  87. package/src/catalog/retry.ts +25 -0
  88. package/src/catalog/table.ts +237 -0
  89. package/src/connector-app/create-table.ts +47 -0
  90. package/src/connector-app/demo.ts +47 -0
  91. package/src/connector-app/index.ts +3 -0
  92. package/src/connector-app/setup.ts +69 -0
  93. package/src/connector-app/start.ts +1 -1
  94. package/src/core/builder.ts +15 -23
  95. package/src/core/index.ts +11 -1
  96. package/src/core/types.ts +114 -39
  97. package/src/demo/definition.ts +68 -0
  98. package/src/demo/generate.ts +167 -0
  99. package/src/demo/index.ts +3 -0
  100. package/src/demo/run.ts +172 -0
  101. package/src/iceberg/annotations.ts +49 -0
  102. package/src/iceberg/error.ts +9 -0
  103. package/src/iceberg/index.ts +3 -0
  104. package/src/iceberg/table.ts +461 -0
  105. package/src/index.ts +3 -1
  106. package/src/ingestion/engine.ts +240 -60
  107. package/src/ingestor/arrow-flight.ts +141 -0
  108. package/src/ingestor/arrow.ts +248 -0
  109. package/src/ingestor/console.ts +13 -0
  110. package/src/ingestor/index.ts +13 -0
  111. package/src/ingestor/instrumented.ts +61 -0
  112. package/src/ingestor/service.ts +30 -0
  113. package/src/manifest/config.ts +3 -3
  114. package/src/metrics.ts +66 -20
  115. package/src/runtime-config/config.ts +11 -8
  116. package/src/runtime-config/constants.ts +3 -2
  117. package/src/runtime-config/index.ts +2 -7
  118. package/src/runtime-config/layer.ts +1 -1
  119. package/src/runtime-config/table-bindings.ts +50 -136
  120. package/src/state-store/layer-key-value-store.ts +8 -3
  121. package/src/state-store/layer-memory.ts +1 -1
  122. package/src/state-store/layer-sql.ts +4 -4
  123. package/src/state-store/schema.ts +2 -2
  124. package/src/state-store/state.ts +14 -2
  125. package/src/status/route.ts +2 -2
  126. package/src/telemetry.ts +20 -28
  127. package/src/webhook/server.ts +39 -40
  128. package/dist/auth/index.d.ts +0 -2
  129. package/dist/auth/index.js +0 -3
  130. package/dist/auth-DvU_CZ7C.js.map +0 -1
  131. package/dist/connector-app/index.d.ts +0 -14
  132. package/dist/connector-app/index.js +0 -12
  133. package/dist/connector-app-BljHd0Pe.js +0 -64
  134. package/dist/connector-app-BljHd0Pe.js.map +0 -1
  135. package/dist/constants-D113Ut1x.js.map +0 -1
  136. package/dist/errors/index.d.ts +0 -2
  137. package/dist/errors/index.js +0 -3
  138. package/dist/errors-DHWylqRZ.js.map +0 -1
  139. package/dist/index-BA8ASHKr.d.ts +0 -31
  140. package/dist/index.d.ts +0 -14
  141. package/dist/index.js +0 -60
  142. package/dist/index.js.map +0 -1
  143. package/dist/ingestion/index.d.ts +0 -6
  144. package/dist/ingestion/index.js +0 -11
  145. package/dist/ingestion-QBGKrFr9.js +0 -200
  146. package/dist/ingestion-QBGKrFr9.js.map +0 -1
  147. package/dist/manifest/index.d.ts +0 -2
  148. package/dist/manifest-P7kBnch9.js.map +0 -1
  149. package/dist/metrics-inkCAxKq.js +0 -136
  150. package/dist/metrics-inkCAxKq.js.map +0 -1
  151. package/dist/metrics.d.ts +0 -4
  152. package/dist/metrics.js +0 -5
  153. package/dist/publisher/index.d.ts +0 -6
  154. package/dist/publisher/index.js +0 -8
  155. package/dist/publisher-BdWJOfer.js +0 -138
  156. package/dist/publisher-BdWJOfer.js.map +0 -1
  157. package/dist/runtime-config/index.d.ts +0 -4
  158. package/dist/runtime-config/index.js +0 -5
  159. package/dist/runtime-config-IbyCVpei.js +0 -60
  160. package/dist/runtime-config-IbyCVpei.js.map +0 -1
  161. package/dist/server-D-eLX3yN.js +0 -183
  162. package/dist/server-D-eLX3yN.js.map +0 -1
  163. package/dist/service-CUjGhe-7.d.ts +0 -32
  164. package/dist/service-DiP-XETo.js +0 -8
  165. package/dist/service-DiP-XETo.js.map +0 -1
  166. package/dist/state-store/index.d.ts +0 -3
  167. package/dist/state-store/index.js +0 -5
  168. package/dist/state-store-BdYulQOY.js.map +0 -1
  169. package/dist/status-BTTd7AUR.js.map +0 -1
  170. package/dist/table-bindings-Dp1DhTeS.js +0 -106
  171. package/dist/table-bindings-Dp1DhTeS.js.map +0 -1
  172. package/dist/telemetry-j_nxwYfh.js.map +0 -1
  173. package/dist/types-jVD5Ys43.d.ts +0 -174
  174. package/dist/webhook/index.d.ts +0 -7
  175. package/dist/webhook/index.js +0 -9
  176. package/dist/webhook-MhFcLW-o.js +0 -13
  177. package/dist/webhook-MhFcLW-o.js.map +0 -1
  178. package/src/publisher/console.ts +0 -18
  179. package/src/publisher/index.ts +0 -7
  180. package/src/publisher/instrumented.ts +0 -75
  181. package/src/publisher/service.ts +0 -37
  182. package/src/publisher/wings.ts +0 -204
@@ -0,0 +1,172 @@
1
+ import { Faker, base, en } from "@faker-js/faker";
2
+ import { Array as Arr, Effect, Option, Schema, Stream } from "effect";
3
+ import humanFormat from "human-format";
4
+
5
+ import type { DemoDefinition, DemoTable } from "./definition";
6
+
7
+ import { ConnectorError } from "../errors";
8
+ import { ingestBatch } from "../ingestor/instrumented";
9
+ import { Ingestor, type IngestResult } from "../ingestor/service";
10
+ import { compileRow, type DemoContext } from "./generate";
11
+
12
+ export type RunOptions = {
13
+ /** Stop once this many Arrow bytes are written. */
14
+ readonly bytes: number;
15
+ /** Omit for random data on each run. */
16
+ readonly seed?: number;
17
+ /** The time data is made around. Same seed, time and tables give the same rows. */
18
+ readonly now: Date;
19
+ /** Maximum rows per ingestion batch. Defaults to 500. Must be a positive integer. */
20
+ readonly batchSize?: number;
21
+ /** Groups per chunk. Overrides the demo's. */
22
+ readonly groupsPerChunk?: number;
23
+ };
24
+
25
+ export type DemoTotals = IngestResult & {
26
+ readonly tables: Readonly<Record<string, IngestResult>>;
27
+ };
28
+
29
+ const empty: DemoTotals = { rows: 0, bytes: 0, tables: {} };
30
+
31
+ const sum = (results: ReadonlyArray<IngestResult>): IngestResult => ({
32
+ rows: results.reduce((total, result) => total + result.rows, 0),
33
+ bytes: results.reduce((total, result) => total + result.bytes, 0),
34
+ });
35
+
36
+ const add = (totals: DemoTotals, written: ReadonlyArray<readonly [string, IngestResult]>) =>
37
+ written.reduce<DemoTotals>(
38
+ (current, [name, result]) => ({
39
+ rows: current.rows + result.rows,
40
+ bytes: current.bytes + result.bytes,
41
+ tables: {
42
+ ...current.tables,
43
+ [name]: {
44
+ rows: (current.tables[name]?.rows ?? 0) + result.rows,
45
+ bytes: (current.tables[name]?.bytes ?? 0) + result.bytes,
46
+ },
47
+ },
48
+ }),
49
+ totals,
50
+ );
51
+
52
+ /**
53
+ * Writes the setup rows, then groups, until it reaches the size or the demo's
54
+ * count. It can go over the size by one chunk. Only bound tables are
55
+ * written; keys still point to the others.
56
+ */
57
+ export const run = Effect.fnUntraced(function* (demo: DemoDefinition, options: RunOptions) {
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
+ );
65
+
66
+ const bound = (table: DemoTable) =>
67
+ ingestor.resourceNames === undefined || ingestor.resourceNames.includes(table.name);
68
+
69
+ // A fresh Faker per run keeps explicit seeds repeatable.
70
+ const faker = new Faker({ locale: [en, base] });
71
+ faker.seed(options.seed);
72
+ const context: DemoContext = { faker, now: options.now };
73
+
74
+ const prepare = (tables: ReadonlyArray<DemoTable>) =>
75
+ Effect.forEach(tables.filter(bound), (table) =>
76
+ compileRow(table.rowSchema).pipe(
77
+ Effect.map((fill) => ({
78
+ table,
79
+ fill,
80
+ decode: Schema.decodeUnknownEffect(Schema.toType(table.rowSchema)),
81
+ })),
82
+ ),
83
+ );
84
+
85
+ const write = (
86
+ tables: Effect.Success<ReturnType<typeof prepare>>,
87
+ rowsOf: (name: string) => ReadonlyArray<object>,
88
+ ) =>
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
+ ),
98
+ ),
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
+ });
120
+
121
+ const setupTables = yield* prepare(demo.setup.tables);
122
+ const groupTables = yield* prepare(demo.group.tables);
123
+
124
+ const setup = demo.setup.rows(context);
125
+ const afterSetup = add(empty, yield* write(setupTables, (name) => setup[name] ?? []));
126
+
127
+ const totals =
128
+ afterSetup.bytes >= options.bytes || groupTables.length === 0
129
+ ? afterSetup
130
+ : yield* Stream.range(0, (demo.group.count ?? Infinity) - 1).pipe(
131
+ Stream.grouped(options.groupsPerChunk ?? demo.group.groupsPerChunk ?? 1000),
132
+ Stream.mapEffect((indexes) => {
133
+ const groups = indexes.map((index) => demo.group.rows(index, context));
134
+ return write(groupTables, (name) => groups.flatMap((group) => group[name] ?? []));
135
+ }),
136
+ Stream.scan(() => afterSetup, add),
137
+ Stream.takeUntil((current) => current.bytes >= options.bytes),
138
+ Stream.tap((current) =>
139
+ Effect.logInfo("demo progress").pipe(
140
+ Effect.annotateLogs({ rows: current.rows, size: humanFormat.bytes(current.bytes) }),
141
+ ),
142
+ ),
143
+ Stream.runLast,
144
+ Effect.map(Option.getOrElse(() => afterSetup)),
145
+ );
146
+
147
+ if (totals.bytes < options.bytes) {
148
+ yield* Effect.logWarning(
149
+ "The selected tables ran out of rows, so the run wrote less than the size asked for",
150
+ ).pipe(
151
+ Effect.annotateLogs({
152
+ size: humanFormat.bytes(totals.bytes),
153
+ target: humanFormat.bytes(options.bytes),
154
+ }),
155
+ );
156
+ }
157
+
158
+ yield* Effect.logInfo("demo done").pipe(
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
+ }),
169
+ );
170
+
171
+ return totals;
172
+ });
@@ -0,0 +1,49 @@
1
+ import { Schema } from "effect";
2
+
3
+ declare module "effect/Schema" {
4
+ namespace Annotations {
5
+ interface Annotations {
6
+ readonly icebergIdentifierFields?: ReadonlyArray<string> | undefined;
7
+ }
8
+ }
9
+ }
10
+
11
+ type KnownField<S extends Schema.Top> = keyof S["Type"] & string;
12
+
13
+ /** A row field that cannot be null or undefined. */
14
+ export type RequiredField<S extends Schema.Top> = [KnownField<S>] extends [never]
15
+ ? string
16
+ : {
17
+ readonly [Field in KnownField<S>]-?: undefined extends S["Type"][Field]
18
+ ? never
19
+ : null extends S["Type"][Field]
20
+ ? never
21
+ : Field;
22
+ }[KnownField<S>];
23
+
24
+ /** A row schema whose identifier fields are `Key`. Only the type has it. */
25
+ export interface Identified<Key extends string> {
26
+ readonly "~iceberg.identifier": Key;
27
+ }
28
+
29
+ /**
30
+ * Marks the fields that identify a row. The engine upserts rows by them.
31
+ *
32
+ * Put it last. A later `.annotate()` keeps the runtime annotation, but the type
33
+ * loses the key, so `Resource.entity` stops compiling.
34
+ *
35
+ * @example
36
+ * ```ts
37
+ * const Post = Schema.Struct({
38
+ * id: Schema.String,
39
+ * updatedAt: Schema.String,
40
+ * }).pipe(Iceberg.identifier("id"));
41
+ * ```
42
+ */
43
+ export const identifier =
44
+ <S extends Schema.Top, const Key extends RequiredField<S>>(
45
+ ...keys: readonly [Key, ...ReadonlyArray<Key>]
46
+ ) =>
47
+ (schema: S): S["Rebuild"] & Identified<Key> =>
48
+ // Effect has no slot for the key in the schema type, so we add it.
49
+ schema.annotate({ icebergIdentifierFields: keys }) as S["Rebuild"] & Identified<Key>;
@@ -0,0 +1,9 @@
1
+ import { Data } from "effect";
2
+
3
+ /** A schema could not be turned into an Iceberg table. */
4
+ export class IcebergSchemaError extends Data.TaggedError("IcebergSchemaError")<{
5
+ /** Schema path, such as `$.orders.element`. */
6
+ readonly path: string;
7
+ readonly message: string;
8
+ readonly cause?: unknown;
9
+ }> {}
@@ -0,0 +1,3 @@
1
+ export { identifier } from "./annotations";
2
+ export { IcebergSchemaError } from "./error";
3
+ export { makeCreateTableRequest, type CreateTableOptions } from "./table";
@@ -0,0 +1,461 @@
1
+ import type {
2
+ CreateTableRequest,
3
+ IcebergType,
4
+ MapType,
5
+ StructField,
6
+ StructType,
7
+ TableSchema,
8
+ } from "iceberg-js";
9
+
10
+ import { Effect, Schema, type SchemaRepresentation } from "effect";
11
+
12
+ import { IcebergSchemaError } from "./error";
13
+
14
+ /** Options for creating a table. */
15
+ export type CreateTableOptions = {
16
+ readonly name: string;
17
+ /** Where the table's files go. The catalog may choose this when omitted. */
18
+ readonly location?: string | undefined;
19
+ /** Extra table properties. These win over the ones from the schema. */
20
+ readonly properties?: Readonly<Record<string, string>> | undefined;
21
+ };
22
+
23
+ type Representation = SchemaRepresentation.Representation;
24
+ type Resolved = Exclude<Representation, SchemaRepresentation.Reference>;
25
+ type Annotations = Schema.Annotations.Annotations;
26
+
27
+ type CompiledType = {
28
+ readonly type: IcebergType;
29
+ readonly nullable: boolean;
30
+ };
31
+
32
+ export type CompiledTable = {
33
+ readonly schema: TableSchema;
34
+ readonly description: string | undefined;
35
+ };
36
+
37
+ const anonymousDeclaration =
38
+ "Schemas without a name, such as Schema.Class, do not work here; use Schema.Struct";
39
+
40
+ class CompilationFailure extends Error {
41
+ readonly path: string;
42
+
43
+ constructor(path: string, message: string) {
44
+ super(message);
45
+ this.path = path;
46
+ }
47
+ }
48
+
49
+ const fail = (path: string, message: string): never => {
50
+ throw new CompilationFailure(path, message);
51
+ };
52
+
53
+ const propertyPath = (path: string, name: string): string =>
54
+ /^[A-Za-z_$][\w$]*$/.test(name) ? `${path}.${name}` : `${path}[${JSON.stringify(name)}]`;
55
+
56
+ /** Reads annotations the way Effect does: if there are checks, the last one wins. */
57
+ const resolveAnnotations = (representation: Resolved): Annotations | undefined => {
58
+ const lastCheck = representation.checks.at(-1);
59
+ return lastCheck === undefined ? representation.annotations : lastCheck.annotations;
60
+ };
61
+
62
+ const description = (annotations: Annotations | undefined): string | undefined => {
63
+ const value = annotations?.description;
64
+ return typeof value === "string" ? value : undefined;
65
+ };
66
+
67
+ /** @internal */
68
+ export const checkHasRepresentation = (
69
+ checks: ReadonlyArray<SchemaRepresentation.Check>,
70
+ id: string,
71
+ ): boolean =>
72
+ checks.some(
73
+ (check) =>
74
+ check.representation?.id === id ||
75
+ (check._tag === "FilterGroup" && checkHasRepresentation(check.checks, id)),
76
+ );
77
+
78
+ const isAnonymousDeclaration = (representation: Resolved): boolean =>
79
+ representation._tag === "Declaration" && representation.representation?.id === undefined;
80
+
81
+ const literalType = (
82
+ literal: SchemaRepresentation.Literal["literal"],
83
+ path: string,
84
+ ): IcebergType => {
85
+ switch (typeof literal) {
86
+ case "string":
87
+ return "string";
88
+ case "boolean":
89
+ return "boolean";
90
+ case "number":
91
+ return Number.isFinite(literal)
92
+ ? "double"
93
+ : fail(path, "A number literal must be a real number, not Infinity or NaN");
94
+ case "bigint":
95
+ return "long";
96
+ default:
97
+ return fail(path, `A ${typeof literal} literal does not work here`);
98
+ }
99
+ };
100
+
101
+ const identifierId = (fields: ReadonlyArray<StructField>, name: string): number => {
102
+ const path = propertyPath("$", name);
103
+ const field = fields.find((candidate) => candidate.name === name);
104
+
105
+ if (!field) return fail(path, "Iceberg.identifier names a field that does not exist");
106
+ if (!field.required) return fail(path, "An identifier field cannot be optional or null");
107
+ if (typeof field.type !== "string" || field.type === "float" || field.type === "double") {
108
+ return fail(path, "An identifier field must be a primitive type other than float or double");
109
+ }
110
+ return field.id;
111
+ };
112
+
113
+ const assertNever = (_representation: never, path: string): never =>
114
+ fail(path, "This kind of schema does not work here");
115
+
116
+ class Compiler {
117
+ readonly #activeObjects = new Set<SchemaRepresentation.Objects>();
118
+ readonly #activeReferences = new Set<string>();
119
+ readonly #activeSuspends = new Set<SchemaRepresentation.Suspend>();
120
+ // The REST request needs IDs. Only the catalog's returned IDs are authoritative.
121
+ #nextId = 0;
122
+
123
+ constructor(private readonly references: SchemaRepresentation.References) {}
124
+
125
+ compile(root: Representation): CompiledTable {
126
+ return this.#withReference(root, "$", (representation) => {
127
+ if (isAnonymousDeclaration(representation)) return fail("$", anonymousDeclaration);
128
+ if (representation._tag !== "Objects") {
129
+ return fail("$", "The top level of a table must be a Schema.Struct");
130
+ }
131
+
132
+ const annotations = resolveAnnotations(representation);
133
+ const fields = this.#compileFields(representation, "$");
134
+ const identifierIds = (annotations?.icebergIdentifierFields ?? []).map((name) =>
135
+ identifierId(fields, name),
136
+ );
137
+
138
+ return {
139
+ schema:
140
+ identifierIds.length === 0
141
+ ? { type: "struct", fields }
142
+ : {
143
+ type: "struct",
144
+ // Deletes only send the key and version, so other columns must be nullable.
145
+ fields: fields.map((field) => ({
146
+ ...field,
147
+ required: identifierIds.includes(field.id),
148
+ })),
149
+ "identifier-field-ids": identifierIds,
150
+ },
151
+ description: description(annotations),
152
+ };
153
+ });
154
+ }
155
+
156
+ #withReference<A>(
157
+ representation: Representation,
158
+ path: string,
159
+ compile: (resolved: Resolved) => A,
160
+ ): A {
161
+ if (representation._tag !== "Reference") return compile(representation);
162
+
163
+ const reference = representation.$ref;
164
+ const resolved = this.references[reference];
165
+ if (resolved === undefined) return fail(path, `Cannot find the schema named ${reference}`);
166
+ if (this.#activeReferences.has(reference)) {
167
+ return fail(path, "Iceberg cannot store a schema that contains itself");
168
+ }
169
+
170
+ this.#activeReferences.add(reference);
171
+ try {
172
+ return this.#withReference(resolved, path, compile);
173
+ } finally {
174
+ this.#activeReferences.delete(reference);
175
+ }
176
+ }
177
+
178
+ #checkRecursion(representation: Representation, path: string): void {
179
+ if (representation._tag === "Reference" && this.#activeReferences.has(representation.$ref)) {
180
+ fail(path, "Iceberg cannot store a schema that contains itself");
181
+ }
182
+ }
183
+
184
+ #compileFields(representation: SchemaRepresentation.Objects, path: string): Array<StructField> {
185
+ if (representation.indexSignatures.length > 0) {
186
+ return fail(
187
+ path,
188
+ "Schema.Record does not work here; name the keys with Schema.Struct, or store it as a JSON string",
189
+ );
190
+ }
191
+
192
+ return representation.propertySignatures.map((property) => {
193
+ if (typeof property.name !== "string") {
194
+ return fail(
195
+ propertyPath(path, String(property.name)),
196
+ "Iceberg field names must be strings",
197
+ );
198
+ }
199
+
200
+ const fieldPath = propertyPath(path, property.name);
201
+ this.#checkRecursion(property.type, fieldPath);
202
+ const id = ++this.#nextId;
203
+ const compiled = this.#compileType(property.type, fieldPath);
204
+ const fieldDescription = description(property.annotations);
205
+
206
+ return {
207
+ id,
208
+ name: property.name,
209
+ type: compiled.type,
210
+ required: !property.isOptional && !compiled.nullable,
211
+ ...(fieldDescription === undefined ? {} : { doc: fieldDescription }),
212
+ };
213
+ });
214
+ }
215
+
216
+ #compileType(representation: Representation, path: string): CompiledType {
217
+ if (representation._tag === "Reference") {
218
+ return this.#withReference(representation, path, (resolved) =>
219
+ this.#compileType(resolved, path),
220
+ );
221
+ }
222
+
223
+ switch (representation._tag) {
224
+ case "String":
225
+ return { type: "string", nullable: false };
226
+ case "Boolean":
227
+ return { type: "boolean", nullable: false };
228
+ case "BigInt":
229
+ return { type: "long", nullable: false };
230
+ case "Number":
231
+ // Schema.Int can exceed Iceberg int. A double holds every safe integer
232
+ // exactly; a long would need bigint rows.
233
+ return checkHasRepresentation(representation.checks, "effect/schema/isFinite") ||
234
+ checkHasRepresentation(representation.checks, "effect/schema/isInt")
235
+ ? { type: "double", nullable: false }
236
+ : fail(path, "Plain Schema.Number does not work here; use Schema.Finite or Schema.Int");
237
+ case "Literal":
238
+ return { type: literalType(representation.literal, path), nullable: false };
239
+ case "Objects": {
240
+ if (this.#activeObjects.has(representation)) {
241
+ return fail(path, "Iceberg cannot store a schema that contains itself");
242
+ }
243
+
244
+ this.#activeObjects.add(representation);
245
+ try {
246
+ return {
247
+ type: {
248
+ type: "struct",
249
+ fields: this.#compileFields(representation, path),
250
+ } satisfies StructType,
251
+ nullable: false,
252
+ };
253
+ } finally {
254
+ this.#activeObjects.delete(representation);
255
+ }
256
+ }
257
+ case "Arrays":
258
+ return this.#compileArray(representation, path);
259
+ case "Declaration":
260
+ return this.#compileDeclaration(representation, path);
261
+ case "Union":
262
+ return this.#compileUnion(representation, path);
263
+ case "Suspend": {
264
+ if (this.#activeSuspends.has(representation)) {
265
+ return fail(path, "Iceberg cannot store a schema that contains itself");
266
+ }
267
+
268
+ this.#activeSuspends.add(representation);
269
+ try {
270
+ return this.#compileType(representation.thunk, path);
271
+ } finally {
272
+ this.#activeSuspends.delete(representation);
273
+ }
274
+ }
275
+ case "Null":
276
+ case "Undefined":
277
+ return fail(path, "A field cannot contain only null or undefined");
278
+ case "Any":
279
+ case "Unknown":
280
+ case "ObjectKeyword":
281
+ case "Symbol":
282
+ case "UniqueSymbol":
283
+ case "Void":
284
+ case "Never":
285
+ case "Enum":
286
+ case "TemplateLiteral":
287
+ return fail(path, `A ${representation._tag} schema does not work here`);
288
+ default:
289
+ return assertNever(representation, path);
290
+ }
291
+ }
292
+
293
+ #compileArray(representation: SchemaRepresentation.Arrays, path: string): CompiledType {
294
+ if (representation.elements.length > 0 || representation.rest.length !== 1) {
295
+ return fail(
296
+ path,
297
+ "A tuple does not work here; use a Schema.Array where every item has the same type",
298
+ );
299
+ }
300
+
301
+ const elementPath = `${path}.element`;
302
+ const elementRepresentation = representation.rest[0]!;
303
+ const elementId = ++this.#nextId;
304
+ const compiled = this.#compileType(elementRepresentation, elementPath);
305
+
306
+ return {
307
+ type: {
308
+ type: "list",
309
+ "element-id": elementId,
310
+ element: compiled.type,
311
+ "element-required": !compiled.nullable,
312
+ },
313
+ nullable: false,
314
+ };
315
+ }
316
+
317
+ #compileDeclaration(
318
+ representation: SchemaRepresentation.Declaration,
319
+ path: string,
320
+ ): CompiledType {
321
+ const id = representation.representation?.id;
322
+
323
+ switch (id) {
324
+ case "effect/schema/Date":
325
+ return { type: "timestamptz", nullable: false };
326
+ case "effect/schema/Uint8Array":
327
+ return { type: "binary", nullable: false };
328
+ case "effect/schema/ReadonlyMap":
329
+ return this.#compileMap(representation, path);
330
+ case undefined:
331
+ return fail(path, anonymousDeclaration);
332
+ default:
333
+ return fail(path, `The ${id} schema does not work here`);
334
+ }
335
+ }
336
+
337
+ #compileMap(representation: SchemaRepresentation.Declaration, path: string): CompiledType {
338
+ if (representation.typeParameters.length !== 2) {
339
+ return fail(path, "ReadonlyMap needs one key schema and one value schema");
340
+ }
341
+
342
+ const keyPath = `${path}.key`;
343
+ const valuePath = `${path}.value`;
344
+ const keyRepresentation = representation.typeParameters[0]!;
345
+ const valueRepresentation = representation.typeParameters[1]!;
346
+ const keyId = ++this.#nextId;
347
+ const compiledKey = this.#compileType(keyRepresentation, keyPath);
348
+
349
+ if (compiledKey.nullable) return fail(keyPath, "Map keys cannot be null");
350
+
351
+ const valueId = ++this.#nextId;
352
+ const compiledValue = this.#compileType(valueRepresentation, valuePath);
353
+
354
+ return {
355
+ type: {
356
+ type: "map",
357
+ "key-id": keyId,
358
+ key: compiledKey.type,
359
+ "value-id": valueId,
360
+ value: compiledValue.type,
361
+ "value-required": !compiledValue.nullable,
362
+ } satisfies MapType,
363
+ nullable: false,
364
+ };
365
+ }
366
+
367
+ #compileUnion(representation: SchemaRepresentation.Union, path: string): CompiledType {
368
+ let nullable = false;
369
+ const concrete: Array<Representation> = [];
370
+
371
+ for (const member of representation.types) {
372
+ if (member._tag === "Null" || member._tag === "Undefined") {
373
+ nullable = true;
374
+ } else {
375
+ concrete.push(member);
376
+ }
377
+ }
378
+
379
+ if (concrete.length === 0) return fail(path, "A field cannot contain only null or undefined");
380
+
381
+ if (concrete.length === 1) {
382
+ const compiled = this.#compileType(concrete[0]!, path);
383
+ return { type: compiled.type, nullable: nullable || compiled.nullable };
384
+ }
385
+
386
+ if (!concrete.every((member) => member._tag === "Literal")) {
387
+ return fail(path, "A union only works when every member is a literal");
388
+ }
389
+
390
+ const first = literalType(concrete[0]!.literal, path);
391
+ for (const member of concrete.slice(1)) {
392
+ if (literalType(member.literal, path) !== first) {
393
+ return fail(path, "Every literal in a union must be the same type");
394
+ }
395
+ }
396
+
397
+ return { type: first, nullable };
398
+ }
399
+ }
400
+
401
+ export const compileTable = (
402
+ schema: Schema.Top,
403
+ ): Effect.Effect<CompiledTable, IcebergSchemaError> =>
404
+ Effect.try({
405
+ try: () => {
406
+ const document = Schema.toRepresentation(Schema.toType(schema));
407
+ return new Compiler(document.references).compile(document.representation);
408
+ },
409
+ catch: (cause) =>
410
+ cause instanceof CompilationFailure
411
+ ? new IcebergSchemaError({ path: cause.path, message: cause.message, cause })
412
+ : new IcebergSchemaError({
413
+ path: "$",
414
+ message: cause instanceof Error ? cause.message : String(cause),
415
+ cause,
416
+ }),
417
+ });
418
+
419
+ export const makeCreateRequest = (
420
+ table: CompiledTable,
421
+ options: CreateTableOptions,
422
+ ): CreateTableRequest => ({
423
+ name: options.name,
424
+ schema: table.schema,
425
+ ...(options.location === undefined ? {} : { location: options.location }),
426
+ properties: {
427
+ ...(table.description === undefined ? {} : { comment: table.description }),
428
+ ...options.properties,
429
+ "format-version": "3",
430
+ },
431
+ });
432
+
433
+ /**
434
+ * Builds a REST create-table request from an Effect schema.
435
+ * The catalog assigns the stored field IDs.
436
+ *
437
+ * A root `description` becomes the table `comment`, and a field `description`
438
+ * becomes its `doc`. `title` and `examples` are dropped.
439
+ *
440
+ * `Iceberg.identifier` sets the `identifier-field-ids`, and makes every other
441
+ * top-level column nullable.
442
+ *
443
+ * @example
444
+ * ```ts
445
+ * const Row = Schema.Struct({
446
+ * id: Schema.String.pipe(Schema.annotateKey({ description: "Row identifier" })),
447
+ * })
448
+ * .annotate({ description: "Connector rows" })
449
+ * .pipe(Iceberg.identifier("id"))
450
+ *
451
+ * const request = yield* Iceberg.makeCreateTableRequest(Row, {
452
+ * name: "rows",
453
+ * location: "s3://warehouse/rows"
454
+ * })
455
+ * ```
456
+ */
457
+ export const makeCreateTableRequest = (
458
+ schema: Schema.Top,
459
+ options: CreateTableOptions,
460
+ ): Effect.Effect<CreateTableRequest, IcebergSchemaError> =>
461
+ Effect.map(compileTable(schema), (table) => makeCreateRequest(table, options));