@rc312/effect-snowflake 0.0.0-stage → 0.1.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.
- package/CHANGELOG.md +7 -0
- package/LICENSE +21 -0
- package/README.md +176 -2
- package/dist/adbc-driver.d.ts +49 -0
- package/dist/adbc-driver.d.ts.map +1 -0
- package/dist/adbc-driver.js +29 -0
- package/dist/adbc-driver.js.map +1 -0
- package/dist/index.d.ts +96 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +89 -0
- package/dist/index.js.map +1 -0
- package/dist/internal/client.d.ts +6 -0
- package/dist/internal/client.d.ts.map +1 -0
- package/dist/internal/client.js +29 -0
- package/dist/internal/client.js.map +1 -0
- package/dist/internal/configuration.d.ts +4 -0
- package/dist/internal/configuration.d.ts.map +1 -0
- package/dist/internal/configuration.js +51 -0
- package/dist/internal/configuration.js.map +1 -0
- package/dist/internal/connection.d.ts +25 -0
- package/dist/internal/connection.d.ts.map +1 -0
- package/dist/internal/connection.js +39 -0
- package/dist/internal/connection.js.map +1 -0
- package/dist/internal/lifecycle.d.ts +9 -0
- package/dist/internal/lifecycle.d.ts.map +1 -0
- package/dist/internal/lifecycle.js +81 -0
- package/dist/internal/lifecycle.js.map +1 -0
- package/dist/internal/native.d.ts +4 -0
- package/dist/internal/native.d.ts.map +1 -0
- package/dist/internal/native.js +5 -0
- package/dist/internal/native.js.map +1 -0
- package/dist/internal/session.d.ts +6 -0
- package/dist/internal/session.d.ts.map +1 -0
- package/dist/internal/session.js +46 -0
- package/dist/internal/session.js.map +1 -0
- package/dist/internal/transaction.d.ts +6 -0
- package/dist/internal/transaction.d.ts.map +1 -0
- package/dist/internal/transaction.js +38 -0
- package/dist/internal/transaction.js.map +1 -0
- package/dist/models.d.ts +99 -0
- package/dist/models.d.ts.map +1 -0
- package/dist/models.js +2 -0
- package/dist/models.js.map +1 -0
- package/dist/snowflake-error.d.ts +19 -0
- package/dist/snowflake-error.d.ts.map +1 -0
- package/dist/snowflake-error.js +13 -0
- package/dist/snowflake-error.js.map +1 -0
- package/examples/.env.example +16 -0
- package/examples/README.md +73 -0
- package/examples/check.ts +12 -0
- package/examples/composition.ts +24 -0
- package/examples/options.ts +80 -0
- package/examples/query.ts +4 -0
- package/examples/run.ts +30 -0
- package/examples/scenarios.ts +114 -0
- package/examples/stream.ts +4 -0
- package/examples/transaction.ts +4 -0
- package/package.json +59 -4
- package/src/adbc-driver.ts +83 -0
- package/src/index.ts +151 -0
- package/src/internal/client.ts +69 -0
- package/src/internal/configuration.ts +63 -0
- package/src/internal/connection.ts +83 -0
- package/src/internal/lifecycle.ts +186 -0
- package/src/internal/native.ts +9 -0
- package/src/internal/session.ts +111 -0
- package/src/internal/transaction.ts +88 -0
- package/src/models.ts +126 -0
- package/src/snowflake-error.ts +17 -0
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { ConnectOptions } from "@apache-arrow/adbc-driver-manager";
|
|
2
|
+
import { Redacted } from "effect";
|
|
3
|
+
|
|
4
|
+
import type { SnowflakeOptions } from "../models.ts";
|
|
5
|
+
|
|
6
|
+
export const configuration = (options: SnowflakeOptions): ConnectOptions => {
|
|
7
|
+
const databaseOptions = Object.fromEntries(
|
|
8
|
+
Object.entries(options.databaseOptions ?? {}).map(([key, value]) => [
|
|
9
|
+
key,
|
|
10
|
+
Redacted.value(value),
|
|
11
|
+
])
|
|
12
|
+
);
|
|
13
|
+
|
|
14
|
+
const values = [
|
|
15
|
+
["adbc.snowflake.sql.account", options.account],
|
|
16
|
+
["username", options.username],
|
|
17
|
+
["adbc.snowflake.sql.db", options.database],
|
|
18
|
+
["adbc.snowflake.sql.schema", options.schema],
|
|
19
|
+
["adbc.snowflake.sql.warehouse", options.warehouse],
|
|
20
|
+
["adbc.snowflake.sql.role", options.role],
|
|
21
|
+
["adbc.snowflake.sql.auth_type", options.authenticator],
|
|
22
|
+
[
|
|
23
|
+
"adbc.snowflake.sql.client_option.jwt_private_key",
|
|
24
|
+
options.privateKeyPath,
|
|
25
|
+
],
|
|
26
|
+
] as const;
|
|
27
|
+
|
|
28
|
+
for (const [key, value] of values) {
|
|
29
|
+
if (value !== undefined) {
|
|
30
|
+
databaseOptions[key] = value;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const secrets = [
|
|
35
|
+
["password", options.password],
|
|
36
|
+
["adbc.snowflake.sql.client_option.auth_token", options.token],
|
|
37
|
+
[
|
|
38
|
+
"adbc.snowflake.sql.client_option.jwt_private_key_pkcs8_value",
|
|
39
|
+
options.privateKey,
|
|
40
|
+
],
|
|
41
|
+
[
|
|
42
|
+
"adbc.snowflake.sql.client_option.jwt_private_key_pkcs8_password",
|
|
43
|
+
options.privateKeyPass,
|
|
44
|
+
],
|
|
45
|
+
] as const;
|
|
46
|
+
|
|
47
|
+
for (const [key, value] of secrets) {
|
|
48
|
+
if (value !== undefined) {
|
|
49
|
+
databaseOptions[key] = Redacted.value(value);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const config: ConnectOptions = {
|
|
54
|
+
databaseOptions,
|
|
55
|
+
driver: options.driver ?? "snowflake",
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
if (options.manifestSearchPaths !== undefined) {
|
|
59
|
+
config.manifestSearchPaths = [...options.manifestSearchPaths];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return config;
|
|
63
|
+
};
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import type { Scope } from "effect";
|
|
2
|
+
import { Effect, flow } from "effect";
|
|
3
|
+
|
|
4
|
+
import { AdbcDriver } from "../adbc-driver.ts";
|
|
5
|
+
import type { Connection as NativeConnection } from "../adbc-driver.ts";
|
|
6
|
+
import type { SnowflakeOptions } from "../models.ts";
|
|
7
|
+
import { failure } from "../snowflake-error.ts";
|
|
8
|
+
import type { SnowflakeError } from "../snowflake-error.ts";
|
|
9
|
+
import { configuration } from "./configuration.ts";
|
|
10
|
+
import { asCleanup } from "./lifecycle.ts";
|
|
11
|
+
import { call } from "./native.ts";
|
|
12
|
+
|
|
13
|
+
/** One native connection and the lifetime shared by all of its sessions. */
|
|
14
|
+
export interface Connection {
|
|
15
|
+
readonly native: NativeConnection;
|
|
16
|
+
readonly owner: Scope.Scope;
|
|
17
|
+
readonly isOpen: () => boolean;
|
|
18
|
+
readonly isHealthy: () => boolean;
|
|
19
|
+
readonly cleanup: <A>(
|
|
20
|
+
operation: string,
|
|
21
|
+
run: () => Promise<A>
|
|
22
|
+
) => Effect.Effect<A>;
|
|
23
|
+
readonly setAutoCommit: (
|
|
24
|
+
enabled: boolean
|
|
25
|
+
) => Effect.Effect<void, SnowflakeError>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Called inside the client's private sequential scope. Dependencies are captured once. */
|
|
29
|
+
export const make = Effect.fnUntraced(function* acquireConnection(
|
|
30
|
+
options: SnowflakeOptions
|
|
31
|
+
) {
|
|
32
|
+
const driver = yield* AdbcDriver;
|
|
33
|
+
const owner = yield* Effect.scope;
|
|
34
|
+
|
|
35
|
+
const config = yield* Effect.try({
|
|
36
|
+
catch: failure("options"),
|
|
37
|
+
try: () => configuration(options),
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
let open = true;
|
|
41
|
+
let healthy = true;
|
|
42
|
+
|
|
43
|
+
const invalidate = Effect.sync(() => {
|
|
44
|
+
healthy = false;
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
const finalize = flow(
|
|
48
|
+
Effect.tapError(() => invalidate),
|
|
49
|
+
asCleanup
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
const cleanup: Connection["cleanup"] = (operation, run) =>
|
|
53
|
+
call(operation, run).pipe(finalize);
|
|
54
|
+
|
|
55
|
+
const database = yield* Effect.acquireRelease(
|
|
56
|
+
driver.open(config),
|
|
57
|
+
(resource) => cleanup("database.close", () => resource.close())
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
const native = yield* Effect.acquireRelease(
|
|
61
|
+
call("connect", () => database.connect()),
|
|
62
|
+
(resource) => cleanup("connection.close", () => resource.close())
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
yield* Effect.addFinalizer(() =>
|
|
66
|
+
Effect.sync(() => {
|
|
67
|
+
open = false;
|
|
68
|
+
})
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
cleanup,
|
|
73
|
+
isHealthy: () => healthy,
|
|
74
|
+
isOpen: () => open && healthy,
|
|
75
|
+
native,
|
|
76
|
+
owner,
|
|
77
|
+
setAutoCommit: (enabled: boolean) =>
|
|
78
|
+
Effect.try({
|
|
79
|
+
catch: failure("transaction.autocommit"),
|
|
80
|
+
try: () => native.setAutoCommit(enabled),
|
|
81
|
+
}).pipe(Effect.tapError(() => invalidate)),
|
|
82
|
+
} satisfies Connection;
|
|
83
|
+
});
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import type { RecordBatch } from "apache-arrow";
|
|
2
|
+
import { Cause, Effect, Exit, Fiber, Scope, Stream } from "effect";
|
|
3
|
+
|
|
4
|
+
import { failure } from "../snowflake-error.ts";
|
|
5
|
+
import type { SnowflakeError } from "../snowflake-error.ts";
|
|
6
|
+
|
|
7
|
+
const cleanupReason = <E>(reason: Cause.Reason<E>): Cause.Reason<never> =>
|
|
8
|
+
Cause.isFailReason(reason)
|
|
9
|
+
? Cause.makeDieReason(reason.error).annotate(
|
|
10
|
+
Cause.reasonAnnotations(reason)
|
|
11
|
+
)
|
|
12
|
+
: reason;
|
|
13
|
+
|
|
14
|
+
const cleanupCause = <E>(cause: Cause.Cause<E>) =>
|
|
15
|
+
Cause.fromReasons<never>(cause.reasons.map(cleanupReason));
|
|
16
|
+
|
|
17
|
+
export const asCleanup = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
|
|
18
|
+
Effect.catchCause(effect, (cause) => Effect.failCause(cleanupCause(cause)));
|
|
19
|
+
|
|
20
|
+
const cleanupExit = <A, E>(exit: Exit.Exit<A, E>) => {
|
|
21
|
+
if (Exit.isFailure(exit)) {
|
|
22
|
+
const reasons = exit.cause.reasons.filter(
|
|
23
|
+
(reason) => !Cause.isInterruptReason(reason)
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
if (reasons.length > 0) {
|
|
27
|
+
return Effect.failCause(Cause.fromReasons(reasons)).pipe(asCleanup);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return Effect.void;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const observeCleanup = <A, E>(fiber: Fiber.Fiber<A, E>) =>
|
|
35
|
+
Fiber.await(fiber).pipe(Effect.flatMap(cleanupExit));
|
|
36
|
+
|
|
37
|
+
const stopOperation = Effect.fnUntraced(function* stopOwnedOperation<A, E>(
|
|
38
|
+
fiber: Fiber.Fiber<A, E>
|
|
39
|
+
) {
|
|
40
|
+
if (fiber.pollUnsafe() !== undefined) {
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
yield* Fiber.interrupt(fiber);
|
|
45
|
+
yield* observeCleanup(fiber);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
interface Operation<A, E> {
|
|
49
|
+
readonly fiber: Fiber.Fiber<A, E>;
|
|
50
|
+
readonly lifetime: Scope.Closeable;
|
|
51
|
+
readonly stop: Effect.Effect<void>;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const acquireOperation = Effect.fnUntraced(function* acquireOwnedOperation<
|
|
55
|
+
A,
|
|
56
|
+
E,
|
|
57
|
+
R,
|
|
58
|
+
>(effect: Effect.Effect<A, E, R>, scope: Scope.Scope) {
|
|
59
|
+
const lifetime = yield* Scope.fork(scope, "sequential");
|
|
60
|
+
const fiber = yield* Effect.forkIn(Effect.interruptible(effect), lifetime);
|
|
61
|
+
// forkIn joins on closure but discards the exit; observe cleanup failures too.
|
|
62
|
+
const stop = yield* stopOperation(fiber).pipe(Effect.cached);
|
|
63
|
+
yield* Scope.addFinalizer(lifetime, stop);
|
|
64
|
+
|
|
65
|
+
return { fiber, lifetime, stop } satisfies Operation<A, E>;
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
const releaseOperation = <A, E>(
|
|
69
|
+
{ fiber, lifetime, stop }: Operation<A, E>,
|
|
70
|
+
exit: Exit.Exit<A, E>
|
|
71
|
+
) =>
|
|
72
|
+
stop.pipe(
|
|
73
|
+
Effect.andThen(
|
|
74
|
+
Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)
|
|
75
|
+
? observeCleanup(fiber)
|
|
76
|
+
: Effect.void
|
|
77
|
+
),
|
|
78
|
+
Effect.ensuring(Scope.close(lifetime, exit))
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
// Each operation belongs to both its caller and its client/session Scope. Closing
|
|
82
|
+
// either interrupts AND joins it before the ADBC connection can be destroyed.
|
|
83
|
+
export const owned = <A, E, R>(
|
|
84
|
+
effect: Effect.Effect<A, E, R>,
|
|
85
|
+
scope: Scope.Scope,
|
|
86
|
+
isOpen: () => boolean
|
|
87
|
+
): Effect.Effect<A, E | SnowflakeError, R> =>
|
|
88
|
+
Effect.suspend<A, E | SnowflakeError, R>(() =>
|
|
89
|
+
isOpen()
|
|
90
|
+
? Effect.acquireUseRelease(
|
|
91
|
+
acquireOperation(effect, scope),
|
|
92
|
+
({ fiber }) => Fiber.join(fiber),
|
|
93
|
+
releaseOperation
|
|
94
|
+
)
|
|
95
|
+
: Effect.fail(failure("closed")())
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
// Finalizers and concurrent callers must join the same teardown. Scope.close
|
|
99
|
+
// marks a scope closed before its finalizers settle, so it cannot provide this
|
|
100
|
+
// join by itself. Keep the parent registration attached until teardown finishes.
|
|
101
|
+
const makeLifetime = Effect.fnUntraced(function* makeOwnedLifetime(
|
|
102
|
+
owner: Scope.Scope
|
|
103
|
+
) {
|
|
104
|
+
const registration = yield* Scope.fork(owner, "sequential");
|
|
105
|
+
const scope = yield* Scope.make("sequential");
|
|
106
|
+
// Native resources must finalize after any fiber that can acquire or use them.
|
|
107
|
+
const resources = yield* Scope.fork(scope, "sequential");
|
|
108
|
+
let firstExit: Exit.Exit<unknown, unknown> | undefined;
|
|
109
|
+
let retired = false;
|
|
110
|
+
let open = true;
|
|
111
|
+
|
|
112
|
+
const retire = Effect.suspend(() => {
|
|
113
|
+
retired = true;
|
|
114
|
+
|
|
115
|
+
return Scope.close(registration, firstExit ?? Exit.void);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
const teardown = yield* Effect.suspend(() => {
|
|
119
|
+
open = false;
|
|
120
|
+
|
|
121
|
+
return Scope.close(scope, firstExit ?? Exit.void);
|
|
122
|
+
}).pipe(Effect.ensuring(retire), Effect.uninterruptible, Effect.cached);
|
|
123
|
+
|
|
124
|
+
const close = (exit: Exit.Exit<unknown, unknown>) =>
|
|
125
|
+
Effect.suspend(() => {
|
|
126
|
+
firstExit ??= exit;
|
|
127
|
+
|
|
128
|
+
return teardown;
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
yield* Scope.addFinalizerExit(registration, (exit) =>
|
|
132
|
+
retired ? Effect.void : close(exit)
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
return { close, isOpen: () => open, resources, scope };
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
const acquireOwnedStream = Effect.fnUntraced(function* acquireStreamLifetime(
|
|
139
|
+
stream: Stream.Stream<RecordBatch, SnowflakeError>,
|
|
140
|
+
owner: Scope.Scope,
|
|
141
|
+
isOpen: () => boolean
|
|
142
|
+
) {
|
|
143
|
+
if (!isOpen()) {
|
|
144
|
+
return yield* failure("closed")();
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const lifetime = yield* Effect.acquireRelease(
|
|
148
|
+
makeLifetime(owner),
|
|
149
|
+
(resource, exit) => resource.close(exit)
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
const active = () => lifetime.isOpen() && isOpen();
|
|
153
|
+
|
|
154
|
+
const pull = yield* owned(
|
|
155
|
+
Stream.toPull(stream).pipe(Scope.provide(lifetime.resources)),
|
|
156
|
+
lifetime.scope,
|
|
157
|
+
active
|
|
158
|
+
);
|
|
159
|
+
|
|
160
|
+
return Stream.fromPull(Effect.succeed(owned(pull, lifetime.scope, active)));
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
export const ownedStream = (
|
|
164
|
+
stream: Stream.Stream<RecordBatch, SnowflakeError>,
|
|
165
|
+
owner: Scope.Scope,
|
|
166
|
+
isOpen: () => boolean
|
|
167
|
+
) => Stream.unwrap(acquireOwnedStream(stream, owner, isOpen));
|
|
168
|
+
|
|
169
|
+
/** Join interrupted acquisition before releasing its late native resources. */
|
|
170
|
+
export const acquireOwner = Effect.fnUntraced(function* acquireClientOwner<
|
|
171
|
+
A,
|
|
172
|
+
E,
|
|
173
|
+
R,
|
|
174
|
+
>(self: Effect.Effect<A, E, R>) {
|
|
175
|
+
return yield* Effect.acquireUseRelease(
|
|
176
|
+
makeLifetime(yield* Effect.scope),
|
|
177
|
+
(lifetime) =>
|
|
178
|
+
owned(
|
|
179
|
+
self.pipe(Scope.provide(lifetime.resources)),
|
|
180
|
+
lifetime.scope,
|
|
181
|
+
lifetime.isOpen
|
|
182
|
+
),
|
|
183
|
+
(lifetime, exit) =>
|
|
184
|
+
Exit.isFailure(exit) ? lifetime.close(exit) : Effect.void
|
|
185
|
+
);
|
|
186
|
+
});
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { Effect } from "effect";
|
|
2
|
+
|
|
3
|
+
import { failure } from "../snowflake-error.ts";
|
|
4
|
+
|
|
5
|
+
/** ADBC JS cannot cancel promises; settle before releasing or reusing resources. */
|
|
6
|
+
export const call = <A>(operation: string, run: () => Promise<A>) =>
|
|
7
|
+
Effect.tryPromise({ catch: failure(operation), try: run }).pipe(
|
|
8
|
+
Effect.uninterruptible
|
|
9
|
+
);
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { Table } from "apache-arrow";
|
|
2
|
+
import type { RecordBatch } from "apache-arrow";
|
|
3
|
+
import { Cause, Effect, flow, pipe, Stream } from "effect";
|
|
4
|
+
import type { Scope, Semaphore } from "effect";
|
|
5
|
+
|
|
6
|
+
import type { Statement } from "../adbc-driver.ts";
|
|
7
|
+
import type { SnowflakeSession } from "../models.ts";
|
|
8
|
+
import { failure } from "../snowflake-error.ts";
|
|
9
|
+
import type { Connection } from "./connection.ts";
|
|
10
|
+
import { owned, ownedStream } from "./lifecycle.ts";
|
|
11
|
+
import { call } from "./native.ts";
|
|
12
|
+
|
|
13
|
+
type Reader = Awaited<ReturnType<Statement["executeQuery"]>>;
|
|
14
|
+
|
|
15
|
+
const executeStatement = (statement: Statement) =>
|
|
16
|
+
call("execute", () => statement.executeUpdate());
|
|
17
|
+
|
|
18
|
+
const collectBatches = (source: Reader) =>
|
|
19
|
+
call("query.read", async () => {
|
|
20
|
+
await source.open();
|
|
21
|
+
const { schema } = source;
|
|
22
|
+
const batches: RecordBatch[] = [];
|
|
23
|
+
|
|
24
|
+
for await (const batch of source) {
|
|
25
|
+
batches.push(batch);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return new Table(schema, batches);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
// fromAsyncIterable installs an unsanitized iterator.return finalizer. The scoped
|
|
32
|
+
// reader.cancel owns cleanup; this pull only reads on downstream demand.
|
|
33
|
+
const streamBatches = (source: Reader) =>
|
|
34
|
+
pipe(
|
|
35
|
+
call("stream.read", () => Promise.resolve(source.next())),
|
|
36
|
+
Effect.flatMap((result) =>
|
|
37
|
+
result.done ? Cause.done() : Effect.succeed([result.value] as const)
|
|
38
|
+
),
|
|
39
|
+
Effect.succeed,
|
|
40
|
+
Stream.fromPull
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
/** Build session operations over a captured connection, permit, and owning scope. */
|
|
44
|
+
export const make = (
|
|
45
|
+
connection: Connection,
|
|
46
|
+
owner: Scope.Scope,
|
|
47
|
+
mutex: Semaphore.Semaphore,
|
|
48
|
+
active: () => boolean
|
|
49
|
+
): SnowflakeSession => {
|
|
50
|
+
const statement = Effect.fnUntraced(function* acquireStatement(
|
|
51
|
+
sql: string,
|
|
52
|
+
parameters?: Table
|
|
53
|
+
) {
|
|
54
|
+
if (!active()) {
|
|
55
|
+
return yield* failure("closed")();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const resource = yield* Effect.acquireRelease(
|
|
59
|
+
call("statement.create", () => connection.native.createStatement()),
|
|
60
|
+
(value) => connection.cleanup("statement.close", () => value.close())
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
yield* call("statement.sql", () => resource.setSqlQuery(sql));
|
|
64
|
+
|
|
65
|
+
if (parameters !== undefined) {
|
|
66
|
+
yield* call("statement.bind", () => resource.bind(parameters));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return resource;
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
const reader = Effect.fnUntraced(function* acquireReader(
|
|
73
|
+
sql: string,
|
|
74
|
+
parameters?: Table
|
|
75
|
+
) {
|
|
76
|
+
const resource = yield* statement(sql, parameters);
|
|
77
|
+
|
|
78
|
+
return yield* Effect.acquireRelease(
|
|
79
|
+
call("query", () => resource.executeQuery()),
|
|
80
|
+
(value) =>
|
|
81
|
+
connection.cleanup("reader.close", async () => {
|
|
82
|
+
await value.cancel();
|
|
83
|
+
})
|
|
84
|
+
);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
const run = <A, E, R>(effect: Effect.Effect<A, E, R | Scope.Scope>) =>
|
|
88
|
+
pipe(effect, Effect.scoped, mutex.withPermit, (self) =>
|
|
89
|
+
owned(self, owner, active)
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
const permit = Effect.acquireRelease(mutex.take(1), () => mutex.release(1));
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
execute: Effect.fn("Snowflake.execute")(
|
|
96
|
+
flow(statement, Effect.flatMap(executeStatement), run)
|
|
97
|
+
),
|
|
98
|
+
query: Effect.fn("Snowflake.query")(
|
|
99
|
+
flow(reader, Effect.flatMap(collectBatches), run)
|
|
100
|
+
),
|
|
101
|
+
stream: (sql, parameters) =>
|
|
102
|
+
pipe(
|
|
103
|
+
permit,
|
|
104
|
+
Effect.andThen(reader(sql, parameters)),
|
|
105
|
+
Effect.map(streamBatches),
|
|
106
|
+
Stream.unwrap,
|
|
107
|
+
(self) => ownedStream(self, owner, active),
|
|
108
|
+
Stream.withSpan("Snowflake.stream")
|
|
109
|
+
),
|
|
110
|
+
};
|
|
111
|
+
};
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { Effect, Exit, Semaphore } from "effect";
|
|
2
|
+
|
|
3
|
+
import type { SnowflakeClient, SnowflakeSession } from "../models.ts";
|
|
4
|
+
import { failure } from "../snowflake-error.ts";
|
|
5
|
+
import type { Connection } from "./connection.ts";
|
|
6
|
+
import { asCleanup, owned } from "./lifecycle.ts";
|
|
7
|
+
import { call } from "./native.ts";
|
|
8
|
+
import * as Session from "./session.ts";
|
|
9
|
+
|
|
10
|
+
/** The callback owns its session; closing its scope expires escaped operations. */
|
|
11
|
+
const acquireSession = Effect.fnUntraced(function* acquireTransactionSession(
|
|
12
|
+
connection: Connection
|
|
13
|
+
) {
|
|
14
|
+
let active = true;
|
|
15
|
+
const owner = yield* Effect.scope;
|
|
16
|
+
yield* Effect.addFinalizer(() =>
|
|
17
|
+
Effect.sync(() => {
|
|
18
|
+
active = false;
|
|
19
|
+
})
|
|
20
|
+
);
|
|
21
|
+
const lock = yield* Semaphore.make(1);
|
|
22
|
+
|
|
23
|
+
return Session.make(
|
|
24
|
+
connection,
|
|
25
|
+
owner,
|
|
26
|
+
lock,
|
|
27
|
+
() => active && connection.isOpen()
|
|
28
|
+
);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
/** Serialize the whole transaction and finish native finalization before interruption. */
|
|
32
|
+
export const make = (
|
|
33
|
+
connection: Connection,
|
|
34
|
+
mutex: Semaphore.Semaphore
|
|
35
|
+
): SnowflakeClient["transaction"] => {
|
|
36
|
+
const begin = Effect.suspend(() =>
|
|
37
|
+
connection.isOpen()
|
|
38
|
+
? connection.setAutoCommit(false)
|
|
39
|
+
: Effect.fail(failure("closed")())
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
const commit = Effect.suspend(() =>
|
|
43
|
+
connection.isHealthy()
|
|
44
|
+
? call("transaction.commit", () => connection.native.commit())
|
|
45
|
+
: Effect.fail(failure("transaction.invalidated")())
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
const rollback = connection.cleanup("transaction.rollback", () =>
|
|
49
|
+
connection.native.rollback()
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
const restoreAutoCommit = Effect.suspend(() =>
|
|
53
|
+
connection.isHealthy()
|
|
54
|
+
? connection.setAutoCommit(true).pipe(asCleanup)
|
|
55
|
+
: Effect.void
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
const rollbackOnFailure = (exit: Exit.Exit<unknown, unknown>) =>
|
|
59
|
+
Exit.isFailure(exit) ? rollback : Effect.void;
|
|
60
|
+
|
|
61
|
+
const complete = <A, E, R>(self: Effect.Effect<A, E, R>) =>
|
|
62
|
+
self.pipe(
|
|
63
|
+
Effect.tap(() => commit),
|
|
64
|
+
Effect.onExit((exit) => rollbackOnFailure(exit)),
|
|
65
|
+
Effect.ensuring(restoreAutoCommit)
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
const runBody = <A, E, R>(
|
|
69
|
+
use: (session: SnowflakeSession) => Effect.Effect<A, E, R>
|
|
70
|
+
) => acquireSession(connection).pipe(Effect.flatMap(use), Effect.scoped);
|
|
71
|
+
|
|
72
|
+
const transact = <A, E, R>(
|
|
73
|
+
use: (session: SnowflakeSession) => Effect.Effect<A, E, R>
|
|
74
|
+
) =>
|
|
75
|
+
Effect.uninterruptibleMask((restore) => {
|
|
76
|
+
// Only the callback is interruptible; its scope closes before commit.
|
|
77
|
+
const body = restore(runBody(use));
|
|
78
|
+
|
|
79
|
+
return begin.pipe(Effect.andThen(complete(body)));
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
return Effect.fn("Snowflake.transaction")(
|
|
83
|
+
<A, E, R>(use: (session: SnowflakeSession) => Effect.Effect<A, E, R>) =>
|
|
84
|
+
transact(use).pipe(mutex.withPermit, (self) =>
|
|
85
|
+
owned(self, connection.owner, connection.isOpen)
|
|
86
|
+
)
|
|
87
|
+
);
|
|
88
|
+
};
|
package/src/models.ts
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import type { RecordBatch, Table } from "apache-arrow";
|
|
2
|
+
import type { Effect, Redacted, Stream } from "effect";
|
|
3
|
+
|
|
4
|
+
import type { SnowflakeError } from "./snowflake-error.ts";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Snowflake ADBC connection settings. Named fields override `databaseOptions`.
|
|
8
|
+
* Credentials are revealed only while constructing the native database options.
|
|
9
|
+
* @since 0.1.0
|
|
10
|
+
*/
|
|
11
|
+
export interface SnowflakeOptions {
|
|
12
|
+
/** Snowflake account identifier, for example `myorg-myaccount`. */
|
|
13
|
+
readonly account: string;
|
|
14
|
+
/** Login user. Some native authentication methods obtain identity elsewhere. */
|
|
15
|
+
readonly username?: string;
|
|
16
|
+
/** Login password, wrapped in `Redacted`. */
|
|
17
|
+
readonly password?: Redacted.Redacted<string>;
|
|
18
|
+
/** Default database for unqualified SQL names. */
|
|
19
|
+
readonly database?: string;
|
|
20
|
+
/** Default schema within the selected database. */
|
|
21
|
+
readonly schema?: string;
|
|
22
|
+
/** Compute warehouse used to execute queries. */
|
|
23
|
+
readonly warehouse?: string;
|
|
24
|
+
/** Snowflake role to activate for the session. */
|
|
25
|
+
readonly role?: string;
|
|
26
|
+
/** ADBC authentication name; the driver defaults to `auth_snowflake`. These differ from SDK authenticator names. */
|
|
27
|
+
readonly authenticator?:
|
|
28
|
+
| "auth_snowflake"
|
|
29
|
+
| "auth_oauth"
|
|
30
|
+
| "auth_ext_browser"
|
|
31
|
+
| "auth_okta"
|
|
32
|
+
| "auth_jwt"
|
|
33
|
+
| "auth_mfa"
|
|
34
|
+
| "auth_pat"
|
|
35
|
+
| "auth_wif";
|
|
36
|
+
/** Redacted authentication token, passed as the native `auth_token` option. */
|
|
37
|
+
readonly token?: Redacted.Redacted<string>;
|
|
38
|
+
/** Redacted inline PKCS#8 PEM used with `auth_jwt`. */
|
|
39
|
+
readonly privateKey?: Redacted.Redacted<string>;
|
|
40
|
+
/** Redacted passphrase for an encrypted inline PKCS#8 PEM. */
|
|
41
|
+
readonly privateKeyPass?: Redacted.Redacted<string>;
|
|
42
|
+
/** Path to a native-driver-supported private-key file; not inline PEM. */
|
|
43
|
+
readonly privateKeyPath?: string;
|
|
44
|
+
/** Driver name or absolute library/manifest path. Defaults to snowflake. */
|
|
45
|
+
readonly driver?: string;
|
|
46
|
+
/** Additional driver manifest directories, searched before standard locations. */
|
|
47
|
+
readonly manifestSearchPaths?: readonly string[];
|
|
48
|
+
/** Advanced native options. Redact every value, including connection URIs. */
|
|
49
|
+
readonly databaseOptions?: Readonly<
|
|
50
|
+
Record<string, Redacted.Redacted<string>>
|
|
51
|
+
>;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Serialized operations on a single connection. A transaction session expires
|
|
56
|
+
* when its callback exits. Fully consume a stream before reusing that session.
|
|
57
|
+
* @since 0.1.0
|
|
58
|
+
*/
|
|
59
|
+
export interface SnowflakeSession {
|
|
60
|
+
/**
|
|
61
|
+
* Collect a result into an Arrow table, preserving its schema for empty results.
|
|
62
|
+
* @param sql Trusted SQL text with `?` placeholders; values are never interpolated.
|
|
63
|
+
* @param parameters Arrow columns in placeholder order, normally one parameter row.
|
|
64
|
+
* @returns A lazy Effect that owns a fresh statement and reader until collection ends.
|
|
65
|
+
* Native errors become `SnowflakeError`; cleanup failures remain defects.
|
|
66
|
+
*/
|
|
67
|
+
readonly query: (
|
|
68
|
+
sql: string,
|
|
69
|
+
parameters?: Table
|
|
70
|
+
) => Effect.Effect<Table, SnowflakeError>;
|
|
71
|
+
/**
|
|
72
|
+
* Execute DDL or DML using a fresh, scoped statement.
|
|
73
|
+
* @param sql Trusted SQL text with optional `?` placeholders.
|
|
74
|
+
* @param parameters Arrow columns in placeholder order.
|
|
75
|
+
* @returns The affected row count, or `-1` when the native driver cannot report it.
|
|
76
|
+
* Snowflake DDL may implicitly commit; execute it outside transaction callbacks.
|
|
77
|
+
*/
|
|
78
|
+
readonly execute: (
|
|
79
|
+
sql: string,
|
|
80
|
+
parameters?: Table
|
|
81
|
+
) => Effect.Effect<number, SnowflakeError>;
|
|
82
|
+
/**
|
|
83
|
+
* Pull Arrow record batches lazily without collecting the full result.
|
|
84
|
+
* @param sql Trusted SQL text with optional `?` placeholders.
|
|
85
|
+
* @param parameters Arrow columns in placeholder order.
|
|
86
|
+
* @returns A stream that holds the connection until consumption and cleanup finish.
|
|
87
|
+
* Early termination releases the reader before its statement. In-flight native
|
|
88
|
+
* reads settle before interruption completes; ADBC JS provides no cancellation.
|
|
89
|
+
*/
|
|
90
|
+
readonly stream: (
|
|
91
|
+
sql: string,
|
|
92
|
+
parameters?: Table
|
|
93
|
+
) => Stream.Stream<RecordBatch, SnowflakeError>;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* A scoped connection with exclusive transaction callbacks.
|
|
98
|
+
* @since 0.1.0
|
|
99
|
+
*/
|
|
100
|
+
export interface SnowflakeClient extends SnowflakeSession {
|
|
101
|
+
/**
|
|
102
|
+
* Run an Effect inside this client's transaction. Composes with `pipe` and
|
|
103
|
+
* preserves its success, errors, and environment. Captured repositories and
|
|
104
|
+
* client methods automatically use the active session on this fiber.
|
|
105
|
+
*/
|
|
106
|
+
readonly withTransaction: <A, E, R>(
|
|
107
|
+
self: Effect.Effect<A, E, R>
|
|
108
|
+
) => Effect.Effect<A, E | SnowflakeError, R>;
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Disable autocommit, run `use`, and commit its success or roll back its failure.
|
|
112
|
+
* @param use Callback using the supplied session; await all work before returning.
|
|
113
|
+
* @returns The callback value, preserving its error and service requirements.
|
|
114
|
+
* Defects and callback interruption also roll back. Once commit starts, its
|
|
115
|
+
* outcome and autocommit restoration finish before interruption is accepted;
|
|
116
|
+
* an interrupted caller may therefore observe an already committed transaction.
|
|
117
|
+
* Failed commits attempt rollback. Recovered cleanup defects still prevent
|
|
118
|
+
* commit and fail with operation `transaction.invalidated`.
|
|
119
|
+
* Failed rollback or autocommit restoration invalidates the client, and cleanup
|
|
120
|
+
* defects accompany the original failure. Captured client methods resolve the
|
|
121
|
+
* active transaction session. Nested transactions fail with `transaction.nested`.
|
|
122
|
+
*/
|
|
123
|
+
readonly transaction: <A, E, R>(
|
|
124
|
+
use: (session: SnowflakeSession) => Effect.Effect<A, E, R>
|
|
125
|
+
) => Effect.Effect<A, E | SnowflakeError, R>;
|
|
126
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { Schema } from "effect";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Sanitized native failure with an operation label and a generic message.
|
|
5
|
+
* Raw native messages, SQL, bind values, credentials, and original causes are
|
|
6
|
+
* intentionally excluded. Operation failures use the typed error channel;
|
|
7
|
+
* cleanup failures are promoted to defects while preserving the original Cause.
|
|
8
|
+
* @since 0.1.0
|
|
9
|
+
*/
|
|
10
|
+
// oxlint-disable-next-line unicorn/throw-new-error -- Schema.TaggedError is a class factory.
|
|
11
|
+
export class SnowflakeError extends Schema.TaggedError<
|
|
12
|
+
SnowflakeError,
|
|
13
|
+
{ readonly brand: unique symbol }
|
|
14
|
+
>()("SnowflakeError", { message: Schema.String, operation: Schema.String }) {}
|
|
15
|
+
|
|
16
|
+
export const failure = (operation: string) => () =>
|
|
17
|
+
new SnowflakeError({ message: `Snowflake ${operation} failed`, operation });
|