@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/src/Hrana.ts
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { MigrationError, type SqlExecutor } from "@alchemy.run/sql/Migrations";
|
|
2
|
+
import { splitSqlStatements } from "@alchemy.run/sql/SqlFile";
|
|
3
|
+
import * as Data from "effect/Data";
|
|
4
|
+
import * as Effect from "effect/Effect";
|
|
5
|
+
import * as HttpClient from "effect/http/HttpClient";
|
|
6
|
+
import * as HttpClientRequest from "effect/http/HttpClientRequest";
|
|
7
|
+
import * as Redacted from "effect/Redacted";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* A minimal Hrana-over-HTTP client (`POST /v2/pipeline`) — the SQL wire
|
|
11
|
+
* protocol every Turso database speaks. Deploy-time work (migrations, SQL
|
|
12
|
+
* imports) uses it instead of `@libsql/client`, so the provider needs no
|
|
13
|
+
* driver and runs anywhere `HttpClient` does.
|
|
14
|
+
*
|
|
15
|
+
* @see https://github.com/tursodatabase/libsql/blob/main/docs/HRANA_3_SPEC.md
|
|
16
|
+
*/
|
|
17
|
+
export class HranaError extends Data.TaggedError("Turso::HranaError")<{
|
|
18
|
+
message: string;
|
|
19
|
+
code?: string;
|
|
20
|
+
cause?: unknown;
|
|
21
|
+
}> {}
|
|
22
|
+
|
|
23
|
+
type HranaValue =
|
|
24
|
+
| { type: "null" }
|
|
25
|
+
| { type: "integer"; value: string }
|
|
26
|
+
| { type: "float"; value: number }
|
|
27
|
+
| { type: "text"; value: string }
|
|
28
|
+
| { type: "blob"; base64: string };
|
|
29
|
+
|
|
30
|
+
interface HranaStmt {
|
|
31
|
+
sql: string;
|
|
32
|
+
args?: HranaValue[];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface HranaExecuteResult {
|
|
36
|
+
cols: Array<{ name: string | null }>;
|
|
37
|
+
rows: HranaValue[][];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
interface HranaError_ {
|
|
41
|
+
message: string;
|
|
42
|
+
code?: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
interface PipelineResponse {
|
|
46
|
+
results: Array<
|
|
47
|
+
| { type: "ok"; response: { type: string; result?: unknown } }
|
|
48
|
+
| { type: "error"; error: HranaError_ }
|
|
49
|
+
>;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const toHranaValue = (value: unknown): HranaValue => {
|
|
53
|
+
if (value === null || value === undefined) return { type: "null" };
|
|
54
|
+
if (typeof value === "bigint") return { type: "integer", value: value.toString() };
|
|
55
|
+
if (typeof value === "number") {
|
|
56
|
+
return Number.isInteger(value)
|
|
57
|
+
? { type: "integer", value: value.toString() }
|
|
58
|
+
: { type: "float", value };
|
|
59
|
+
}
|
|
60
|
+
if (typeof value === "boolean") return { type: "integer", value: value ? "1" : "0" };
|
|
61
|
+
if (value instanceof Uint8Array) {
|
|
62
|
+
return { type: "blob", base64: Buffer.from(value).toString("base64") };
|
|
63
|
+
}
|
|
64
|
+
if (typeof value === "string") return { type: "text", value };
|
|
65
|
+
if (value instanceof Date) return { type: "text", value: value.toISOString() };
|
|
66
|
+
return { type: "text", value: JSON.stringify(value) };
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const fromHranaValue = (value: HranaValue): unknown => {
|
|
70
|
+
switch (value.type) {
|
|
71
|
+
case "null":
|
|
72
|
+
return null;
|
|
73
|
+
case "integer":
|
|
74
|
+
return Number(value.value);
|
|
75
|
+
case "float":
|
|
76
|
+
return value.value;
|
|
77
|
+
case "text":
|
|
78
|
+
return value.value;
|
|
79
|
+
case "blob":
|
|
80
|
+
return Buffer.from(value.base64, "base64");
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const toRows = (result: HranaExecuteResult): Array<Record<string, unknown>> =>
|
|
85
|
+
result.rows.map((row) =>
|
|
86
|
+
Object.fromEntries(
|
|
87
|
+
result.cols.map((col, i) => [col.name ?? String(i), fromHranaValue(row[i]!)]),
|
|
88
|
+
),
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
/** Connection details for one Turso database. */
|
|
92
|
+
export interface HranaConnection {
|
|
93
|
+
/** `https://<hostname>` of the database. */
|
|
94
|
+
url: string;
|
|
95
|
+
/** A database auth token (JWT). */
|
|
96
|
+
authToken: Redacted.Redacted<string>;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const pipeline = (connection: HranaConnection, requests: ReadonlyArray<unknown>) =>
|
|
100
|
+
Effect.gen(function* () {
|
|
101
|
+
const client = yield* HttpClient.HttpClient;
|
|
102
|
+
const response = yield* client
|
|
103
|
+
.execute(
|
|
104
|
+
HttpClientRequest.post(`${connection.url.replace(/\/$/, "")}/v2/pipeline`).pipe(
|
|
105
|
+
HttpClientRequest.bearerToken(Redacted.value(connection.authToken)),
|
|
106
|
+
HttpClientRequest.bodyJsonUnsafe({ requests: [...requests, { type: "close" }] }),
|
|
107
|
+
),
|
|
108
|
+
)
|
|
109
|
+
.pipe(Effect.mapError((cause) => new HranaError({ message: String(cause), cause })));
|
|
110
|
+
if (response.status < 200 || response.status >= 300) {
|
|
111
|
+
const text = yield* response.text.pipe(Effect.orElseSucceed(() => ""));
|
|
112
|
+
return yield* new HranaError({
|
|
113
|
+
message: `Turso SQL request failed with HTTP ${response.status}: ${text}`,
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
const body = (yield* response.json.pipe(
|
|
117
|
+
Effect.mapError((cause) => new HranaError({ message: String(cause), cause })),
|
|
118
|
+
)) as unknown as PipelineResponse;
|
|
119
|
+
return body.results.slice(0, requests.length);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
/** Execute one statement and return its rows as objects. */
|
|
123
|
+
export const query = (
|
|
124
|
+
connection: HranaConnection,
|
|
125
|
+
sql: string,
|
|
126
|
+
params?: ReadonlyArray<unknown>,
|
|
127
|
+
): Effect.Effect<Array<Record<string, unknown>>, HranaError, HttpClient.HttpClient> =>
|
|
128
|
+
Effect.gen(function* () {
|
|
129
|
+
const stmt: HranaStmt = { sql, args: (params ?? []).map(toHranaValue) };
|
|
130
|
+
const [result] = yield* pipeline(connection, [{ type: "execute", stmt }]);
|
|
131
|
+
if (result === undefined) {
|
|
132
|
+
return yield* new HranaError({ message: "Turso returned no result" });
|
|
133
|
+
}
|
|
134
|
+
if (result.type === "error") {
|
|
135
|
+
return yield* new HranaError({ message: result.error.message, code: result.error.code });
|
|
136
|
+
}
|
|
137
|
+
return toRows(result.response.result as HranaExecuteResult);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Execute statements atomically. Each entry may hold several
|
|
142
|
+
* `;`-separated statements (a whole migration file or seed script), so they
|
|
143
|
+
* run as one Hrana `sequence` wrapped in `BEGIN`/`COMMIT`. A failing
|
|
144
|
+
* statement stops the sequence before `COMMIT`, and closing the stream
|
|
145
|
+
* rolls the open transaction back.
|
|
146
|
+
*/
|
|
147
|
+
export const transaction = (
|
|
148
|
+
connection: HranaConnection,
|
|
149
|
+
statements: ReadonlyArray<string>,
|
|
150
|
+
): Effect.Effect<void, HranaError, HttpClient.HttpClient> =>
|
|
151
|
+
Effect.gen(function* () {
|
|
152
|
+
const body = statements.flatMap(splitSqlStatements);
|
|
153
|
+
if (body.length === 0) return;
|
|
154
|
+
const sql = ["BEGIN", ...body.map((s) => s.replace(/;\s*$/, "")), "COMMIT"].join(";\n");
|
|
155
|
+
const [result] = yield* pipeline(connection, [{ type: "sequence", sql }]);
|
|
156
|
+
if (result === undefined) {
|
|
157
|
+
return yield* new HranaError({ message: "Turso returned no result" });
|
|
158
|
+
}
|
|
159
|
+
if (result.type === "error") {
|
|
160
|
+
return yield* new HranaError({ message: result.error.message, code: result.error.code });
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
/** Adapt a Turso database connection into the migration registry's executor. */
|
|
165
|
+
export const makeMigrationExecutor = (
|
|
166
|
+
connection: HranaConnection,
|
|
167
|
+
client: HttpClient.HttpClient,
|
|
168
|
+
): SqlExecutor => {
|
|
169
|
+
const toMigrationError = (error: HranaError) =>
|
|
170
|
+
new MigrationError({ message: `turso: ${error.message}`, cause: error });
|
|
171
|
+
return {
|
|
172
|
+
dialect: "sqlite",
|
|
173
|
+
query: (sql, params) =>
|
|
174
|
+
query(connection, sql, params).pipe(
|
|
175
|
+
Effect.provideService(HttpClient.HttpClient, client),
|
|
176
|
+
Effect.mapError(toMigrationError),
|
|
177
|
+
),
|
|
178
|
+
batch: (statements) =>
|
|
179
|
+
transaction(connection, statements).pipe(
|
|
180
|
+
Effect.provideService(HttpClient.HttpClient, client),
|
|
181
|
+
Effect.mapError(toMigrationError),
|
|
182
|
+
),
|
|
183
|
+
};
|
|
184
|
+
};
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { Credentials, DEFAULT_API_BASE_URL } from "@distilled.cloud/turso/Credentials";
|
|
2
|
+
import * as turso from "@distilled.cloud/turso/turso";
|
|
3
|
+
import * as Binding from "alchemy/Binding";
|
|
4
|
+
import type { RuntimeContext } from "alchemy/RuntimeContext";
|
|
5
|
+
import * as Effect from "effect/Effect";
|
|
6
|
+
import * as FetchHttpClient from "effect/http/FetchHttpClient";
|
|
7
|
+
import type * as HttpClient from "effect/http/HttpClient";
|
|
8
|
+
import * as Layer from "effect/Layer";
|
|
9
|
+
import { ApiToken } from "./ApiToken.ts";
|
|
10
|
+
import type { ConnectClient } from "./Connect.ts";
|
|
11
|
+
import type { DatabaseSeed } from "./Database.ts";
|
|
12
|
+
import type { Group } from "./Group.ts";
|
|
13
|
+
import { GroupToken } from "./GroupToken.ts";
|
|
14
|
+
|
|
15
|
+
/** A database in the managed group. */
|
|
16
|
+
export interface ManagedDatabase {
|
|
17
|
+
/** Database name. */
|
|
18
|
+
name: string;
|
|
19
|
+
/** Database UUID. */
|
|
20
|
+
dbId: string;
|
|
21
|
+
/** DNS hostname of the database. */
|
|
22
|
+
hostname: string;
|
|
23
|
+
/** libSQL URL (`libsql://<hostname>`). */
|
|
24
|
+
url: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface CreateManagedDatabaseOptions {
|
|
28
|
+
/** Seed the new database from an existing database (e.g. a template). */
|
|
29
|
+
seed?: DatabaseSeed;
|
|
30
|
+
/** Maximum size of the database, e.g. `"256mb"`. */
|
|
31
|
+
sizeLimit?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface ManageDatabasesClient {
|
|
35
|
+
/** Create a database in the group. */
|
|
36
|
+
create(
|
|
37
|
+
name: string,
|
|
38
|
+
options?: CreateManagedDatabaseOptions,
|
|
39
|
+
): Effect.Effect<ManagedDatabase, turso.CreateDatabaseError, RuntimeContext>;
|
|
40
|
+
/** Look up a database by name; `undefined` when it doesn't exist. */
|
|
41
|
+
get(
|
|
42
|
+
name: string,
|
|
43
|
+
): Effect.Effect<ManagedDatabase | undefined, turso.GetDatabaseError, RuntimeContext>;
|
|
44
|
+
/** List every database in the group. */
|
|
45
|
+
list(): Effect.Effect<ManagedDatabase[], turso.ListDatabasesError, RuntimeContext>;
|
|
46
|
+
/** Delete a database. Deleting a missing database succeeds. */
|
|
47
|
+
delete(name: string): Effect.Effect<void, turso.DeleteDatabaseError, RuntimeContext>;
|
|
48
|
+
/**
|
|
49
|
+
* libSQL connection accessors for a database in the group, authorized by
|
|
50
|
+
* a group-wide SQL token. Pass to `SQL.LibSQL` or `Drizzle.LibSQL`.
|
|
51
|
+
*/
|
|
52
|
+
connect(name: string): ConnectClient;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Let a Worker create, list, delete, and query databases in one Turso
|
|
57
|
+
* {@link Group}. Use it to give each customer their own database.
|
|
58
|
+
*
|
|
59
|
+
* The Worker gets an {@link ApiToken} that only works on that group, and a
|
|
60
|
+
* {@link GroupToken} for running SQL. It can't touch databases in any other
|
|
61
|
+
* group, and your own Turso API token stays on your machine.
|
|
62
|
+
*
|
|
63
|
+
* ### Database per tenant
|
|
64
|
+
* **Example:** Create a tenant database on signup and query it
|
|
65
|
+
* ```typescript
|
|
66
|
+
* Effect.gen(function* () {
|
|
67
|
+
* const tenants = yield* Turso.ManageDatabases(Tenants);
|
|
68
|
+
* return {
|
|
69
|
+
* fetch: Effect.gen(function* () {
|
|
70
|
+
* const db = yield* tenants.create("acme");
|
|
71
|
+
* const sql = yield* SQL.LibSQL(tenants.connect(db.name));
|
|
72
|
+
* yield* sql`CREATE TABLE IF NOT EXISTS notes (body TEXT)`;
|
|
73
|
+
* return HttpServerResponse.text(db.url);
|
|
74
|
+
* }),
|
|
75
|
+
* };
|
|
76
|
+
* }).pipe(Effect.provide(Turso.ManageDatabasesHttp));
|
|
77
|
+
* ```
|
|
78
|
+
*
|
|
79
|
+
* @binding
|
|
80
|
+
* @product Group
|
|
81
|
+
*/
|
|
82
|
+
export interface ManageDatabases extends Binding.Service<
|
|
83
|
+
ManageDatabases,
|
|
84
|
+
"Turso.ManageDatabases",
|
|
85
|
+
(group: Group) => Effect.Effect<ManageDatabasesClient>
|
|
86
|
+
> {}
|
|
87
|
+
|
|
88
|
+
export const ManageDatabases = Binding.Service<ManageDatabases>("Turso.ManageDatabases");
|
|
89
|
+
|
|
90
|
+
/** Implementation over the Turso Platform API, usable from any Platform host. */
|
|
91
|
+
export const ManageDatabasesHttp = Layer.effect(
|
|
92
|
+
ManageDatabases,
|
|
93
|
+
Effect.gen(function* () {
|
|
94
|
+
const PlatformToken = yield* ApiToken;
|
|
95
|
+
const SqlToken = yield* GroupToken;
|
|
96
|
+
return Effect.fn(function* (group: Group) {
|
|
97
|
+
const host = yield* Binding.Host;
|
|
98
|
+
const prefix = `${host?.LogicalId ?? ""}${group.LogicalId}`;
|
|
99
|
+
const apiToken = yield* PlatformToken(`${prefix}ManageToken`, {
|
|
100
|
+
group: group.name,
|
|
101
|
+
scopes: ["read", "db:create", "db:delete", "db:configure"],
|
|
102
|
+
});
|
|
103
|
+
const sqlToken = yield* SqlToken(`${prefix}SqlToken`, {
|
|
104
|
+
group: group.name,
|
|
105
|
+
});
|
|
106
|
+
const apiKey = yield* apiToken.token;
|
|
107
|
+
const authToken = yield* sqlToken.token;
|
|
108
|
+
const organization = yield* group.organization;
|
|
109
|
+
const groupName = yield* group.name;
|
|
110
|
+
const location = yield* group.location;
|
|
111
|
+
|
|
112
|
+
const authorize = <A, E>(
|
|
113
|
+
effect: Effect.Effect<A, E, Credentials | HttpClient.HttpClient>,
|
|
114
|
+
): Effect.Effect<A, E, RuntimeContext> =>
|
|
115
|
+
effect.pipe(
|
|
116
|
+
Effect.provide(
|
|
117
|
+
Layer.mergeAll(
|
|
118
|
+
Layer.succeed(
|
|
119
|
+
Credentials,
|
|
120
|
+
Effect.map(apiKey, (key) => ({ apiKey: key, apiBaseUrl: DEFAULT_API_BASE_URL })),
|
|
121
|
+
),
|
|
122
|
+
FetchHttpClient.layer,
|
|
123
|
+
),
|
|
124
|
+
),
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
const toManaged = (db: {
|
|
128
|
+
Name?: string;
|
|
129
|
+
DbId?: string;
|
|
130
|
+
Hostname?: string;
|
|
131
|
+
}): ManagedDatabase => ({
|
|
132
|
+
name: db.Name ?? "",
|
|
133
|
+
dbId: db.DbId ?? "",
|
|
134
|
+
hostname: db.Hostname ?? "",
|
|
135
|
+
url: `libsql://${db.Hostname}`,
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
const client: ManageDatabasesClient = {
|
|
139
|
+
create: (name, options) =>
|
|
140
|
+
authorize(
|
|
141
|
+
Effect.gen(function* () {
|
|
142
|
+
const { database } = yield* turso.createDatabase({
|
|
143
|
+
organizationSlug: yield* organization,
|
|
144
|
+
name,
|
|
145
|
+
group: yield* groupName,
|
|
146
|
+
seed: options?.seed,
|
|
147
|
+
size_limit: options?.sizeLimit,
|
|
148
|
+
});
|
|
149
|
+
return toManaged(database ?? {});
|
|
150
|
+
}),
|
|
151
|
+
),
|
|
152
|
+
get: (name) =>
|
|
153
|
+
authorize(
|
|
154
|
+
Effect.gen(function* () {
|
|
155
|
+
const { database } = yield* turso.getDatabase({
|
|
156
|
+
organizationSlug: yield* organization,
|
|
157
|
+
databaseName: name,
|
|
158
|
+
});
|
|
159
|
+
return database && database.group === (yield* groupName)
|
|
160
|
+
? toManaged(database)
|
|
161
|
+
: undefined;
|
|
162
|
+
}).pipe(Effect.catchTag("NotFound", () => Effect.undefined)),
|
|
163
|
+
),
|
|
164
|
+
list: () =>
|
|
165
|
+
authorize(
|
|
166
|
+
Effect.gen(function* () {
|
|
167
|
+
const { databases } = yield* turso.listDatabases({
|
|
168
|
+
organizationSlug: yield* organization,
|
|
169
|
+
group: yield* groupName,
|
|
170
|
+
});
|
|
171
|
+
return (databases ?? []).map(toManaged);
|
|
172
|
+
}),
|
|
173
|
+
),
|
|
174
|
+
delete: (name) =>
|
|
175
|
+
authorize(
|
|
176
|
+
Effect.gen(function* () {
|
|
177
|
+
yield* turso.deleteDatabase({
|
|
178
|
+
organizationSlug: yield* organization,
|
|
179
|
+
databaseName: name,
|
|
180
|
+
});
|
|
181
|
+
}).pipe(Effect.catchTag("NotFound", () => Effect.void)),
|
|
182
|
+
),
|
|
183
|
+
connect: (name) => ({
|
|
184
|
+
// Turso hostnames are `<database>-<organization>.<location>.turso.io`,
|
|
185
|
+
// so the URL is derived without a Platform API round-trip.
|
|
186
|
+
url: Effect.gen(function* () {
|
|
187
|
+
return `libsql://${name}-${yield* organization}.${yield* location}.turso.io`;
|
|
188
|
+
}),
|
|
189
|
+
authToken,
|
|
190
|
+
}),
|
|
191
|
+
};
|
|
192
|
+
return client;
|
|
193
|
+
});
|
|
194
|
+
}),
|
|
195
|
+
);
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import * as turso from "@distilled.cloud/turso/turso";
|
|
2
|
+
import { isResolved } from "alchemy/Diff";
|
|
3
|
+
import * as Provider from "alchemy/Provider";
|
|
4
|
+
import { Resource } from "alchemy/Resource";
|
|
5
|
+
import * as Effect from "effect/Effect";
|
|
6
|
+
import { organization } from "./Credentials.ts";
|
|
7
|
+
import type { Providers } from "./Providers.ts";
|
|
8
|
+
|
|
9
|
+
/** Role granted to an invited or added organization member. */
|
|
10
|
+
export type MemberRole = "admin" | "member" | "viewer";
|
|
11
|
+
|
|
12
|
+
export interface OrganizationInviteProps {
|
|
13
|
+
/** Email address to invite. Changing it replaces the invite. */
|
|
14
|
+
email: string;
|
|
15
|
+
/**
|
|
16
|
+
* Role the invitee receives on accepting.
|
|
17
|
+
* @default "member"
|
|
18
|
+
*/
|
|
19
|
+
role?: MemberRole;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface OrganizationInviteAttributes {
|
|
23
|
+
/** The invited email address. */
|
|
24
|
+
email: string;
|
|
25
|
+
/** The organization slug the invite is for. */
|
|
26
|
+
organization: string;
|
|
27
|
+
/** Role the invitee receives on accepting. */
|
|
28
|
+
role: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export type OrganizationInvite = Resource<
|
|
32
|
+
"Turso.OrganizationInvite",
|
|
33
|
+
OrganizationInviteProps,
|
|
34
|
+
OrganizationInviteAttributes,
|
|
35
|
+
never,
|
|
36
|
+
Providers
|
|
37
|
+
>;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* An invitation for someone to join your Turso organization by email.
|
|
41
|
+
* Changing `role` re-sends the invite with the new role; destroying the
|
|
42
|
+
* resource withdraws a pending invite. Requires a team organization on a
|
|
43
|
+
* paid plan — the Starter plan rejects invites with `BadRequest`.
|
|
44
|
+
* @see https://docs.turso.tech/api-reference/organizations/invites/create
|
|
45
|
+
*
|
|
46
|
+
* ### Inviting Members
|
|
47
|
+
* **Example:** Invite a teammate as an admin
|
|
48
|
+
* ```typescript
|
|
49
|
+
* yield* Turso.OrganizationInvite("Alice", {
|
|
50
|
+
* email: "alice@example.com",
|
|
51
|
+
* role: "admin",
|
|
52
|
+
* });
|
|
53
|
+
* ```
|
|
54
|
+
*
|
|
55
|
+
* @resource
|
|
56
|
+
* @product Organization
|
|
57
|
+
*/
|
|
58
|
+
export const OrganizationInvite = Resource<OrganizationInvite>("Turso.OrganizationInvite");
|
|
59
|
+
|
|
60
|
+
export const OrganizationInviteProvider = () =>
|
|
61
|
+
Provider.effect(
|
|
62
|
+
OrganizationInvite,
|
|
63
|
+
Effect.gen(function* () {
|
|
64
|
+
const observe = (org: string, email: string) =>
|
|
65
|
+
turso
|
|
66
|
+
.listOrganizationInvitesV2({ organizationSlug: org })
|
|
67
|
+
.pipe(
|
|
68
|
+
Effect.map(({ invites }) =>
|
|
69
|
+
(invites ?? []).find((i) => i.email?.toLowerCase() === email.toLowerCase()),
|
|
70
|
+
),
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
const withdraw = (org: string, email: string) =>
|
|
74
|
+
turso.deleteOrganizationInviteByEmailV2({ organizationSlug: org, email }).pipe(
|
|
75
|
+
Effect.asVoid,
|
|
76
|
+
Effect.catchTag("NotFound", () => Effect.void),
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
stables: ["email", "organization"],
|
|
81
|
+
diff: Effect.fn(function* ({ olds, news }) {
|
|
82
|
+
if (!isResolved(news)) return undefined;
|
|
83
|
+
if (news.email.toLowerCase() !== olds?.email.toLowerCase()) {
|
|
84
|
+
return { action: "replace" } as const;
|
|
85
|
+
}
|
|
86
|
+
}),
|
|
87
|
+
read: Effect.fn(function* ({ olds, output }) {
|
|
88
|
+
const org = output?.organization ?? (yield* organization);
|
|
89
|
+
const invite = yield* observe(org, output?.email ?? olds.email);
|
|
90
|
+
if (!invite?.email) return undefined;
|
|
91
|
+
return { email: invite.email, organization: org, role: invite.role ?? "member" };
|
|
92
|
+
}),
|
|
93
|
+
reconcile: Effect.fn(function* ({ news, output }) {
|
|
94
|
+
const org = output?.organization ?? (yield* organization);
|
|
95
|
+
const role = news.role ?? "member";
|
|
96
|
+
|
|
97
|
+
// Observe
|
|
98
|
+
const invite = yield* observe(org, news.email);
|
|
99
|
+
|
|
100
|
+
// Ensure / sync — invites have no update API, so a role change
|
|
101
|
+
// withdraws and re-sends.
|
|
102
|
+
if (invite?.role === role) {
|
|
103
|
+
return { email: invite.email ?? news.email, organization: org, role };
|
|
104
|
+
}
|
|
105
|
+
if (invite) yield* withdraw(org, news.email);
|
|
106
|
+
const { invited } = yield* turso.inviteOrganizationMemberV2({
|
|
107
|
+
organizationSlug: org,
|
|
108
|
+
email: news.email,
|
|
109
|
+
role,
|
|
110
|
+
});
|
|
111
|
+
return { email: invited?.email ?? news.email, organization: org, role };
|
|
112
|
+
}),
|
|
113
|
+
delete: Effect.fn(function* ({ output }) {
|
|
114
|
+
yield* withdraw(output.organization, output.email);
|
|
115
|
+
}),
|
|
116
|
+
};
|
|
117
|
+
}),
|
|
118
|
+
);
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import * as turso from "@distilled.cloud/turso/turso";
|
|
2
|
+
import { isResolved } from "alchemy/Diff";
|
|
3
|
+
import * as Provider from "alchemy/Provider";
|
|
4
|
+
import { Resource } from "alchemy/Resource";
|
|
5
|
+
import * as Effect from "effect/Effect";
|
|
6
|
+
import { organization } from "./Credentials.ts";
|
|
7
|
+
import type { MemberRole } from "./OrganizationInvite.ts";
|
|
8
|
+
import type { Providers } from "./Providers.ts";
|
|
9
|
+
|
|
10
|
+
export interface OrganizationMemberProps {
|
|
11
|
+
/** Username of an existing Turso user. Changing it replaces the member. */
|
|
12
|
+
username: string;
|
|
13
|
+
/**
|
|
14
|
+
* Role of the member in the organization.
|
|
15
|
+
* @default "member"
|
|
16
|
+
*/
|
|
17
|
+
role?: MemberRole;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface OrganizationMemberAttributes {
|
|
21
|
+
/** Username of the member. */
|
|
22
|
+
username: string;
|
|
23
|
+
/** The organization slug. */
|
|
24
|
+
organization: string;
|
|
25
|
+
/** Role of the member. */
|
|
26
|
+
role: string;
|
|
27
|
+
/** Email of the member. */
|
|
28
|
+
email: string | undefined;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export type OrganizationMember = Resource<
|
|
32
|
+
"Turso.OrganizationMember",
|
|
33
|
+
OrganizationMemberProps,
|
|
34
|
+
OrganizationMemberAttributes,
|
|
35
|
+
never,
|
|
36
|
+
Providers
|
|
37
|
+
>;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Adds an existing Turso user to your organization with a role, and keeps
|
|
41
|
+
* that role in sync. Destroying the resource removes the member. Use
|
|
42
|
+
* {@link OrganizationInvite} for people who don't have a Turso account
|
|
43
|
+
* yet. Requires a team organization on a paid plan.
|
|
44
|
+
* @see https://docs.turso.tech/api-reference/organizations/members/add
|
|
45
|
+
*
|
|
46
|
+
* ### Managing Members
|
|
47
|
+
* **Example:** Add a user as a viewer
|
|
48
|
+
* ```typescript
|
|
49
|
+
* yield* Turso.OrganizationMember("Bob", {
|
|
50
|
+
* username: "bob",
|
|
51
|
+
* role: "viewer",
|
|
52
|
+
* });
|
|
53
|
+
* ```
|
|
54
|
+
*
|
|
55
|
+
* @resource
|
|
56
|
+
* @product Organization
|
|
57
|
+
*/
|
|
58
|
+
export const OrganizationMember = Resource<OrganizationMember>("Turso.OrganizationMember");
|
|
59
|
+
|
|
60
|
+
export const OrganizationMemberProvider = () =>
|
|
61
|
+
Provider.effect(
|
|
62
|
+
OrganizationMember,
|
|
63
|
+
Effect.gen(function* () {
|
|
64
|
+
const observe = (org: string, username: string) =>
|
|
65
|
+
turso.getOrganizationMember({ organizationSlug: org, username }).pipe(
|
|
66
|
+
Effect.map(({ member }) => member),
|
|
67
|
+
Effect.catchTag("NotFound", () => Effect.undefined),
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
stables: ["username", "organization"],
|
|
72
|
+
diff: Effect.fn(function* ({ olds, news }) {
|
|
73
|
+
if (!isResolved(news)) return undefined;
|
|
74
|
+
if (news.username !== olds?.username) return { action: "replace" } as const;
|
|
75
|
+
}),
|
|
76
|
+
read: Effect.fn(function* ({ olds, output }) {
|
|
77
|
+
const org = output?.organization ?? (yield* organization);
|
|
78
|
+
const member = yield* observe(org, output?.username ?? olds.username);
|
|
79
|
+
if (!member?.username) return undefined;
|
|
80
|
+
return {
|
|
81
|
+
username: member.username,
|
|
82
|
+
organization: org,
|
|
83
|
+
role: member.role ?? "member",
|
|
84
|
+
email: member.email,
|
|
85
|
+
};
|
|
86
|
+
}),
|
|
87
|
+
reconcile: Effect.fn(function* ({ news, output }) {
|
|
88
|
+
const org = output?.organization ?? (yield* organization);
|
|
89
|
+
const role = news.role ?? "member";
|
|
90
|
+
|
|
91
|
+
// Observe
|
|
92
|
+
let member = yield* observe(org, news.username);
|
|
93
|
+
|
|
94
|
+
// Ensure
|
|
95
|
+
if (!member) {
|
|
96
|
+
yield* turso
|
|
97
|
+
.addOrganizationMember({ organizationSlug: org, username: news.username, role })
|
|
98
|
+
.pipe(Effect.catchTag("Conflict", () => Effect.void));
|
|
99
|
+
member = yield* observe(org, news.username);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Sync role
|
|
103
|
+
if (member?.role !== role) {
|
|
104
|
+
yield* turso.updateMemberRole({
|
|
105
|
+
organizationSlug: org,
|
|
106
|
+
username: news.username,
|
|
107
|
+
role,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
return { username: news.username, organization: org, role, email: member?.email };
|
|
111
|
+
}),
|
|
112
|
+
delete: Effect.fn(function* ({ output }) {
|
|
113
|
+
yield* turso
|
|
114
|
+
.removeOrganizationMember({
|
|
115
|
+
organizationSlug: output.organization,
|
|
116
|
+
username: output.username,
|
|
117
|
+
})
|
|
118
|
+
.pipe(Effect.catchTag("NotFound", () => Effect.void));
|
|
119
|
+
}),
|
|
120
|
+
};
|
|
121
|
+
}),
|
|
122
|
+
);
|