@useairfoil/connector-kit 0.4.0 → 0.5.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.
- package/METRICS.md +78 -0
- package/README.md +450 -88
- package/dist/auth/index.d.ts +2 -0
- package/dist/auth/index.js +3 -0
- package/dist/auth-DvU_CZ7C.js +42 -0
- package/dist/auth-DvU_CZ7C.js.map +1 -0
- package/dist/chunk-Bp6m_JJh.js +13 -0
- package/dist/connector-app/index.d.ts +14 -0
- package/dist/connector-app/index.js +11 -0
- package/dist/connector-app-d7Z-SZLQ.js +64 -0
- package/dist/connector-app-d7Z-SZLQ.js.map +1 -0
- package/dist/constants-BRhIX_rz.js +27 -0
- package/dist/constants-BRhIX_rz.js.map +1 -0
- package/dist/errors/index.d.ts +2 -0
- package/dist/errors/index.js +3 -0
- package/dist/errors-DHWylqRZ.js +8 -0
- package/dist/errors-DHWylqRZ.js.map +1 -0
- package/dist/index-Byr0Lf0C.d.ts +25 -0
- package/dist/index-CBT-JX-T.d.ts +173 -0
- package/dist/index-CTHVudvQ.d.ts +41 -0
- package/dist/index-CTLYQ74P.d.ts +80 -0
- package/dist/index-Coh_SkBs.d.ts +95 -0
- package/dist/index-D54ySAKn.d.ts +14 -0
- package/dist/index-DtJX_tic.d.ts +153 -0
- package/dist/index-J42AZaXU.d.ts +38 -0
- package/dist/index-fIIBVAai.d.ts +25 -0
- package/dist/index-o1-Z3n7V.d.ts +55 -0
- package/dist/index.d.ts +14 -165
- package/dist/index.js +50 -228
- package/dist/index.js.map +1 -1
- package/dist/ingestion/index.d.ts +6 -0
- package/dist/ingestion/index.js +10 -0
- package/dist/ingestion-BQo4cbGw.js +200 -0
- package/dist/ingestion-BQo4cbGw.js.map +1 -0
- package/dist/manifest/index.d.ts +2 -0
- package/dist/manifest/index.js +4 -0
- package/dist/manifest-ByE6YPG0.js +221 -0
- package/dist/manifest-ByE6YPG0.js.map +1 -0
- package/dist/metrics-CVAk1ALR.d.ts +53 -0
- package/dist/metrics-CWPy2Aqc.js +96 -0
- package/dist/metrics-CWPy2Aqc.js.map +1 -0
- package/dist/metrics.d.ts +4 -0
- package/dist/metrics.js +3 -0
- package/dist/publisher/index.d.ts +5 -0
- package/dist/publisher/index.js +6 -0
- package/dist/publisher-BYT3RLVO.js +131 -0
- package/dist/publisher-BYT3RLVO.js.map +1 -0
- package/dist/runtime-config/index.d.ts +2 -0
- package/dist/runtime-config/index.js +4 -0
- package/dist/runtime-config-BpB-d_bF.js +51 -0
- package/dist/runtime-config-BpB-d_bF.js.map +1 -0
- package/dist/server-CXTQ7odl.js +183 -0
- package/dist/server-CXTQ7odl.js.map +1 -0
- package/dist/service-DuFiQwid.js +191 -0
- package/dist/service-DuFiQwid.js.map +1 -0
- package/dist/service-IE5qaf9x.d.ts +32 -0
- package/dist/state-store/index.d.ts +3 -0
- package/dist/state-store/index.js +5 -0
- package/dist/state-store-yl6LiTQ0.js +198 -0
- package/dist/state-store-yl6LiTQ0.js.map +1 -0
- package/dist/status/index.d.ts +5 -0
- package/dist/status/index.js +6 -0
- package/dist/status-B-oZBc33.js +84 -0
- package/dist/status-B-oZBc33.js.map +1 -0
- package/dist/types-B35J5YPu.d.ts +174 -0
- package/dist/webhook/index.d.ts +6 -0
- package/dist/webhook/index.js +8 -0
- package/dist/webhook-DI-Afxkg.js +13 -0
- package/dist/webhook-DI-Afxkg.js.map +1 -0
- package/package.json +66 -8
- package/src/auth/index.ts +1 -0
- package/src/auth/token-cache.ts +54 -0
- package/src/connector-app/check.ts +76 -0
- package/src/connector-app/index.ts +2 -0
- package/src/connector-app/start.ts +42 -0
- package/src/core/builder.ts +81 -9
- package/src/core/index.ts +22 -0
- package/src/core/types.ts +204 -76
- package/src/errors/index.ts +1 -0
- package/src/index.ts +13 -28
- package/src/ingestion/engine.ts +409 -174
- package/src/ingestion/index.ts +1 -0
- package/src/manifest/config.ts +232 -0
- package/src/manifest/index.ts +14 -0
- package/src/manifest/manifest.ts +31 -0
- package/src/manifest/number-schema.ts +17 -0
- package/src/manifest/schema.ts +160 -0
- package/src/metrics.ts +128 -0
- package/src/publisher/console.ts +18 -0
- package/src/publisher/index.ts +7 -0
- package/src/publisher/instrumented.ts +68 -0
- package/src/publisher/service.ts +30 -13
- package/src/publisher/wings.ts +159 -72
- package/src/runtime/context.ts +15 -0
- package/src/runtime-config/constants.ts +27 -0
- package/src/runtime-config/error.ts +13 -0
- package/src/runtime-config/index.ts +3 -0
- package/src/runtime-config/layer.ts +56 -0
- package/src/state-store/index.ts +3 -0
- package/src/state-store/keys.ts +22 -0
- package/src/state-store/layer-key-value-store.ts +132 -0
- package/src/state-store/layer-memory.ts +11 -0
- package/src/state-store/layer-sql.ts +50 -0
- package/src/state-store/schema.ts +57 -0
- package/src/state-store/service.ts +51 -0
- package/src/state-store/state.ts +17 -0
- package/src/status/index.ts +2 -0
- package/src/status/route.ts +77 -0
- package/src/status/types.ts +33 -0
- package/src/telemetry.ts +288 -0
- package/src/webhook/index.ts +8 -0
- package/src/webhook/server.ts +209 -39
- package/src/webhook/types.ts +6 -17
- package/src/ingestion/state-store.ts +0 -36
- package/src/streams/pull-stream.ts +0 -54
- package/src/streams/webhook-queue.ts +0 -14
- /package/src/{core/errors.ts → errors/connector-error.ts} +0 -0
package/src/publisher/service.ts
CHANGED
|
@@ -1,20 +1,37 @@
|
|
|
1
|
+
import type * as Wings from "@useairfoil/wings";
|
|
1
2
|
import type * as Effect from "effect/Effect";
|
|
2
3
|
|
|
3
4
|
import { Context } from "effect";
|
|
4
5
|
|
|
5
|
-
import type {
|
|
6
|
-
import type {
|
|
6
|
+
import type { ResourceBatch } from "../core/types";
|
|
7
|
+
import type { ConnectorError } from "../errors";
|
|
7
8
|
|
|
8
|
-
export type
|
|
9
|
-
|
|
9
|
+
export type PublishSource = "backfill" | "changes" | "webhook";
|
|
10
|
+
|
|
11
|
+
export type PublishAck =
|
|
12
|
+
| {
|
|
13
|
+
readonly status: "accepted";
|
|
14
|
+
readonly resource: string;
|
|
15
|
+
readonly partition?: Wings.PartitionValue.PartitionValue;
|
|
16
|
+
}
|
|
17
|
+
| {
|
|
18
|
+
readonly status: "rejected";
|
|
19
|
+
readonly resource: string;
|
|
20
|
+
readonly reason: string;
|
|
21
|
+
readonly rejectedRows?: number;
|
|
22
|
+
readonly partition?: Wings.PartitionValue.PartitionValue;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export type PublishOptions = {
|
|
26
|
+
readonly resource: string;
|
|
27
|
+
readonly source: PublishSource;
|
|
28
|
+
readonly batch: ResourceBatch;
|
|
10
29
|
};
|
|
11
30
|
|
|
12
|
-
export
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
}
|
|
20
|
-
>()("Publisher") {}
|
|
31
|
+
export interface PublisherService {
|
|
32
|
+
readonly publish: (options: PublishOptions) => Effect.Effect<PublishAck, ConnectorError>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export class Publisher extends Context.Service<Publisher, PublisherService>()(
|
|
36
|
+
"@useairfoil/connector-kit/Publisher",
|
|
37
|
+
) {}
|
package/src/publisher/wings.ts
CHANGED
|
@@ -1,117 +1,204 @@
|
|
|
1
|
-
import type { PartitionValue } from "@useairfoil/wings";
|
|
2
|
-
|
|
3
1
|
import * as Wings from "@useairfoil/wings";
|
|
4
2
|
import { Effect, Layer } from "effect";
|
|
5
3
|
|
|
6
|
-
import type { ConnectorDefinition } from "../core/types";
|
|
4
|
+
import type { ConnectorDefinition, ResourceDefinition } from "../core/types";
|
|
7
5
|
|
|
8
|
-
import { ConnectorError } from "../
|
|
9
|
-
import {
|
|
6
|
+
import { ConnectorError } from "../errors";
|
|
7
|
+
import { SpanName } from "../telemetry";
|
|
8
|
+
import { Publisher, type PublishAck, type PublisherService } from "./service";
|
|
10
9
|
|
|
11
10
|
type Rows = Record<string, unknown>;
|
|
12
11
|
|
|
12
|
+
export type WingsTableMapping = {
|
|
13
|
+
readonly name: string;
|
|
14
|
+
readonly partitionValue?: Wings.PartitionValue.PartitionValue;
|
|
15
|
+
};
|
|
16
|
+
|
|
13
17
|
export type WingsPublisherConfig = {
|
|
14
18
|
readonly connector: ConnectorDefinition;
|
|
15
|
-
|
|
16
|
-
readonly topics: Record<string, Wings.Cluster.Topic.Topic>;
|
|
17
|
-
/** per-stream partition value (key is entity/event name). */
|
|
18
|
-
readonly partitionValues?: Record<string, PartitionValue>;
|
|
19
|
+
readonly tables: Record<string, string | WingsTableMapping>;
|
|
19
20
|
};
|
|
20
21
|
|
|
21
|
-
/** Publisher entry for a single entity/event. */
|
|
22
22
|
type PublisherEntry = {
|
|
23
|
-
|
|
23
|
+
readonly resource: ResourceDefinition;
|
|
24
|
+
readonly table: Wings.Cluster.Table.Table;
|
|
24
25
|
readonly publisher: Wings.WingsClient.Publisher;
|
|
25
|
-
|
|
26
|
-
readonly
|
|
27
|
-
|
|
28
|
-
readonly partitionValue?: PartitionValue;
|
|
26
|
+
readonly partitionValue?: Wings.PartitionValue.PartitionValue;
|
|
27
|
+
readonly keyField: Wings.Cluster.ArrowType.Field;
|
|
28
|
+
readonly versionField: Wings.Cluster.ArrowType.Field;
|
|
29
29
|
};
|
|
30
30
|
|
|
31
|
+
const normalizeMapping = (mapping: string | WingsTableMapping): WingsTableMapping =>
|
|
32
|
+
typeof mapping === "string" ? { name: mapping } : mapping;
|
|
33
|
+
|
|
31
34
|
const buildRecordBatch = (rows: ReadonlyArray<Rows>) => {
|
|
32
|
-
|
|
33
|
-
const table = Wings.tableFromJSON(Array.from(rows));
|
|
35
|
+
const table = Wings.Arrow.tableFromJSON(Array.from(rows));
|
|
34
36
|
const [batch] = table.batches;
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
return batch;
|
|
37
|
+
return batch
|
|
38
|
+
? Effect.succeed(batch)
|
|
39
|
+
: Effect.fail(new ConnectorError({ message: "No rows to publish" }));
|
|
41
40
|
};
|
|
42
41
|
|
|
43
|
-
|
|
42
|
+
const validateTableMapping = (options: {
|
|
43
|
+
readonly resource: ResourceDefinition;
|
|
44
|
+
readonly table: Wings.Cluster.Table.Table;
|
|
45
|
+
readonly partitionValue?: Wings.PartitionValue.PartitionValue;
|
|
46
|
+
}) =>
|
|
47
|
+
Effect.try({
|
|
48
|
+
try: () => {
|
|
49
|
+
const keyField = Wings.TableUtils.getKeyField(options.table);
|
|
50
|
+
const versionField = Wings.TableUtils.getVersionField(options.table);
|
|
51
|
+
const partitionField = Wings.TableUtils.getPartitionField(options.table);
|
|
52
|
+
|
|
53
|
+
if (keyField.name !== options.resource.key) {
|
|
54
|
+
throw new Error(
|
|
55
|
+
`Resource ${options.resource.name} key '${options.resource.key}' does not match Wings table key '${keyField.name}'`,
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
if (versionField.name !== options.resource.version) {
|
|
59
|
+
throw new Error(
|
|
60
|
+
`Resource ${options.resource.name} version '${options.resource.version}' does not match Wings table version '${versionField.name}'`,
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
if (options.resource.partition?.required === true && !options.partitionValue) {
|
|
64
|
+
throw new Error(`Resource ${options.resource.name} requires a partition value`);
|
|
65
|
+
}
|
|
66
|
+
if (partitionField && !options.partitionValue) {
|
|
67
|
+
throw new Error(
|
|
68
|
+
`Wings table ${options.table.name} is partitioned but no partition value was provided`,
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
if (!partitionField && options.partitionValue) {
|
|
72
|
+
throw new Error(
|
|
73
|
+
`Wings table ${options.table.name} is not partitioned but a partition value was provided`,
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return { keyField, versionField };
|
|
78
|
+
},
|
|
79
|
+
catch: (cause) =>
|
|
80
|
+
new ConnectorError({
|
|
81
|
+
message: `Invalid Wings table mapping for ${options.resource.name}`,
|
|
82
|
+
cause,
|
|
83
|
+
}),
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
const accepted = (entry: PublisherEntry): PublishAck => ({
|
|
87
|
+
status: "accepted",
|
|
88
|
+
resource: entry.resource.name,
|
|
89
|
+
partition: entry.partitionValue,
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
const rejected = (entry: PublisherEntry, reason: string, rejectedRows: number): PublishAck => ({
|
|
93
|
+
status: "rejected",
|
|
94
|
+
resource: entry.resource.name,
|
|
95
|
+
reason,
|
|
96
|
+
rejectedRows,
|
|
97
|
+
partition: entry.partitionValue,
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
export const layerWings = (
|
|
44
101
|
config: WingsPublisherConfig,
|
|
45
102
|
): Layer.Layer<Publisher, ConnectorError, Wings.WingsClient.WingsClient> =>
|
|
46
103
|
Layer.effect(Publisher)(
|
|
47
104
|
Effect.gen(function* () {
|
|
48
105
|
const entries = new Map<string, PublisherEntry>();
|
|
106
|
+
const clusterClient = yield* Wings.WingsClient.clusterClient;
|
|
49
107
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
if (!topic) {
|
|
108
|
+
for (const resource of config.connector.resources) {
|
|
109
|
+
const mappingInput = config.tables[resource.name];
|
|
110
|
+
if (!mappingInput) {
|
|
54
111
|
return yield* Effect.fail(
|
|
55
|
-
new ConnectorError({ message: `Missing
|
|
112
|
+
new ConnectorError({ message: `Missing table for ${resource.name}` }),
|
|
56
113
|
);
|
|
57
114
|
}
|
|
58
115
|
|
|
59
|
-
const
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
116
|
+
const mapping = normalizeMapping(mappingInput);
|
|
117
|
+
const table = yield* clusterClient.getTable({ name: mapping.name }).pipe(
|
|
118
|
+
Effect.mapError(
|
|
119
|
+
(error) =>
|
|
120
|
+
new ConnectorError({
|
|
121
|
+
message: `Failed to resolve Wings table for ${resource.name}`,
|
|
122
|
+
cause: error,
|
|
123
|
+
}),
|
|
124
|
+
),
|
|
125
|
+
);
|
|
126
|
+
const { keyField, versionField } = yield* validateTableMapping({
|
|
127
|
+
resource,
|
|
128
|
+
table,
|
|
129
|
+
partitionValue: mapping.partitionValue,
|
|
130
|
+
});
|
|
68
131
|
|
|
69
132
|
const publisher = yield* Wings.WingsClient.publisher({
|
|
70
|
-
|
|
71
|
-
partitionValue:
|
|
133
|
+
table,
|
|
134
|
+
partitionValue: mapping.partitionValue,
|
|
72
135
|
}).pipe(
|
|
73
136
|
Effect.mapError((error) => new ConnectorError({ message: error.message, cause: error })),
|
|
74
137
|
);
|
|
75
138
|
|
|
76
|
-
entries.set(
|
|
139
|
+
entries.set(resource.name, {
|
|
140
|
+
resource,
|
|
141
|
+
table,
|
|
77
142
|
publisher,
|
|
78
|
-
|
|
79
|
-
|
|
143
|
+
partitionValue: mapping.partitionValue,
|
|
144
|
+
keyField,
|
|
145
|
+
versionField,
|
|
80
146
|
});
|
|
81
147
|
}
|
|
82
148
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
149
|
+
const pushRows = (
|
|
150
|
+
entry: PublisherEntry,
|
|
151
|
+
operation: "upsert" | "delete",
|
|
152
|
+
rows: ReadonlyArray<Rows>,
|
|
153
|
+
) =>
|
|
154
|
+
Effect.gen(function* () {
|
|
155
|
+
if (rows.length === 0) return accepted(entry);
|
|
156
|
+
const batch = yield* buildRecordBatch(rows);
|
|
157
|
+
return yield* entry.publisher
|
|
158
|
+
.push({ operation, batch, partitionValue: entry.partitionValue })
|
|
159
|
+
.pipe(
|
|
160
|
+
Effect.matchEffect({
|
|
161
|
+
onFailure: (error) => Effect.succeed(rejected(entry, error.message, rows.length)),
|
|
162
|
+
onSuccess: () => Effect.succeed(accepted(entry)),
|
|
163
|
+
}),
|
|
164
|
+
);
|
|
165
|
+
});
|
|
90
166
|
|
|
91
|
-
|
|
92
|
-
|
|
167
|
+
const service: PublisherService = {
|
|
168
|
+
publish: Effect.fn(SpanName.publish, { kind: "producer" })(function* ({
|
|
169
|
+
resource,
|
|
170
|
+
source: _source,
|
|
171
|
+
batch,
|
|
172
|
+
}) {
|
|
173
|
+
const entry = entries.get(resource);
|
|
174
|
+
if (!entry) {
|
|
175
|
+
return yield* Effect.fail(
|
|
176
|
+
new ConnectorError({ message: `Unknown resource ${resource}` }),
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (batch.mutations.length === 0) return accepted(entry);
|
|
181
|
+
|
|
182
|
+
const upserts: Rows[] = [];
|
|
183
|
+
const deletes: Rows[] = [];
|
|
184
|
+
for (const mutation of batch.mutations) {
|
|
185
|
+
if (mutation.op === "upsert") {
|
|
186
|
+
upserts.push(mutation.row as Rows);
|
|
187
|
+
} else {
|
|
188
|
+
deletes.push({
|
|
189
|
+
[entry.keyField.name]: mutation.key,
|
|
190
|
+
[entry.versionField.name]: mutation.version,
|
|
191
|
+
});
|
|
93
192
|
}
|
|
193
|
+
}
|
|
94
194
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
})
|
|
101
|
-
.pipe(
|
|
102
|
-
Effect.mapError(
|
|
103
|
-
(error) =>
|
|
104
|
-
new ConnectorError({
|
|
105
|
-
message: error.message,
|
|
106
|
-
cause: error,
|
|
107
|
-
}),
|
|
108
|
-
),
|
|
109
|
-
);
|
|
110
|
-
|
|
111
|
-
return {
|
|
112
|
-
success: !!(result.result && result.result.$case === "accepted"),
|
|
113
|
-
};
|
|
114
|
-
}),
|
|
195
|
+
const upsertAck = yield* pushRows(entry, "upsert", upserts);
|
|
196
|
+
if (upsertAck.status === "rejected") return upsertAck;
|
|
197
|
+
|
|
198
|
+
return yield* pushRows(entry, "delete", deletes);
|
|
199
|
+
}),
|
|
115
200
|
};
|
|
201
|
+
|
|
202
|
+
return Publisher.of(service);
|
|
116
203
|
}),
|
|
117
204
|
);
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { Context, Layer } from "effect";
|
|
2
|
+
|
|
3
|
+
import type { ConnectorDefinition } from "../core/types";
|
|
4
|
+
|
|
5
|
+
export type ConnectorRuntimeContextValue = {
|
|
6
|
+
readonly connector: ConnectorDefinition;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export class ConnectorRuntimeContext extends Context.Service<
|
|
10
|
+
ConnectorRuntimeContext,
|
|
11
|
+
ConnectorRuntimeContextValue
|
|
12
|
+
>()("@useairfoil/connector-kit/ConnectorRuntimeContext") {}
|
|
13
|
+
|
|
14
|
+
export const layer = (connector: ConnectorDefinition) =>
|
|
15
|
+
Layer.succeed(ConnectorRuntimeContext)(ConnectorRuntimeContext.of({ connector }));
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { HashSet } from "effect";
|
|
2
|
+
|
|
3
|
+
/** Shared runtime keys owned by Airfoil rather than an individual connector. */
|
|
4
|
+
export const PlatformRuntimeKey = {
|
|
5
|
+
configPath: "AIRFOIL_CONFIG_PATH",
|
|
6
|
+
connectorInstanceId: "AIRFOIL_CONNECTOR_INSTANCE_ID",
|
|
7
|
+
stateTable: "AIRFOIL_STATE_TABLE",
|
|
8
|
+
postgresConnectionString: "POSTGRES_CONNECTION_STRING",
|
|
9
|
+
wingsHost: "WINGS_HOST",
|
|
10
|
+
wingsNamespace: "WINGS_NAMESPACE",
|
|
11
|
+
otelEnabled: "OTEL_ENABLED",
|
|
12
|
+
otelExporterOtlpEndpoint: "OTEL_EXPORTER_OTLP_ENDPOINT",
|
|
13
|
+
otelExporterOtlpHeaders: "OTEL_EXPORTER_OTLP_HEADERS",
|
|
14
|
+
otelServiceName: "OTEL_SERVICE_NAME",
|
|
15
|
+
otelServiceVersion: "OTEL_SERVICE_VERSION",
|
|
16
|
+
otelResourceAttributes: "OTEL_RESOURCE_ATTRIBUTES",
|
|
17
|
+
} as const;
|
|
18
|
+
|
|
19
|
+
/** Defaults for optional platform-owned runtime configuration. */
|
|
20
|
+
export const PlatformRuntimeDefault = {
|
|
21
|
+
stateTable: "_airfoil_connectors_state",
|
|
22
|
+
} as const;
|
|
23
|
+
|
|
24
|
+
const platformRuntimeKeys = HashSet.fromIterable(Object.values(PlatformRuntimeKey));
|
|
25
|
+
|
|
26
|
+
/** @internal */
|
|
27
|
+
export const isPlatformRuntimeKey = (key: string): boolean => HashSet.has(platformRuntimeKeys, key);
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { Data } from "effect";
|
|
2
|
+
|
|
3
|
+
export type RuntimeConfigErrorCode =
|
|
4
|
+
| "CONFIG_PATH_MISSING"
|
|
5
|
+
| "CONFIG_FILE_UNREADABLE"
|
|
6
|
+
| "CONFIG_JSON_INVALID";
|
|
7
|
+
|
|
8
|
+
/** Expected hosted-config failure with a stable code and diagnostic cause. */
|
|
9
|
+
export class RuntimeConfigError extends Data.TaggedError("RuntimeConfigError")<{
|
|
10
|
+
readonly code: RuntimeConfigErrorCode;
|
|
11
|
+
readonly message: string;
|
|
12
|
+
readonly cause: Error;
|
|
13
|
+
}> {}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { Config, ConfigProvider, Effect, FileSystem, Schema } from "effect";
|
|
2
|
+
|
|
3
|
+
import { PlatformRuntimeKey } from "./constants";
|
|
4
|
+
import { RuntimeConfigError } from "./error";
|
|
5
|
+
|
|
6
|
+
const providerFromFile: Effect.Effect<
|
|
7
|
+
ConfigProvider.ConfigProvider,
|
|
8
|
+
RuntimeConfigError,
|
|
9
|
+
FileSystem.FileSystem
|
|
10
|
+
> = Effect.gen(function* () {
|
|
11
|
+
const configPath = yield* Config.nonEmptyString(PlatformRuntimeKey.configPath).pipe(
|
|
12
|
+
Effect.mapError(
|
|
13
|
+
(error) =>
|
|
14
|
+
new RuntimeConfigError({
|
|
15
|
+
code: "CONFIG_PATH_MISSING",
|
|
16
|
+
message: `${PlatformRuntimeKey.configPath} is required in hosted mode`,
|
|
17
|
+
cause: error.cause,
|
|
18
|
+
}),
|
|
19
|
+
),
|
|
20
|
+
);
|
|
21
|
+
const fileSystem = yield* FileSystem.FileSystem;
|
|
22
|
+
const contents = yield* fileSystem.readFileString(configPath).pipe(
|
|
23
|
+
Effect.mapError(
|
|
24
|
+
(cause) =>
|
|
25
|
+
new RuntimeConfigError({
|
|
26
|
+
code: "CONFIG_FILE_UNREADABLE",
|
|
27
|
+
message: "Connector config file could not be read",
|
|
28
|
+
cause,
|
|
29
|
+
}),
|
|
30
|
+
),
|
|
31
|
+
);
|
|
32
|
+
const document = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Unknown))(
|
|
33
|
+
contents,
|
|
34
|
+
).pipe(
|
|
35
|
+
Effect.mapError(
|
|
36
|
+
(error) =>
|
|
37
|
+
new RuntimeConfigError({
|
|
38
|
+
code: "CONFIG_JSON_INVALID",
|
|
39
|
+
message: "Connector config is not valid JSON",
|
|
40
|
+
cause: error,
|
|
41
|
+
}),
|
|
42
|
+
),
|
|
43
|
+
);
|
|
44
|
+
return ConfigProvider.fromUnknown(document, { preserveEmptyStrings: true });
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Loads the required JSON file and lets environment values override it per key.
|
|
49
|
+
* Connector field validation remains in the connector's Effect Config.
|
|
50
|
+
*
|
|
51
|
+
* @example
|
|
52
|
+
* ```ts
|
|
53
|
+
* program.pipe(Effect.provide(layerHosted()));
|
|
54
|
+
* ```
|
|
55
|
+
*/
|
|
56
|
+
export const layerHosted = () => ConfigProvider.layerAdd(providerFromFile);
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { StateSource } from "./schema";
|
|
2
|
+
|
|
3
|
+
type StateComponent = StateSource | `error:${StateSource}`;
|
|
4
|
+
|
|
5
|
+
export const resourceComponentKey = (resource: string, component: StateComponent): string =>
|
|
6
|
+
`resource:${resource}:${component}`;
|
|
7
|
+
|
|
8
|
+
export const resourceErrorKey = (resource: string, source: StateSource): string =>
|
|
9
|
+
resourceComponentKey(resource, `error:${source}`);
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Prefix for all state owned by one connector instance. The platform-owned ID
|
|
13
|
+
* stays visible so operators can inspect and clean up shared-table rows directly.
|
|
14
|
+
*
|
|
15
|
+
* @example
|
|
16
|
+
* ```ts
|
|
17
|
+
* connectorInstanceKeyPrefix("team-a/orders");
|
|
18
|
+
* // "connector-instance:team-a/orders:"
|
|
19
|
+
* ```
|
|
20
|
+
*/
|
|
21
|
+
export const connectorInstanceKeyPrefix = (connectorInstanceId: string): string =>
|
|
22
|
+
`connector-instance:${connectorInstanceId}:`;
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { DateTime, Effect, Layer, Option, Schema } from "effect";
|
|
2
|
+
import { KeyValueStore } from "effect/unstable/persistence";
|
|
3
|
+
|
|
4
|
+
import { ConnectorError } from "../errors";
|
|
5
|
+
import { resourceComponentKey, resourceErrorKey } from "./keys";
|
|
6
|
+
import {
|
|
7
|
+
BackfillStateSchema,
|
|
8
|
+
ChangesStateSchema,
|
|
9
|
+
ResourceErrorSchema,
|
|
10
|
+
type PersistedBackfillState,
|
|
11
|
+
type PersistedChangesState,
|
|
12
|
+
type PersistedResourceError,
|
|
13
|
+
type ResourceErrorContext,
|
|
14
|
+
type StateOperation,
|
|
15
|
+
type StateSource,
|
|
16
|
+
} from "./schema";
|
|
17
|
+
import { StateStore } from "./service";
|
|
18
|
+
|
|
19
|
+
const mapStoreError = (operation: "read" | "write" | "clear", resource: string) =>
|
|
20
|
+
Effect.mapError(
|
|
21
|
+
(cause: KeyValueStore.KeyValueStoreError | Schema.SchemaError) =>
|
|
22
|
+
new ConnectorError({
|
|
23
|
+
message: `Failed to ${operation} state for resource ${resource}`,
|
|
24
|
+
cause,
|
|
25
|
+
}),
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
const resourceErrorContext = (
|
|
29
|
+
source: StateSource,
|
|
30
|
+
operation: StateOperation,
|
|
31
|
+
): ResourceErrorContext => {
|
|
32
|
+
const sourceName = source === "backfill" ? "Backfill" : "Changes";
|
|
33
|
+
switch (operation) {
|
|
34
|
+
case "fetch":
|
|
35
|
+
return { source, operation, code: "fetch_failed", message: `${sourceName} fetch failed` };
|
|
36
|
+
case "publish":
|
|
37
|
+
return { source, operation, code: "publish_failed", message: `${sourceName} publish failed` };
|
|
38
|
+
case "checkpoint":
|
|
39
|
+
return {
|
|
40
|
+
source,
|
|
41
|
+
operation,
|
|
42
|
+
code: "checkpoint_failed",
|
|
43
|
+
message: `${sourceName} checkpoint failed`,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const latestError = (
|
|
49
|
+
backfill: Option.Option<PersistedResourceError>,
|
|
50
|
+
changes: Option.Option<PersistedResourceError>,
|
|
51
|
+
): Option.Option<PersistedResourceError> => {
|
|
52
|
+
if (Option.isNone(backfill)) return changes;
|
|
53
|
+
if (Option.isNone(changes)) return backfill;
|
|
54
|
+
return backfill.value.at >= changes.value.at ? backfill : changes;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Implements StateStore over any Effect KeyValueStore. `toSchemaStore` owns
|
|
59
|
+
* serialization and validates persisted values when they are read and written.
|
|
60
|
+
* The caller owns instance scoping; `layerSql` supplies the production prefix.
|
|
61
|
+
*/
|
|
62
|
+
export const layerKeyValueStore: Layer.Layer<StateStore, never, KeyValueStore.KeyValueStore> =
|
|
63
|
+
Layer.effect(StateStore)(
|
|
64
|
+
Effect.gen(function* () {
|
|
65
|
+
const keyValueStore = yield* KeyValueStore.KeyValueStore;
|
|
66
|
+
const backfillStore = KeyValueStore.toSchemaStore(keyValueStore, BackfillStateSchema);
|
|
67
|
+
const changesStore = KeyValueStore.toSchemaStore(keyValueStore, ChangesStateSchema);
|
|
68
|
+
const errorStore = KeyValueStore.toSchemaStore(keyValueStore, ResourceErrorSchema);
|
|
69
|
+
|
|
70
|
+
const getResourceState = (resource: string) =>
|
|
71
|
+
// Components use separate keys so a cursor update cannot overwrite the
|
|
72
|
+
// independently managed error or backfill state.
|
|
73
|
+
Effect.all(
|
|
74
|
+
{
|
|
75
|
+
backfill: backfillStore.get(resourceComponentKey(resource, "backfill")),
|
|
76
|
+
changes: changesStore.get(resourceComponentKey(resource, "changes")),
|
|
77
|
+
backfillError: errorStore.get(resourceErrorKey(resource, "backfill")),
|
|
78
|
+
changesError: errorStore.get(resourceErrorKey(resource, "changes")),
|
|
79
|
+
},
|
|
80
|
+
{ concurrency: "unbounded" },
|
|
81
|
+
).pipe(
|
|
82
|
+
Effect.map(({ backfill, changes, backfillError, changesError }) => {
|
|
83
|
+
const lastError = latestError(backfillError, changesError);
|
|
84
|
+
if (Option.isNone(backfill) && Option.isNone(changes) && Option.isNone(lastError)) {
|
|
85
|
+
return undefined;
|
|
86
|
+
}
|
|
87
|
+
return {
|
|
88
|
+
...(Option.isSome(backfill) ? { backfill: backfill.value } : {}),
|
|
89
|
+
...(Option.isSome(changes) ? { changes: changes.value } : {}),
|
|
90
|
+
...(Option.isSome(lastError) ? { lastError: lastError.value } : {}),
|
|
91
|
+
};
|
|
92
|
+
}),
|
|
93
|
+
mapStoreError("read", resource),
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
const setBackfillState = (resource: string, state: PersistedBackfillState) =>
|
|
97
|
+
backfillStore
|
|
98
|
+
.set(resourceComponentKey(resource, "backfill"), state)
|
|
99
|
+
.pipe(mapStoreError("write", resource));
|
|
100
|
+
|
|
101
|
+
const setChangesState = (resource: string, state: PersistedChangesState) =>
|
|
102
|
+
changesStore
|
|
103
|
+
.set(resourceComponentKey(resource, "changes"), state)
|
|
104
|
+
.pipe(mapStoreError("write", resource));
|
|
105
|
+
|
|
106
|
+
const setResourceError = (resource: string, source: StateSource, operation: StateOperation) =>
|
|
107
|
+
DateTime.now.pipe(
|
|
108
|
+
Effect.map(DateTime.formatIso),
|
|
109
|
+
Effect.flatMap((at) => {
|
|
110
|
+
const error = resourceErrorContext(source, operation);
|
|
111
|
+
return errorStore.set(resourceErrorKey(resource, source), {
|
|
112
|
+
...error,
|
|
113
|
+
at,
|
|
114
|
+
});
|
|
115
|
+
}),
|
|
116
|
+
mapStoreError("write", resource),
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
const clearResourceError = (resource: string, source: StateSource) =>
|
|
120
|
+
errorStore
|
|
121
|
+
.remove(resourceErrorKey(resource, source))
|
|
122
|
+
.pipe(mapStoreError("clear", resource));
|
|
123
|
+
|
|
124
|
+
return StateStore.of({
|
|
125
|
+
getResourceState,
|
|
126
|
+
setBackfillState,
|
|
127
|
+
setChangesState,
|
|
128
|
+
setResourceError,
|
|
129
|
+
clearResourceError,
|
|
130
|
+
});
|
|
131
|
+
}),
|
|
132
|
+
);
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { Layer } from "effect";
|
|
2
|
+
import { KeyValueStore } from "effect/unstable/persistence";
|
|
3
|
+
|
|
4
|
+
import type { StateStore } from "./service";
|
|
5
|
+
|
|
6
|
+
import { layerKeyValueStore } from "./layer-key-value-store";
|
|
7
|
+
|
|
8
|
+
/** Process-local state for tests and explicit local sandbox commands. */
|
|
9
|
+
export const layerMemory: Layer.Layer<StateStore> = layerKeyValueStore.pipe(
|
|
10
|
+
Layer.provide(KeyValueStore.layerMemory),
|
|
11
|
+
);
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { Config, Effect, Layer } from "effect";
|
|
2
|
+
import { KeyValueStore } from "effect/unstable/persistence";
|
|
3
|
+
import { SqlClient } from "effect/unstable/sql";
|
|
4
|
+
|
|
5
|
+
import type { StateStore } from "./service";
|
|
6
|
+
|
|
7
|
+
import { PlatformRuntimeDefault, PlatformRuntimeKey } from "../runtime-config/constants";
|
|
8
|
+
import { connectorInstanceKeyPrefix } from "./keys";
|
|
9
|
+
import { layerKeyValueStore } from "./layer-key-value-store";
|
|
10
|
+
|
|
11
|
+
const scopedKeyValueStore = (connectorInstanceId: string) =>
|
|
12
|
+
Layer.effect(KeyValueStore.KeyValueStore)(
|
|
13
|
+
KeyValueStore.KeyValueStore.pipe(
|
|
14
|
+
Effect.map(KeyValueStore.prefix(connectorInstanceKeyPrefix(connectorInstanceId))),
|
|
15
|
+
),
|
|
16
|
+
);
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Shared SQL state using the immutable connector instance ID supplied by
|
|
20
|
+
* platform-owned Effect configuration. The table defaults to
|
|
21
|
+
* `_airfoil_connectors_state` and can be overridden with `AIRFOIL_STATE_TABLE`.
|
|
22
|
+
* Effect's SQL KeyValueStore owns the table, while `KeyValueStore.prefix`
|
|
23
|
+
* provides application-level isolation between connector instances in that
|
|
24
|
+
* table. The resulting layer exposes only StateStore, never the unscoped
|
|
25
|
+
* KeyValueStore.
|
|
26
|
+
*
|
|
27
|
+
* @example
|
|
28
|
+
* ```ts
|
|
29
|
+
* const StateStoreLive = layerSql().pipe(Layer.provide(PostgresLive));
|
|
30
|
+
* ```
|
|
31
|
+
*/
|
|
32
|
+
export const layerSql = (): Layer.Layer<StateStore, Config.ConfigError, SqlClient.SqlClient> =>
|
|
33
|
+
Layer.unwrap(
|
|
34
|
+
Config.all({
|
|
35
|
+
connectorInstanceId: Config.nonEmptyString(PlatformRuntimeKey.connectorInstanceId),
|
|
36
|
+
table: Config.nonEmptyString(PlatformRuntimeKey.stateTable).pipe(
|
|
37
|
+
Config.withDefault(PlatformRuntimeDefault.stateTable),
|
|
38
|
+
),
|
|
39
|
+
}).pipe(
|
|
40
|
+
Effect.map(({ connectorInstanceId, table }) =>
|
|
41
|
+
layerKeyValueStore.pipe(
|
|
42
|
+
Layer.provide(
|
|
43
|
+
scopedKeyValueStore(connectorInstanceId).pipe(
|
|
44
|
+
Layer.provide(KeyValueStore.layerSql({ table })),
|
|
45
|
+
),
|
|
46
|
+
),
|
|
47
|
+
),
|
|
48
|
+
),
|
|
49
|
+
),
|
|
50
|
+
);
|