@alchemy.run/turso 0.0.0 → 2.0.0-beta.85
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/LICENSE +201 -0
- package/README.md +68 -4
- package/lib/ApiToken.d.ts +82 -0
- package/lib/ApiToken.d.ts.map +1 -0
- package/lib/ApiToken.js +128 -0
- package/lib/ApiToken.js.map +1 -0
- package/lib/AuthProvider.d.ts +36 -0
- package/lib/AuthProvider.d.ts.map +1 -0
- package/lib/AuthProvider.js +85 -0
- package/lib/AuthProvider.js.map +1 -0
- package/lib/Connect.d.ts +79 -0
- package/lib/Connect.d.ts.map +1 -0
- package/lib/Connect.js +22 -0
- package/lib/Connect.js.map +1 -0
- package/lib/Contributions.d.ts +7 -0
- package/lib/Contributions.d.ts.map +1 -0
- package/lib/Contributions.js +6 -0
- package/lib/Contributions.js.map +1 -0
- package/lib/Credentials.d.ts +39 -0
- package/lib/Credentials.d.ts.map +1 -0
- package/lib/Credentials.js +76 -0
- package/lib/Credentials.js.map +1 -0
- package/lib/Database.d.ts +226 -0
- package/lib/Database.d.ts.map +1 -0
- package/lib/Database.js +277 -0
- package/lib/Database.js.map +1 -0
- package/lib/DatabaseToken.d.ts +104 -0
- package/lib/DatabaseToken.d.ts.map +1 -0
- package/lib/DatabaseToken.js +133 -0
- package/lib/DatabaseToken.js.map +1 -0
- package/lib/Group.d.ts +102 -0
- package/lib/Group.d.ts.map +1 -0
- package/lib/Group.js +171 -0
- package/lib/Group.js.map +1 -0
- package/lib/GroupToken.d.ts +57 -0
- package/lib/GroupToken.d.ts.map +1 -0
- package/lib/GroupToken.js +88 -0
- package/lib/GroupToken.js.map +1 -0
- package/lib/Hrana.d.ts +42 -0
- package/lib/Hrana.d.ts.map +1 -0
- package/lib/Hrana.js +109 -0
- package/lib/Hrana.js.map +1 -0
- package/lib/ManageDatabases.d.ts +73 -0
- package/lib/ManageDatabases.d.ts.map +1 -0
- package/lib/ManageDatabases.js +81 -0
- package/lib/ManageDatabases.js.map +1 -0
- package/lib/OrganizationInvite.d.ts +46 -0
- package/lib/OrganizationInvite.d.ts.map +1 -0
- package/lib/OrganizationInvite.js +72 -0
- package/lib/OrganizationInvite.js.map +1 -0
- package/lib/OrganizationMember.d.ts +47 -0
- package/lib/OrganizationMember.d.ts.map +1 -0
- package/lib/OrganizationMember.js +81 -0
- package/lib/OrganizationMember.js.map +1 -0
- package/lib/OrganizationSettings.d.ts +52 -0
- package/lib/OrganizationSettings.d.ts.map +1 -0
- package/lib/OrganizationSettings.js +93 -0
- package/lib/OrganizationSettings.js.map +1 -0
- package/lib/Providers.d.ts +24 -0
- package/lib/Providers.d.ts.map +1 -0
- package/lib/Providers.js +42 -0
- package/lib/Providers.js.map +1 -0
- package/lib/index.d.ts +14 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +14 -0
- package/lib/index.js.map +1 -0
- package/package.json +54 -3
- package/src/ApiToken.ts +207 -0
- package/src/AuthProvider.ts +113 -0
- package/src/Connect.ts +103 -0
- package/src/Contributions.ts +9 -0
- package/src/Credentials.ts +112 -0
- package/src/Database.ts +519 -0
- package/src/DatabaseToken.ts +213 -0
- package/src/Group.ts +269 -0
- package/src/GroupToken.ts +127 -0
- package/src/Hrana.ts +184 -0
- package/src/ManageDatabases.ts +195 -0
- package/src/OrganizationInvite.ts +118 -0
- package/src/OrganizationMember.ts +122 -0
- package/src/OrganizationSettings.ts +145 -0
- package/src/Providers.ts +67 -0
- package/src/index.ts +20 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@alchemy.run/turso",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "2.0.0-beta.85",
|
|
4
4
|
"description": "Turso databases, groups and tokens for Alchemy.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"alchemy",
|
|
@@ -9,12 +9,63 @@
|
|
|
9
9
|
"libsql",
|
|
10
10
|
"turso"
|
|
11
11
|
],
|
|
12
|
+
"homepage": "https://alchemy.run",
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/alchemy-run/alchemy/issues"
|
|
15
|
+
},
|
|
12
16
|
"license": "Apache-2.0",
|
|
13
17
|
"author": "Sam Goodwin <sam@alchemy.run>",
|
|
14
|
-
"homepage": "https://alchemy.run",
|
|
15
18
|
"repository": {
|
|
16
19
|
"type": "git",
|
|
17
20
|
"url": "git+https://github.com/alchemy-run/alchemy.git",
|
|
18
21
|
"directory": "packages/turso"
|
|
22
|
+
},
|
|
23
|
+
"files": [
|
|
24
|
+
"lib",
|
|
25
|
+
"src"
|
|
26
|
+
],
|
|
27
|
+
"type": "module",
|
|
28
|
+
"sideEffects": false,
|
|
29
|
+
"exports": {
|
|
30
|
+
".": {
|
|
31
|
+
"types": "./lib/index.d.ts",
|
|
32
|
+
"bun": "./src/index.ts",
|
|
33
|
+
"worker": "./src/index.ts",
|
|
34
|
+
"default": "./lib/index.js"
|
|
35
|
+
},
|
|
36
|
+
"./*": {
|
|
37
|
+
"types": "./lib/*.d.ts",
|
|
38
|
+
"bun": "./src/*.ts",
|
|
39
|
+
"worker": "./src/*.ts",
|
|
40
|
+
"default": "./lib/*.js"
|
|
41
|
+
},
|
|
42
|
+
"./package.json": "./package.json"
|
|
43
|
+
},
|
|
44
|
+
"publishConfig": {
|
|
45
|
+
"access": "public"
|
|
46
|
+
},
|
|
47
|
+
"dependencies": {
|
|
48
|
+
"@alchemy.run/sql": "2.0.0-beta.85",
|
|
49
|
+
"@distilled.cloud/core": "1.0.0-rc.18",
|
|
50
|
+
"@distilled.cloud/turso": "1.0.0-rc.18"
|
|
51
|
+
},
|
|
52
|
+
"devDependencies": {
|
|
53
|
+
"@types/bun": "1.4.3",
|
|
54
|
+
"alchemy": "2.0.0-beta.85",
|
|
55
|
+
"effect": "^4.1.0",
|
|
56
|
+
"typescript": "^7.0.2"
|
|
57
|
+
},
|
|
58
|
+
"peerDependencies": {
|
|
59
|
+
"alchemy": "2.0.0-beta.85",
|
|
60
|
+
"effect": "^4.1.0"
|
|
61
|
+
},
|
|
62
|
+
"alchemy": {
|
|
63
|
+
"contributes": [
|
|
64
|
+
"@alchemy.run/turso/Contributions"
|
|
65
|
+
]
|
|
66
|
+
},
|
|
67
|
+
"scripts": {
|
|
68
|
+
"build": "pnpm -r --filter \"{.}...\" run build:package",
|
|
69
|
+
"build:package": "node scripts/build.ts"
|
|
19
70
|
}
|
|
20
|
-
}
|
|
71
|
+
}
|
package/src/ApiToken.ts
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import * as turso from "@distilled.cloud/turso/turso";
|
|
2
|
+
import { Unowned } from "alchemy/AdoptPolicy";
|
|
3
|
+
import { deepEqual, isResolved } from "alchemy/Diff";
|
|
4
|
+
import { createPhysicalName } from "alchemy/PhysicalName";
|
|
5
|
+
import * as Provider from "alchemy/Provider";
|
|
6
|
+
import { Resource } from "alchemy/Resource";
|
|
7
|
+
import * as Data from "effect/Data";
|
|
8
|
+
import * as Effect from "effect/Effect";
|
|
9
|
+
import * as Redacted from "effect/Redacted";
|
|
10
|
+
import { organization } from "./Credentials.ts";
|
|
11
|
+
import type { Providers } from "./Providers.ts";
|
|
12
|
+
|
|
13
|
+
export class ApiTokenUnexpectedResponse extends Data.TaggedError("ApiTokenUnexpectedResponse")<{
|
|
14
|
+
readonly message: string;
|
|
15
|
+
readonly cause?: unknown;
|
|
16
|
+
}> {}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* A permission granted to a group-scoped API token. `read-only` and
|
|
20
|
+
* `full-access` are presets that expand to individual scopes.
|
|
21
|
+
*/
|
|
22
|
+
export type ApiTokenScope =
|
|
23
|
+
| "read"
|
|
24
|
+
| "db:create"
|
|
25
|
+
| "db:delete"
|
|
26
|
+
| "db:configure"
|
|
27
|
+
| "db:mint-token"
|
|
28
|
+
| "db:rotate-creds"
|
|
29
|
+
| "group:configure"
|
|
30
|
+
| "group:mint-token"
|
|
31
|
+
| "group:rotate-creds"
|
|
32
|
+
| "read-only"
|
|
33
|
+
| "full-access";
|
|
34
|
+
|
|
35
|
+
export interface ApiTokenProps {
|
|
36
|
+
/**
|
|
37
|
+
* Token name, unique within your account. If omitted, a unique name is
|
|
38
|
+
* generated from the stack, stage, and logical ID.
|
|
39
|
+
*/
|
|
40
|
+
name?: string;
|
|
41
|
+
/**
|
|
42
|
+
* Restrict the token to one group of the organization (e.g.
|
|
43
|
+
* `group.name`). Requires `scopes`. Omit for an organization-scoped token.
|
|
44
|
+
*/
|
|
45
|
+
group?: string;
|
|
46
|
+
/**
|
|
47
|
+
* Permissions of a group-scoped token. Required (and non-empty) when
|
|
48
|
+
* `group` is set. `db:mint-token` issues new SQL tokens;
|
|
49
|
+
* `db:rotate-creds` invalidates every SQL token of a database.
|
|
50
|
+
*/
|
|
51
|
+
scopes?: ApiTokenScope[];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface ApiTokenAttributes {
|
|
55
|
+
/** Token ID. */
|
|
56
|
+
id: string;
|
|
57
|
+
/** Token name. */
|
|
58
|
+
name: string;
|
|
59
|
+
/** The organization slug the token is restricted to. */
|
|
60
|
+
organization: string;
|
|
61
|
+
/** The group the token is restricted to, if group-scoped. */
|
|
62
|
+
group: string | undefined;
|
|
63
|
+
/** The expanded scopes of a group-scoped token. */
|
|
64
|
+
scopes: string[];
|
|
65
|
+
/** The Platform API token, sent as `Authorization: Bearer <token>`. */
|
|
66
|
+
token: Redacted.Redacted<string>;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export type ApiToken = Resource<
|
|
70
|
+
"Turso.ApiToken",
|
|
71
|
+
ApiTokenProps,
|
|
72
|
+
ApiTokenAttributes,
|
|
73
|
+
never,
|
|
74
|
+
Providers
|
|
75
|
+
>;
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* A Turso API token. It lets an app create and delete groups and databases.
|
|
79
|
+
* To run SQL, use a {@link DatabaseToken} instead. Limit it to one group
|
|
80
|
+
* with `group` and `scopes` to give an app only the access it needs.
|
|
81
|
+
*
|
|
82
|
+
* Turso only shows the token once, when it's created, so Alchemy stores it
|
|
83
|
+
* in state as a `Redacted` value. Changing a prop creates a new token, and
|
|
84
|
+
* destroying it cancels the token.
|
|
85
|
+
* @see https://docs.turso.tech/api-reference/tokens/create
|
|
86
|
+
*
|
|
87
|
+
* ### Creating a Token
|
|
88
|
+
* **Example:** Token for the whole organization
|
|
89
|
+
* ```typescript
|
|
90
|
+
* const token = yield* Turso.ApiToken("Ci", {});
|
|
91
|
+
* ```
|
|
92
|
+
*
|
|
93
|
+
* **Example:** Token that can only create databases in one group
|
|
94
|
+
* ```typescript
|
|
95
|
+
* const token = yield* Turso.ApiToken("Tenants", {
|
|
96
|
+
* group: group.name,
|
|
97
|
+
* scopes: ["read", "db:create", "db:delete", "db:mint-token"],
|
|
98
|
+
* });
|
|
99
|
+
* ```
|
|
100
|
+
*
|
|
101
|
+
* @resource
|
|
102
|
+
* @product API Token
|
|
103
|
+
*/
|
|
104
|
+
export const ApiToken = Resource<ApiToken>("Turso.ApiToken");
|
|
105
|
+
|
|
106
|
+
const tokenName = (id: string, name: string | undefined) =>
|
|
107
|
+
name ? Effect.succeed(name) : createPhysicalName({ id, maxLength: 64, delimiter: "-" });
|
|
108
|
+
|
|
109
|
+
export const ApiTokenProvider = () =>
|
|
110
|
+
Provider.effect(
|
|
111
|
+
ApiToken,
|
|
112
|
+
Effect.gen(function* () {
|
|
113
|
+
const observe = (name: string) =>
|
|
114
|
+
turso
|
|
115
|
+
.listAPITokens({})
|
|
116
|
+
.pipe(Effect.map(({ tokens }) => (tokens ?? []).find((t) => t.name === name)));
|
|
117
|
+
|
|
118
|
+
const revoke = (name: string) =>
|
|
119
|
+
turso.revokeAPIToken({ tokenName: name }).pipe(
|
|
120
|
+
Effect.asVoid,
|
|
121
|
+
Effect.catchTag("NotFound", () => Effect.void),
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
stables: ["id", "name", "organization", "group", "scopes", "token"],
|
|
126
|
+
diff: Effect.fn(function* ({ id, olds, news, output }) {
|
|
127
|
+
if (!isResolved(news)) return undefined;
|
|
128
|
+
const name = output?.name ?? (yield* tokenName(id, olds?.name));
|
|
129
|
+
// Turso tokens are immutable: any change mints a new one.
|
|
130
|
+
if (
|
|
131
|
+
(news.name !== undefined && news.name !== name) ||
|
|
132
|
+
news.group !== olds?.group ||
|
|
133
|
+
!deepEqual(news.scopes ?? [], olds?.scopes ?? [])
|
|
134
|
+
) {
|
|
135
|
+
// A pinned name can't be held by two tokens at once.
|
|
136
|
+
return {
|
|
137
|
+
action: "replace",
|
|
138
|
+
deleteFirst: news.name !== undefined && news.name === name,
|
|
139
|
+
} as const;
|
|
140
|
+
}
|
|
141
|
+
}),
|
|
142
|
+
read: Effect.fn(function* ({ id, olds, output, previous }) {
|
|
143
|
+
const name = output?.name ?? (yield* tokenName(id, olds?.name));
|
|
144
|
+
const observed = yield* observe(name);
|
|
145
|
+
if (!observed) return undefined;
|
|
146
|
+
if (output !== undefined && output.id === observed.id) return output;
|
|
147
|
+
// The generation a delete-first replacement is about to revoke.
|
|
148
|
+
if (previous !== undefined && previous.id === observed.id) return previous;
|
|
149
|
+
// The secret is only revealed at creation, so a token we have no
|
|
150
|
+
// state for can't be adopted with its value — reconcile re-mints.
|
|
151
|
+
return Unowned({
|
|
152
|
+
id: observed.id ?? "",
|
|
153
|
+
name,
|
|
154
|
+
organization: observed.organization ?? "",
|
|
155
|
+
group: observed.group,
|
|
156
|
+
scopes: observed.scopes ?? [],
|
|
157
|
+
token: Redacted.make(""),
|
|
158
|
+
});
|
|
159
|
+
}),
|
|
160
|
+
reconcile: Effect.fn(function* ({ id, news, output, session }) {
|
|
161
|
+
const org = output?.organization ?? (yield* organization);
|
|
162
|
+
const name = output?.name ?? (yield* tokenName(id, news.name));
|
|
163
|
+
|
|
164
|
+
// Observe
|
|
165
|
+
const observed = yield* observe(name);
|
|
166
|
+
if (
|
|
167
|
+
observed &&
|
|
168
|
+
output &&
|
|
169
|
+
Redacted.value(output.token) !== "" &&
|
|
170
|
+
output.id === observed.id
|
|
171
|
+
) {
|
|
172
|
+
return output;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Ensure — a token we hold no secret for (a lost create, or an
|
|
176
|
+
// adoption) is revoked and re-minted.
|
|
177
|
+
if (observed) {
|
|
178
|
+
yield* session.note(`Re-minting API token ${name}...`);
|
|
179
|
+
yield* revoke(name);
|
|
180
|
+
}
|
|
181
|
+
const created = yield* turso.createAPIToken({
|
|
182
|
+
tokenName: name,
|
|
183
|
+
organization: org,
|
|
184
|
+
group: news.group,
|
|
185
|
+
scopes: news.scopes,
|
|
186
|
+
});
|
|
187
|
+
if (!created.token || !created.id) {
|
|
188
|
+
return yield* new ApiTokenUnexpectedResponse({
|
|
189
|
+
message: `Turso returned no token for ${name}`,
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
const minted = yield* observe(name);
|
|
193
|
+
return {
|
|
194
|
+
id: created.id,
|
|
195
|
+
name,
|
|
196
|
+
organization: minted?.organization ?? org,
|
|
197
|
+
group: minted?.group ?? news.group,
|
|
198
|
+
scopes: minted?.scopes ?? [],
|
|
199
|
+
token: created.token,
|
|
200
|
+
};
|
|
201
|
+
}),
|
|
202
|
+
delete: Effect.fn(function* ({ output }) {
|
|
203
|
+
yield* revoke(output.name);
|
|
204
|
+
}),
|
|
205
|
+
};
|
|
206
|
+
}),
|
|
207
|
+
);
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { DEFAULT_API_BASE_URL } from "@distilled.cloud/turso/Credentials";
|
|
2
|
+
import { AuthError } from "alchemy/Auth/AuthProvider";
|
|
3
|
+
import { getEnv, getEnvRedacted } from "alchemy/Auth/Env";
|
|
4
|
+
import {
|
|
5
|
+
makeStoredAuthProvider,
|
|
6
|
+
storedSecret,
|
|
7
|
+
storedValueText,
|
|
8
|
+
type StoredAuthConfig,
|
|
9
|
+
} from "alchemy/Auth/StoredAuthProvider";
|
|
10
|
+
import * as Effect from "effect/Effect";
|
|
11
|
+
import * as Redacted from "effect/Redacted";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Canonical name registered in the AuthProviders registry. Use this key to
|
|
15
|
+
* look up the Turso AuthProvider from inside provider Layers.
|
|
16
|
+
*/
|
|
17
|
+
export const TURSO_AUTH_PROVIDER_NAME = "Turso";
|
|
18
|
+
|
|
19
|
+
export type TursoAuthConfig = StoredAuthConfig;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Resolved Turso Platform API credentials.
|
|
23
|
+
*
|
|
24
|
+
* Turso has no OAuth application flow — the Platform API authenticates
|
|
25
|
+
* with API tokens only (`turso auth api-tokens mint <name>`). The
|
|
26
|
+
* organization is optional: when omitted it is discovered from the token
|
|
27
|
+
* (an organization-scoped token, or an account with a single organization).
|
|
28
|
+
*/
|
|
29
|
+
export interface TursoResolvedCredentials {
|
|
30
|
+
type: "apiToken";
|
|
31
|
+
apiToken: Redacted.Redacted<string>;
|
|
32
|
+
apiBaseUrl: string;
|
|
33
|
+
organization: string | undefined;
|
|
34
|
+
source: { type: TursoAuthConfig["method"] | "env"; details?: string };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const readEnvironment = Effect.gen(function* () {
|
|
38
|
+
const apiToken = yield* getEnvRedacted("TURSO_API_TOKEN");
|
|
39
|
+
const apiKey = yield* getEnvRedacted("TURSO_API_KEY");
|
|
40
|
+
const token = apiToken ?? apiKey;
|
|
41
|
+
if (token === undefined || Redacted.value(token).trim().length === 0) {
|
|
42
|
+
return yield* AuthError.make({
|
|
43
|
+
message: "Turso CI credentials not found. Set TURSO_API_TOKEN or TURSO_API_KEY.",
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
return {
|
|
47
|
+
type: "apiToken" as const,
|
|
48
|
+
apiToken: Redacted.make(Redacted.value(token).trim()),
|
|
49
|
+
apiBaseUrl: (yield* getEnv("TURSO_API_BASE_URL")) ?? DEFAULT_API_BASE_URL,
|
|
50
|
+
organization: yield* getEnv("TURSO_ORGANIZATION"),
|
|
51
|
+
source: {
|
|
52
|
+
type: "env" as const,
|
|
53
|
+
details: apiToken === undefined ? "TURSO_API_KEY" : "TURSO_API_TOKEN",
|
|
54
|
+
},
|
|
55
|
+
} satisfies TursoResolvedCredentials;
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
const tursoAuth = makeStoredAuthProvider<TursoResolvedCredentials>({
|
|
59
|
+
provider: TURSO_AUTH_PROVIDER_NAME,
|
|
60
|
+
fields: [
|
|
61
|
+
{
|
|
62
|
+
name: "apiToken",
|
|
63
|
+
label: "Turso API Token",
|
|
64
|
+
description: "Mint one with `turso auth api-tokens mint alchemy`.",
|
|
65
|
+
secret: true,
|
|
66
|
+
validate: (value) => (value.trim().length === 0 ? "Required" : undefined),
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
name: "organization",
|
|
70
|
+
label: "Turso Organization (slug)",
|
|
71
|
+
description: "Defaults to the token's organization when it has exactly one.",
|
|
72
|
+
optional: true,
|
|
73
|
+
},
|
|
74
|
+
],
|
|
75
|
+
toResolved: (values, source) => ({
|
|
76
|
+
type: "apiToken",
|
|
77
|
+
apiToken: storedSecret(values.apiToken) ?? Redacted.make(""),
|
|
78
|
+
apiBaseUrl: DEFAULT_API_BASE_URL,
|
|
79
|
+
organization: storedValueText(values.organization),
|
|
80
|
+
source: { type: source },
|
|
81
|
+
}),
|
|
82
|
+
readEnvironment,
|
|
83
|
+
environment: [
|
|
84
|
+
{
|
|
85
|
+
name: "TURSO_API_TOKEN",
|
|
86
|
+
required: true,
|
|
87
|
+
secret: true,
|
|
88
|
+
alternatives: ["TURSO_API_KEY"],
|
|
89
|
+
description: "Turso Platform API token.",
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
name: "TURSO_ORGANIZATION",
|
|
93
|
+
required: false,
|
|
94
|
+
description: "Organization slug; defaults to the token's only organization.",
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
name: "TURSO_API_BASE_URL",
|
|
98
|
+
required: false,
|
|
99
|
+
description: "Platform API base URL override.",
|
|
100
|
+
},
|
|
101
|
+
],
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Layer that registers the Turso AuthProvider into the AuthProviders
|
|
106
|
+
* registry when built. Included in `Turso.providers()` so the alchemy CLI
|
|
107
|
+
* (`alchemy profile edit`) can configure it.
|
|
108
|
+
*/
|
|
109
|
+
export const TursoAuth = tursoAuth.layer;
|
|
110
|
+
|
|
111
|
+
/** Schema of Turso's inline API-token values. */
|
|
112
|
+
export const TursoStoredCredentials = tursoAuth.storedSchema;
|
|
113
|
+
export type TursoStoredCredentials = typeof TursoStoredCredentials.Type;
|
package/src/Connect.ts
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import * as Binding from "alchemy/Binding";
|
|
2
|
+
import type { RuntimeContext } from "alchemy/RuntimeContext";
|
|
3
|
+
import * as Effect from "effect/Effect";
|
|
4
|
+
import * as Layer from "effect/Layer";
|
|
5
|
+
import type * as Redacted from "effect/Redacted";
|
|
6
|
+
import type { Database } from "./Database.ts";
|
|
7
|
+
import { DatabaseToken, type TokenAuthorization } from "./DatabaseToken.ts";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* libSQL connection accessors for one Turso database. Resolving them does
|
|
11
|
+
* not open a connection; pass the client to `SQL.LibSQL` or
|
|
12
|
+
* `Drizzle.LibSQL`, or read `url` / `authToken` for `@libsql/client`.
|
|
13
|
+
*/
|
|
14
|
+
export interface ConnectClient {
|
|
15
|
+
/** libSQL URL of the database (`libsql://<hostname>`). */
|
|
16
|
+
url: Effect.Effect<string, never, RuntimeContext>;
|
|
17
|
+
/** SQL auth token for the database. */
|
|
18
|
+
authToken: Effect.Effect<Redacted.Redacted<string>, never, RuntimeContext>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface ConnectOptions {
|
|
22
|
+
/**
|
|
23
|
+
* Access level of the token minted for this host.
|
|
24
|
+
* @default "full-access"
|
|
25
|
+
*/
|
|
26
|
+
authorization?: TokenAuthorization;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Connect to a Turso database from a Worker, Lambda, or other host. The
|
|
31
|
+
* host gets the database's URL and its own {@link DatabaseToken}. Your
|
|
32
|
+
* Turso API token stays on your machine.
|
|
33
|
+
*
|
|
34
|
+
* ### Querying a Database
|
|
35
|
+
* **Example:** Effect SQL over a Turso database
|
|
36
|
+
* ```typescript
|
|
37
|
+
* Effect.gen(function* () {
|
|
38
|
+
* const conn = yield* Turso.Connect(Db);
|
|
39
|
+
* const sql = yield* SQL.LibSQL(conn);
|
|
40
|
+
* return {
|
|
41
|
+
* fetch: Effect.gen(function* () {
|
|
42
|
+
* return yield* HttpServerResponse.json(yield* sql`SELECT 1 AS value`);
|
|
43
|
+
* }),
|
|
44
|
+
* };
|
|
45
|
+
* }).pipe(Effect.provide(Turso.ConnectHttp));
|
|
46
|
+
* ```
|
|
47
|
+
*
|
|
48
|
+
* **Example:** Drizzle over a Turso database
|
|
49
|
+
* ```typescript
|
|
50
|
+
* const conn = yield* Turso.Connect(Db);
|
|
51
|
+
* const db = yield* Drizzle.LibSQL(conn, { relations });
|
|
52
|
+
* ```
|
|
53
|
+
*
|
|
54
|
+
* **Example:** AWS Lambda (install the native libSQL client)
|
|
55
|
+
* ```typescript
|
|
56
|
+
* export default class Api extends Lambda.Function<Api>()(
|
|
57
|
+
* "Api",
|
|
58
|
+
* { main: import.meta.url, build: { install: ["@libsql/client"] } },
|
|
59
|
+
* Effect.gen(function* () {
|
|
60
|
+
* const sql = yield* SQL.LibSQL(yield* Turso.Connect(Db));
|
|
61
|
+
* // ...
|
|
62
|
+
* }).pipe(Effect.provide(Turso.ConnectHttp)),
|
|
63
|
+
* ) {}
|
|
64
|
+
* ```
|
|
65
|
+
*
|
|
66
|
+
* **Example:** Read-only access
|
|
67
|
+
* ```typescript
|
|
68
|
+
* const conn = yield* Turso.Connect(Db, { authorization: "read-only" });
|
|
69
|
+
* ```
|
|
70
|
+
*
|
|
71
|
+
* @binding
|
|
72
|
+
* @product Database
|
|
73
|
+
*/
|
|
74
|
+
export interface Connect extends Binding.Service<
|
|
75
|
+
Connect,
|
|
76
|
+
"Turso.Connect",
|
|
77
|
+
(database: Database, options?: ConnectOptions) => Effect.Effect<ConnectClient>
|
|
78
|
+
> {}
|
|
79
|
+
|
|
80
|
+
export const Connect = Binding.Service<Connect>("Turso.Connect");
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Host-independent implementation: the URL and a per-host token are bound
|
|
84
|
+
* as environment values, so it works on any Platform host.
|
|
85
|
+
*/
|
|
86
|
+
export const ConnectHttp = Layer.effect(
|
|
87
|
+
Connect,
|
|
88
|
+
Effect.gen(function* () {
|
|
89
|
+
const Token = yield* DatabaseToken;
|
|
90
|
+
return Effect.fn(function* (database: Database, options?: ConnectOptions) {
|
|
91
|
+
const host = yield* Binding.Host;
|
|
92
|
+
const authorization = options?.authorization ?? "full-access";
|
|
93
|
+
const token = yield* Token(
|
|
94
|
+
`${host?.LogicalId ?? ""}${database.LogicalId}${authorization === "read-only" ? "ReadOnly" : ""}Token`,
|
|
95
|
+
{ database: database.name, authorization },
|
|
96
|
+
);
|
|
97
|
+
return {
|
|
98
|
+
url: yield* database.url,
|
|
99
|
+
authToken: yield* token.token,
|
|
100
|
+
} satisfies ConnectClient;
|
|
101
|
+
});
|
|
102
|
+
}),
|
|
103
|
+
);
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { ConfigError } from "@distilled.cloud/core/errors";
|
|
2
|
+
import { Credentials, DEFAULT_API_BASE_URL } from "@distilled.cloud/turso/Credentials";
|
|
3
|
+
import * as turso from "@distilled.cloud/turso/turso";
|
|
4
|
+
import { lazyCredentials, orDieCredentialsUnavailable } from "alchemy/Auth/Resolve";
|
|
5
|
+
import * as Context from "effect/Context";
|
|
6
|
+
import * as Effect from "effect/Effect";
|
|
7
|
+
import * as HttpClient from "effect/http/HttpClient";
|
|
8
|
+
import * as Layer from "effect/Layer";
|
|
9
|
+
import * as Redacted from "effect/Redacted";
|
|
10
|
+
import { TURSO_AUTH_PROVIDER_NAME, type TursoResolvedCredentials } from "./AuthProvider.ts";
|
|
11
|
+
|
|
12
|
+
export {
|
|
13
|
+
Credentials,
|
|
14
|
+
CredentialsFromEnv,
|
|
15
|
+
DEFAULT_API_BASE_URL,
|
|
16
|
+
} from "@distilled.cloud/turso/Credentials";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The Turso organization slug every org-scoped Platform API call targets.
|
|
20
|
+
* Holds an effect so the slug is resolved (and, when not configured,
|
|
21
|
+
* discovered from the API token) on first use, never at layer build.
|
|
22
|
+
*/
|
|
23
|
+
export class Organization extends Context.Service<Organization, Effect.Effect<string>>()(
|
|
24
|
+
"Turso.Organization",
|
|
25
|
+
) {}
|
|
26
|
+
|
|
27
|
+
/** The organization slug for the current Turso credentials. */
|
|
28
|
+
export const organization = Effect.gen(function* () {
|
|
29
|
+
return yield* yield* Organization;
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Discover the organization an API token acts on: the token's own
|
|
34
|
+
* organization when it is org-scoped, else the account's only organization.
|
|
35
|
+
*/
|
|
36
|
+
const discoverOrganization = Effect.gen(function* () {
|
|
37
|
+
const orgs = yield* turso.listOrganizations({});
|
|
38
|
+
if (orgs.length === 1 && orgs[0]?.slug) {
|
|
39
|
+
return orgs[0].slug;
|
|
40
|
+
}
|
|
41
|
+
return yield* new ConfigError({
|
|
42
|
+
message:
|
|
43
|
+
orgs.length === 0
|
|
44
|
+
? "Turso: this API token has no organizations."
|
|
45
|
+
: `Turso: this API token can access ${orgs.length} organizations (${orgs
|
|
46
|
+
.map((org) => org.slug)
|
|
47
|
+
.join(", ")}). Set TURSO_ORGANIZATION or configure the organization in your profile.`,
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
const make = (
|
|
52
|
+
resolve: Effect.Effect<TursoResolvedCredentials, ConfigError>,
|
|
53
|
+
): Effect.Effect<Context.Context<Credentials | Organization>, never, HttpClient.HttpClient> =>
|
|
54
|
+
Effect.gen(function* () {
|
|
55
|
+
const http = yield* HttpClient.HttpClient;
|
|
56
|
+
const credentials = Effect.map(resolve, (creds) => ({
|
|
57
|
+
apiKey: creds.apiToken,
|
|
58
|
+
apiBaseUrl: creds.apiBaseUrl,
|
|
59
|
+
}));
|
|
60
|
+
// `Organization` is typed as never failing, so a credentials failure dies
|
|
61
|
+
// here; the engine still reports it as `CredentialsRequired`.
|
|
62
|
+
const organization = yield* resolve.pipe(
|
|
63
|
+
Effect.orDie,
|
|
64
|
+
Effect.flatMap((creds) =>
|
|
65
|
+
creds.organization !== undefined && creds.organization.length > 0
|
|
66
|
+
? Effect.succeed(creds.organization)
|
|
67
|
+
: discoverOrganization.pipe(
|
|
68
|
+
Effect.provideService(Credentials, credentials),
|
|
69
|
+
Effect.provideService(HttpClient.HttpClient, http),
|
|
70
|
+
orDieCredentialsUnavailable(TURSO_AUTH_PROVIDER_NAME),
|
|
71
|
+
),
|
|
72
|
+
),
|
|
73
|
+
Effect.cached,
|
|
74
|
+
);
|
|
75
|
+
return Context.make(Credentials, credentials).pipe(Context.add(Organization, organization));
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Build Turso `Credentials` + `Organization` from an explicit API token.
|
|
80
|
+
* Useful for tests or when the caller already has a token in hand.
|
|
81
|
+
*
|
|
82
|
+
* @example
|
|
83
|
+
* ```ts
|
|
84
|
+
* Effect.provide(Turso.fromToken({ apiToken: "...", organization: "my-org" }))
|
|
85
|
+
* ```
|
|
86
|
+
*/
|
|
87
|
+
export const fromToken = (input: {
|
|
88
|
+
apiToken: string | Redacted.Redacted<string>;
|
|
89
|
+
organization?: string;
|
|
90
|
+
apiBaseUrl?: string;
|
|
91
|
+
}) =>
|
|
92
|
+
Effect.succeed<TursoResolvedCredentials>({
|
|
93
|
+
type: "apiToken",
|
|
94
|
+
apiToken: typeof input.apiToken === "string" ? Redacted.make(input.apiToken) : input.apiToken,
|
|
95
|
+
apiBaseUrl: input.apiBaseUrl ?? DEFAULT_API_BASE_URL,
|
|
96
|
+
organization: input.organization,
|
|
97
|
+
source: { type: "stored" },
|
|
98
|
+
}).pipe(make, Layer.effectContext);
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Build Turso `Credentials` + `Organization` that resolve through the
|
|
102
|
+
* Alchemy AuthProvider: environment credentials (`TURSO_API_TOKEN`) when
|
|
103
|
+
* present, otherwise the selected profile. Resolution is deferred until the
|
|
104
|
+
* first API call, so building the provider layers never needs a profile.
|
|
105
|
+
*/
|
|
106
|
+
export const fromAuthProvider = () =>
|
|
107
|
+
Layer.effectContext(
|
|
108
|
+
lazyCredentials<TursoResolvedCredentials, ConfigError>()(
|
|
109
|
+
TURSO_AUTH_PROVIDER_NAME,
|
|
110
|
+
(creds) => creds,
|
|
111
|
+
).pipe(Effect.flatMap(make)),
|
|
112
|
+
);
|