@useairfoil/connector-kit 0.5.0 → 0.5.2
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 +190 -21
- 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/connector-app/index.d.ts +14 -9
- package/dist/connector-app/index.js +9 -6
- 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 +1 -1
- package/dist/{index-DK9YcR6W.d.ts → index-Byr0Lf0C.d.ts} +4 -4
- package/dist/index-CBT-JX-T.d.ts +173 -0
- package/dist/index-CTHVudvQ.d.ts +41 -0
- package/dist/index-Coh_SkBs.d.ts +95 -0
- package/dist/index-Cq2IzY7T.d.ts +80 -0
- package/dist/{index-DyuT2jzP.d.ts → index-D54ySAKn.d.ts} +1 -1
- package/dist/{index-CQJsFJs3.d.ts → index-DKWIh6M1.d.ts} +7 -5
- package/dist/{index-BlhJCpEk.d.ts → index-DtJX_tic.d.ts} +39 -10
- package/dist/{index-8KnVpoZp.d.ts → index-J42AZaXU.d.ts} +11 -5
- package/dist/index-o1-Z3n7V.d.ts +55 -0
- package/dist/index.d.ts +14 -9
- package/dist/index.js +14 -8
- package/dist/index.js.map +1 -1
- package/dist/ingestion/index.d.ts +5 -5
- package/dist/ingestion/index.js +7 -4
- 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 +4 -4
- package/dist/publisher/index.js +3 -2
- package/dist/{publisher-DusgYvaV.js → publisher-BYT3RLVO.js} +2 -2
- package/dist/{publisher-DusgYvaV.js.map → publisher-BYT3RLVO.js.map} +1 -1
- 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/{telemetry-p0dE5Mdu.js → service-DuFiQwid.js} +30 -10
- package/dist/service-DuFiQwid.js.map +1 -0
- package/dist/{service-B7g_lHMI.d.ts → service-IE5qaf9x.d.ts} +3 -3
- package/dist/state-store/index.d.ts +3 -4
- package/dist/state-store/index.js +4 -2
- 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-ZNrA-Dn0.d.ts → types-B35J5YPu.d.ts} +20 -6
- package/dist/webhook/index.d.ts +5 -4
- package/dist/webhook/index.js +5 -3
- package/dist/{webhook-BSA-tXtU.js → webhook-DI-Afxkg.js} +2 -2
- package/dist/{webhook-BSA-tXtU.js.map → webhook-DI-Afxkg.js.map} +1 -1
- package/package.json +36 -7
- 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 +1 -0
- package/src/connector-app/start.ts +4 -0
- package/src/core/builder.ts +8 -3
- package/src/core/types.ts +20 -2
- package/src/index.ts +5 -0
- package/src/ingestion/engine.ts +246 -133
- 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/instrumented.ts +68 -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 +2 -1
- 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 +39 -6
- 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 +46 -4
- package/src/webhook/server.ts +54 -19
- package/dist/connector-app-CvqbfN6R.js +0 -32
- package/dist/connector-app-CvqbfN6R.js.map +0 -1
- package/dist/index-DaNW63Mv.d.ts +0 -21
- package/dist/ingestion-CMCroK7Y.js +0 -171
- package/dist/ingestion-CMCroK7Y.js.map +0 -1
- package/dist/server-DAeFWT4o.js +0 -113
- package/dist/server-DAeFWT4o.js.map +0 -1
- package/dist/state-store-DG_4QpWG.js +0 -31
- package/dist/state-store-DG_4QpWG.js.map +0 -1
- package/dist/telemetry-p0dE5Mdu.js.map +0 -1
- package/src/state-store/memory.ts +0 -25
|
@@ -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);
|
package/src/state-store/index.ts
CHANGED
|
@@ -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
|
+
);
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { Schema } from "effect";
|
|
2
|
+
|
|
3
|
+
const CursorValueSchema = Schema.Union([Schema.String, Schema.Number]);
|
|
4
|
+
|
|
5
|
+
/** Durable progress for a finite historical backfill. */
|
|
6
|
+
export const BackfillStateSchema = Schema.Struct({
|
|
7
|
+
cutoff: CursorValueSchema,
|
|
8
|
+
pageCursor: Schema.optional(CursorValueSchema),
|
|
9
|
+
completed: Schema.Boolean,
|
|
10
|
+
lastSuccessAt: Schema.optional(Schema.String),
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
/** Durable cursor for an ongoing incremental changes stream. */
|
|
14
|
+
export const ChangesStateSchema = Schema.Struct({
|
|
15
|
+
cursor: CursorValueSchema,
|
|
16
|
+
lastSuccessAt: Schema.optional(Schema.String),
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
export const StateSourceSchema = Schema.Literals(["backfill", "changes"]);
|
|
20
|
+
|
|
21
|
+
export const StateOperationSchema = Schema.Literals(["fetch", "publish", "checkpoint"]);
|
|
22
|
+
|
|
23
|
+
export const StateErrorCodeSchema = Schema.Literals([
|
|
24
|
+
"fetch_failed",
|
|
25
|
+
"publish_failed",
|
|
26
|
+
"checkpoint_failed",
|
|
27
|
+
]);
|
|
28
|
+
|
|
29
|
+
/** SDK-owned error context safe to persist and expose through status. */
|
|
30
|
+
export const ResourceErrorContextSchema = Schema.Struct({
|
|
31
|
+
source: StateSourceSchema,
|
|
32
|
+
operation: StateOperationSchema,
|
|
33
|
+
code: StateErrorCodeSchema,
|
|
34
|
+
message: Schema.String,
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
/** Stable public error summary persisted for status reporting; never a raw cause. */
|
|
38
|
+
export const ResourceErrorSchema = Schema.Struct({
|
|
39
|
+
...ResourceErrorContextSchema.fields,
|
|
40
|
+
at: Schema.String,
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
/** Complete persisted state assembled from independently stored components. */
|
|
44
|
+
export const ResourceStateSchema = Schema.Struct({
|
|
45
|
+
backfill: Schema.optional(BackfillStateSchema),
|
|
46
|
+
changes: Schema.optional(ChangesStateSchema),
|
|
47
|
+
lastError: Schema.optional(ResourceErrorSchema),
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
export type PersistedBackfillState = Schema.Schema.Type<typeof BackfillStateSchema>;
|
|
51
|
+
export type PersistedChangesState = Schema.Schema.Type<typeof ChangesStateSchema>;
|
|
52
|
+
export type StateSource = Schema.Schema.Type<typeof StateSourceSchema>;
|
|
53
|
+
export type StateOperation = Schema.Schema.Type<typeof StateOperationSchema>;
|
|
54
|
+
export type StateErrorCode = Schema.Schema.Type<typeof StateErrorCodeSchema>;
|
|
55
|
+
export type ResourceErrorContext = Schema.Schema.Type<typeof ResourceErrorContextSchema>;
|
|
56
|
+
export type PersistedResourceError = Schema.Schema.Type<typeof ResourceErrorSchema>;
|
|
57
|
+
export type PersistedResourceState = Schema.Schema.Type<typeof ResourceStateSchema>;
|
|
@@ -1,18 +1,51 @@
|
|
|
1
|
-
import { Context,
|
|
1
|
+
import { Context, Effect } from "effect";
|
|
2
2
|
|
|
3
|
-
import type {
|
|
4
|
-
|
|
3
|
+
import type {
|
|
4
|
+
PersistedBackfillState,
|
|
5
|
+
PersistedChangesState,
|
|
6
|
+
PersistedResourceState,
|
|
7
|
+
StateOperation,
|
|
8
|
+
StateSource,
|
|
9
|
+
} from "./schema";
|
|
5
10
|
|
|
11
|
+
import { ConnectorError } from "../errors";
|
|
12
|
+
|
|
13
|
+
/** Connector-facing durable state operations, independent of the storage backend. */
|
|
6
14
|
export interface StateStoreService {
|
|
7
15
|
readonly getResourceState: (
|
|
8
16
|
resource: string,
|
|
9
|
-
) => Effect.Effect<
|
|
10
|
-
readonly
|
|
17
|
+
) => Effect.Effect<PersistedResourceState | undefined, ConnectorError>;
|
|
18
|
+
readonly setBackfillState: (
|
|
19
|
+
resource: string,
|
|
20
|
+
state: PersistedBackfillState,
|
|
21
|
+
) => Effect.Effect<void, ConnectorError>;
|
|
22
|
+
readonly setChangesState: (
|
|
23
|
+
resource: string,
|
|
24
|
+
state: PersistedChangesState,
|
|
25
|
+
) => Effect.Effect<void, ConnectorError>;
|
|
26
|
+
readonly setResourceError: (
|
|
27
|
+
resource: string,
|
|
28
|
+
source: StateSource,
|
|
29
|
+
operation: StateOperation,
|
|
30
|
+
) => Effect.Effect<void, ConnectorError>;
|
|
31
|
+
readonly clearResourceError: (
|
|
11
32
|
resource: string,
|
|
12
|
-
|
|
33
|
+
source: StateSource,
|
|
13
34
|
) => Effect.Effect<void, ConnectorError>;
|
|
14
35
|
}
|
|
15
36
|
|
|
37
|
+
/**
|
|
38
|
+
* Effect service used by ingestion and status logic for connector checkpoints.
|
|
39
|
+
* Production provides `layerSql`; sandboxes and tests may provide `layerMemory`.
|
|
40
|
+
*
|
|
41
|
+
* @example
|
|
42
|
+
* ```ts
|
|
43
|
+
* const program = Effect.gen(function* () {
|
|
44
|
+
* const state = yield* StateStore;
|
|
45
|
+
* yield* state.setChangesState("orders", { cursor: "next-page" });
|
|
46
|
+
* });
|
|
47
|
+
* ```
|
|
48
|
+
*/
|
|
16
49
|
export class StateStore extends Context.Service<StateStore, StateStoreService>()(
|
|
17
50
|
"@useairfoil/connector-kit/StateStore",
|
|
18
51
|
) {}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { Cursor, ResourceDefinition, ResourceState, SyncState } from "../core/types";
|
|
2
|
+
|
|
3
|
+
/** Normalizes a cursor value to its persisted/wire representation (never a Date). */
|
|
4
|
+
export const normalizeCursor = (value: Cursor.Value): string | number =>
|
|
5
|
+
value instanceof Date ? value.toISOString() : value;
|
|
6
|
+
|
|
7
|
+
/** Derives connector-facing sync state from durable state and resource capabilities. */
|
|
8
|
+
export const deriveSyncState = (
|
|
9
|
+
resource: ResourceDefinition,
|
|
10
|
+
state: ResourceState | undefined,
|
|
11
|
+
): SyncState => {
|
|
12
|
+
if (state?.lastError) return "error";
|
|
13
|
+
if (!resource.backfill && !resource.changes) return "live";
|
|
14
|
+
if (!state) return "pending";
|
|
15
|
+
if (resource.backfill && (!state.backfill || !state.backfill.completed)) return "backfilling";
|
|
16
|
+
return "live";
|
|
17
|
+
};
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { Cause, Effect } from "effect";
|
|
2
|
+
import { HttpRouter, HttpServerResponse } from "effect/unstable/http";
|
|
3
|
+
|
|
4
|
+
import type { ConnectorDefinition } from "../core/types";
|
|
5
|
+
import type { ResourceStatus, StatusUnavailableResponse } from "./types";
|
|
6
|
+
|
|
7
|
+
import { StateStore } from "../state-store";
|
|
8
|
+
import { deriveSyncState, normalizeCursor } from "../state-store/state";
|
|
9
|
+
|
|
10
|
+
export const statusRoute = (
|
|
11
|
+
connector: ConnectorDefinition,
|
|
12
|
+
path: HttpRouter.PathInput = "/status",
|
|
13
|
+
) =>
|
|
14
|
+
HttpRouter.add(
|
|
15
|
+
"GET",
|
|
16
|
+
path,
|
|
17
|
+
Effect.gen(function* () {
|
|
18
|
+
const store = yield* StateStore;
|
|
19
|
+
|
|
20
|
+
const resources = yield* Effect.forEach(
|
|
21
|
+
connector.resources,
|
|
22
|
+
(resource) =>
|
|
23
|
+
store.getResourceState(resource.name).pipe(
|
|
24
|
+
Effect.map(
|
|
25
|
+
(state): ResourceStatus => ({
|
|
26
|
+
name: resource.name,
|
|
27
|
+
state: deriveSyncState(resource, state),
|
|
28
|
+
...(state?.backfill
|
|
29
|
+
? {
|
|
30
|
+
backfill: {
|
|
31
|
+
completed: state.backfill.completed,
|
|
32
|
+
cutoff: normalizeCursor(state.backfill.cutoff),
|
|
33
|
+
...(state.backfill.pageCursor !== undefined
|
|
34
|
+
? { pageCursor: normalizeCursor(state.backfill.pageCursor) }
|
|
35
|
+
: {}),
|
|
36
|
+
...(state.backfill.lastSuccessAt !== undefined
|
|
37
|
+
? { lastSuccessAt: state.backfill.lastSuccessAt }
|
|
38
|
+
: {}),
|
|
39
|
+
},
|
|
40
|
+
}
|
|
41
|
+
: {}),
|
|
42
|
+
...(state?.changes
|
|
43
|
+
? {
|
|
44
|
+
changes: {
|
|
45
|
+
cursor: normalizeCursor(state.changes.cursor),
|
|
46
|
+
...(state.changes.lastSuccessAt !== undefined
|
|
47
|
+
? { lastSuccessAt: state.changes.lastSuccessAt }
|
|
48
|
+
: {}),
|
|
49
|
+
},
|
|
50
|
+
}
|
|
51
|
+
: {}),
|
|
52
|
+
...(state?.lastError ? { lastError: state.lastError } : {}),
|
|
53
|
+
}),
|
|
54
|
+
),
|
|
55
|
+
),
|
|
56
|
+
{ concurrency: "unbounded" },
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
return HttpServerResponse.jsonUnsafe({ connector: connector.name, resources });
|
|
60
|
+
}).pipe(
|
|
61
|
+
Effect.catchCause((cause) =>
|
|
62
|
+
Effect.logWarning(`Status route failed: ${Cause.pretty(cause)}`).pipe(
|
|
63
|
+
Effect.andThen(
|
|
64
|
+
Effect.succeed(
|
|
65
|
+
HttpServerResponse.jsonUnsafe(
|
|
66
|
+
{
|
|
67
|
+
ok: false,
|
|
68
|
+
error: "Failed to compute status",
|
|
69
|
+
} satisfies StatusUnavailableResponse,
|
|
70
|
+
{ status: 500 },
|
|
71
|
+
),
|
|
72
|
+
),
|
|
73
|
+
),
|
|
74
|
+
),
|
|
75
|
+
),
|
|
76
|
+
),
|
|
77
|
+
);
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { Schema } from "effect";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
ResourceErrorSchema as StateResourceErrorSchema,
|
|
5
|
+
ResourceStateSchema,
|
|
6
|
+
} from "../state-store/schema";
|
|
7
|
+
|
|
8
|
+
export const SyncStateSchema = Schema.Literals(["pending", "backfilling", "live", "error"]);
|
|
9
|
+
export type SyncState = Schema.Schema.Type<typeof SyncStateSchema>;
|
|
10
|
+
|
|
11
|
+
/** Safe source failure returned by the status endpoint. */
|
|
12
|
+
export const ResourceErrorSchema = StateResourceErrorSchema;
|
|
13
|
+
export type ResourceError = Schema.Schema.Type<typeof ResourceErrorSchema>;
|
|
14
|
+
|
|
15
|
+
export const ResourceStatusSchema = Schema.Struct({
|
|
16
|
+
name: Schema.String,
|
|
17
|
+
state: SyncStateSchema,
|
|
18
|
+
...ResourceStateSchema.fields,
|
|
19
|
+
});
|
|
20
|
+
export type ResourceStatus = Schema.Schema.Type<typeof ResourceStatusSchema>;
|
|
21
|
+
|
|
22
|
+
export const StatusResponseSchema = Schema.Struct({
|
|
23
|
+
connector: Schema.String,
|
|
24
|
+
resources: Schema.Array(ResourceStatusSchema),
|
|
25
|
+
});
|
|
26
|
+
export type StatusResponse = Schema.Schema.Type<typeof StatusResponseSchema>;
|
|
27
|
+
|
|
28
|
+
/** Returned when durable resource status cannot be read. */
|
|
29
|
+
export const StatusUnavailableResponseSchema = Schema.Struct({
|
|
30
|
+
ok: Schema.Literal(false),
|
|
31
|
+
error: Schema.String,
|
|
32
|
+
});
|
|
33
|
+
export type StatusUnavailableResponse = Schema.Schema.Type<typeof StatusUnavailableResponseSchema>;
|
package/src/telemetry.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
import { Cause, Config, Effect, Layer, Option } from "effect";
|
|
1
|
+
import { Cause, Config, Effect, Layer, Metric, Option, type Duration } from "effect";
|
|
2
2
|
import { Headers } from "effect/unstable/http";
|
|
3
3
|
import * as Observability from "effect/unstable/observability";
|
|
4
4
|
|
|
5
|
+
import { PlatformRuntimeKey } from "./runtime-config/constants";
|
|
6
|
+
|
|
5
7
|
/** Canonical span names emitted by connector-kit runtime instrumentation. */
|
|
6
8
|
export const SpanName = {
|
|
7
9
|
batchProcess: "connector.batch.process",
|
|
@@ -45,9 +47,9 @@ const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
|
45
47
|
const maxErrorMessageLength = 500;
|
|
46
48
|
|
|
47
49
|
const OtlpEnvConfig = Config.all({
|
|
48
|
-
enabled: Config.boolean(
|
|
49
|
-
baseUrl: Config.option(Config.string(
|
|
50
|
-
rawHeaders: Config.option(Config.string(
|
|
50
|
+
enabled: Config.boolean(PlatformRuntimeKey.otelEnabled).pipe(Config.withDefault(false)),
|
|
51
|
+
baseUrl: Config.option(Config.string(PlatformRuntimeKey.otelExporterOtlpEndpoint)),
|
|
52
|
+
rawHeaders: Config.option(Config.string(PlatformRuntimeKey.otelExporterOtlpHeaders)),
|
|
51
53
|
});
|
|
52
54
|
|
|
53
55
|
const defaultRedactedHeaders: ReadonlyArray<string | RegExp> = [
|
|
@@ -163,6 +165,10 @@ export type OtlpTracingOptions = {
|
|
|
163
165
|
readonly redactedHeaders?: ReadonlyArray<string | RegExp>;
|
|
164
166
|
};
|
|
165
167
|
|
|
168
|
+
export type OtlpMetricsOptions = {
|
|
169
|
+
readonly exportInterval?: Duration.Input;
|
|
170
|
+
};
|
|
171
|
+
|
|
166
172
|
const buildOtlpTracingLayer = (config: OtlpTracingConfig, options: OtlpTracingOptions) =>
|
|
167
173
|
Effect.gen(function* () {
|
|
168
174
|
const allRedacted = [...defaultRedactedHeaders, ...(options.redactedHeaders ?? [])];
|
|
@@ -244,3 +250,39 @@ export const layerOtlpTracing = (options: OtlpTracingOptions = {}) =>
|
|
|
244
250
|
);
|
|
245
251
|
}),
|
|
246
252
|
);
|
|
253
|
+
|
|
254
|
+
export const layerOtlpMetrics = (options: OtlpMetricsOptions = {}) =>
|
|
255
|
+
Layer.unwrap(
|
|
256
|
+
Effect.gen(function* () {
|
|
257
|
+
const raw = yield* OtlpEnvConfig;
|
|
258
|
+
if (!raw.enabled) return Layer.empty;
|
|
259
|
+
const endpoint = Option.getOrUndefined(raw.baseUrl);
|
|
260
|
+
if (!endpoint)
|
|
261
|
+
return yield* Effect.fail(
|
|
262
|
+
new Error("OTEL_ENABLED=true requires OTEL_EXPORTER_OTLP_ENDPOINT"),
|
|
263
|
+
);
|
|
264
|
+
return Observability.OtlpMetrics.layer({
|
|
265
|
+
url: appendPath(endpoint, "/v1/metrics"),
|
|
266
|
+
headers: Option.isSome(raw.rawHeaders) ? parseOtelHeaders(raw.rawHeaders.value) : undefined,
|
|
267
|
+
exportInterval: options.exportInterval,
|
|
268
|
+
temporality: "cumulative",
|
|
269
|
+
}).pipe(Layer.provide(Observability.OtlpSerialization.layerJson));
|
|
270
|
+
}),
|
|
271
|
+
);
|
|
272
|
+
|
|
273
|
+
export const layerOtlp = (options: OtlpTracingOptions & OtlpMetricsOptions = {}) =>
|
|
274
|
+
Layer.mergeAll(layerOtlpTracing(options), layerOtlpMetrics(options));
|
|
275
|
+
|
|
276
|
+
export const layerMetricsConsoleDump = (interval: Duration.Input = "30 seconds") =>
|
|
277
|
+
Layer.effectDiscard(
|
|
278
|
+
Effect.gen(function* () {
|
|
279
|
+
yield* Effect.forkScoped(
|
|
280
|
+
Effect.forever(
|
|
281
|
+
Metric.dump.pipe(
|
|
282
|
+
Effect.flatMap((snapshot) => Effect.logInfo(snapshot)),
|
|
283
|
+
Effect.andThen(Effect.sleep(interval)),
|
|
284
|
+
),
|
|
285
|
+
),
|
|
286
|
+
);
|
|
287
|
+
}),
|
|
288
|
+
);
|