@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.
Files changed (83) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +68 -4
  3. package/lib/ApiToken.d.ts +82 -0
  4. package/lib/ApiToken.d.ts.map +1 -0
  5. package/lib/ApiToken.js +128 -0
  6. package/lib/ApiToken.js.map +1 -0
  7. package/lib/AuthProvider.d.ts +36 -0
  8. package/lib/AuthProvider.d.ts.map +1 -0
  9. package/lib/AuthProvider.js +85 -0
  10. package/lib/AuthProvider.js.map +1 -0
  11. package/lib/Connect.d.ts +79 -0
  12. package/lib/Connect.d.ts.map +1 -0
  13. package/lib/Connect.js +22 -0
  14. package/lib/Connect.js.map +1 -0
  15. package/lib/Contributions.d.ts +7 -0
  16. package/lib/Contributions.d.ts.map +1 -0
  17. package/lib/Contributions.js +6 -0
  18. package/lib/Contributions.js.map +1 -0
  19. package/lib/Credentials.d.ts +39 -0
  20. package/lib/Credentials.d.ts.map +1 -0
  21. package/lib/Credentials.js +76 -0
  22. package/lib/Credentials.js.map +1 -0
  23. package/lib/Database.d.ts +226 -0
  24. package/lib/Database.d.ts.map +1 -0
  25. package/lib/Database.js +277 -0
  26. package/lib/Database.js.map +1 -0
  27. package/lib/DatabaseToken.d.ts +104 -0
  28. package/lib/DatabaseToken.d.ts.map +1 -0
  29. package/lib/DatabaseToken.js +133 -0
  30. package/lib/DatabaseToken.js.map +1 -0
  31. package/lib/Group.d.ts +102 -0
  32. package/lib/Group.d.ts.map +1 -0
  33. package/lib/Group.js +171 -0
  34. package/lib/Group.js.map +1 -0
  35. package/lib/GroupToken.d.ts +57 -0
  36. package/lib/GroupToken.d.ts.map +1 -0
  37. package/lib/GroupToken.js +88 -0
  38. package/lib/GroupToken.js.map +1 -0
  39. package/lib/Hrana.d.ts +42 -0
  40. package/lib/Hrana.d.ts.map +1 -0
  41. package/lib/Hrana.js +109 -0
  42. package/lib/Hrana.js.map +1 -0
  43. package/lib/ManageDatabases.d.ts +73 -0
  44. package/lib/ManageDatabases.d.ts.map +1 -0
  45. package/lib/ManageDatabases.js +81 -0
  46. package/lib/ManageDatabases.js.map +1 -0
  47. package/lib/OrganizationInvite.d.ts +46 -0
  48. package/lib/OrganizationInvite.d.ts.map +1 -0
  49. package/lib/OrganizationInvite.js +72 -0
  50. package/lib/OrganizationInvite.js.map +1 -0
  51. package/lib/OrganizationMember.d.ts +47 -0
  52. package/lib/OrganizationMember.d.ts.map +1 -0
  53. package/lib/OrganizationMember.js +81 -0
  54. package/lib/OrganizationMember.js.map +1 -0
  55. package/lib/OrganizationSettings.d.ts +52 -0
  56. package/lib/OrganizationSettings.d.ts.map +1 -0
  57. package/lib/OrganizationSettings.js +93 -0
  58. package/lib/OrganizationSettings.js.map +1 -0
  59. package/lib/Providers.d.ts +24 -0
  60. package/lib/Providers.d.ts.map +1 -0
  61. package/lib/Providers.js +42 -0
  62. package/lib/Providers.js.map +1 -0
  63. package/lib/index.d.ts +14 -0
  64. package/lib/index.d.ts.map +1 -0
  65. package/lib/index.js +14 -0
  66. package/lib/index.js.map +1 -0
  67. package/package.json +54 -3
  68. package/src/ApiToken.ts +207 -0
  69. package/src/AuthProvider.ts +113 -0
  70. package/src/Connect.ts +103 -0
  71. package/src/Contributions.ts +9 -0
  72. package/src/Credentials.ts +112 -0
  73. package/src/Database.ts +519 -0
  74. package/src/DatabaseToken.ts +213 -0
  75. package/src/Group.ts +269 -0
  76. package/src/GroupToken.ts +127 -0
  77. package/src/Hrana.ts +184 -0
  78. package/src/ManageDatabases.ts +195 -0
  79. package/src/OrganizationInvite.ts +118 -0
  80. package/src/OrganizationMember.ts +122 -0
  81. package/src/OrganizationSettings.ts +145 -0
  82. package/src/Providers.ts +67 -0
  83. 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
+ );