@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
@@ -0,0 +1,213 @@
1
+ import * as turso from "@distilled.cloud/turso/turso";
2
+ import { deepEqual } from "alchemy/Diff";
3
+ import * as Provider from "alchemy/Provider";
4
+ import { Resource } from "alchemy/Resource";
5
+ import * as Clock from "effect/Clock";
6
+ import * as Data from "effect/Data";
7
+ import * as Effect from "effect/Effect";
8
+ import * as Redacted from "effect/Redacted";
9
+ import { organization } from "./Credentials.ts";
10
+ import type { Providers } from "./Providers.ts";
11
+
12
+ export class DatabaseTokenUnexpectedResponse extends Data.TaggedError(
13
+ "DatabaseTokenUnexpectedResponse",
14
+ )<{
15
+ readonly message: string;
16
+ readonly cause?: unknown;
17
+ }> {}
18
+
19
+ /** Access level of a Turso SQL auth token. */
20
+ export type TokenAuthorization = "full-access" | "read-only";
21
+
22
+ /** Props shared by {@link DatabaseToken} and {@link GroupToken}. */
23
+ export interface SqlTokenProps {
24
+ /**
25
+ * Access level granted by the token.
26
+ * @default "full-access"
27
+ */
28
+ authorization?: TokenAuthorization;
29
+ /**
30
+ * Token lifetime, e.g. `"2w"`, `"7d"`, `"1h30m"`. Omit for a token that
31
+ * never expires. An expired token is re-minted on the next deploy.
32
+ */
33
+ expiration?: string;
34
+ /**
35
+ * Databases this token may `ATTACH` for reading (requires `allowAttach`
36
+ * on those databases). Not supported on AWS-hosted databases.
37
+ */
38
+ readAttach?: string[];
39
+ }
40
+
41
+ /** Attributes shared by {@link DatabaseToken} and {@link GroupToken}. */
42
+ export interface SqlTokenAttributes {
43
+ /** The token (a JWT), sent as `Authorization: Bearer <token>`. */
44
+ token: Redacted.Redacted<string>;
45
+ /** The organization slug the token belongs to. */
46
+ organization: string;
47
+ /** Access level granted by the token. */
48
+ authorization: TokenAuthorization;
49
+ /** Expiry as epoch milliseconds, or `undefined` for a non-expiring token. */
50
+ expiresAt: number | undefined;
51
+ }
52
+
53
+ export interface DatabaseTokenProps extends SqlTokenProps {
54
+ /** Name of the database the token grants access to (e.g. `db.name`). */
55
+ database: string;
56
+ }
57
+
58
+ export interface DatabaseTokenAttributes extends SqlTokenAttributes {
59
+ /** Name of the database the token grants access to. */
60
+ database: string;
61
+ /** UUID of the database the token was minted for. */
62
+ dbId: string;
63
+ }
64
+
65
+ export type DatabaseToken = Resource<
66
+ "Turso.DatabaseToken",
67
+ DatabaseTokenProps,
68
+ DatabaseTokenAttributes,
69
+ never,
70
+ Providers
71
+ >;
72
+
73
+ /**
74
+ * A token that lets an app run SQL on one Turso database. Workers and
75
+ * Lambdas should use {@link Connect}, which creates one for you. Use
76
+ * `DatabaseToken` for anything else, such as a container or a CI job.
77
+ *
78
+ * You can't cancel a single token. Changing a prop creates a new token, but
79
+ * the old one keeps working until it expires. Destroying the resource doesn't
80
+ * stop it either. Give tokens you share an `expiration`, or cancel every token
81
+ * for the database with `turso db tokens invalidate`.
82
+ * @see https://docs.turso.tech/api-reference/databases/create-token
83
+ *
84
+ * ### Creating a Token
85
+ * **Example:** Full-access token
86
+ * ```typescript
87
+ * const token = yield* Turso.DatabaseToken("Token", {
88
+ * database: db.name,
89
+ * });
90
+ * ```
91
+ *
92
+ * **Example:** Read-only token that expires after two weeks
93
+ * ```typescript
94
+ * const token = yield* Turso.DatabaseToken("ReadOnly", {
95
+ * database: db.name,
96
+ * authorization: "read-only",
97
+ * expiration: "2w",
98
+ * });
99
+ * ```
100
+ *
101
+ * @resource
102
+ * @product Database
103
+ */
104
+ export const DatabaseToken = Resource<DatabaseToken>("Turso.DatabaseToken");
105
+
106
+ /** Decode a Turso JWT's `exp` claim (seconds) to epoch milliseconds. */
107
+ export const tokenExpiry = (token: Redacted.Redacted<string>): number | undefined => {
108
+ const payload = Redacted.value(token).split(".")[1];
109
+ if (!payload) return undefined;
110
+ try {
111
+ const claims = JSON.parse(Buffer.from(payload, "base64url").toString("utf8")) as {
112
+ exp?: number;
113
+ };
114
+ return typeof claims.exp === "number" ? claims.exp * 1000 : undefined;
115
+ } catch {
116
+ return undefined;
117
+ }
118
+ };
119
+
120
+ /** Re-mint a token that expires within the hour. */
121
+ const RENEW_WITHIN_MS = 60 * 60 * 1000;
122
+
123
+ /**
124
+ * `diff` for SQL tokens: an unchanged token that is about to expire still
125
+ * needs an update, so the deploy re-mints it.
126
+ */
127
+ export const diffSqlToken = Effect.fn(function* ({
128
+ output,
129
+ }: {
130
+ output: SqlTokenAttributes | undefined;
131
+ }) {
132
+ if (output?.expiresAt === undefined) return undefined;
133
+ const now = yield* Clock.currentTimeMillis;
134
+ return output.expiresAt - now <= RENEW_WITHIN_MS ? ({ action: "update" } as const) : undefined;
135
+ });
136
+
137
+ /** Whether a previously minted token can be kept as-is. */
138
+ export const isTokenCurrent = Effect.fn(function* (
139
+ output: SqlTokenAttributes | undefined,
140
+ olds: SqlTokenProps | undefined,
141
+ news: SqlTokenProps,
142
+ ) {
143
+ if (output === undefined || olds === undefined) return false;
144
+ if (
145
+ (news.authorization ?? "full-access") !== (olds.authorization ?? "full-access") ||
146
+ news.expiration !== olds.expiration ||
147
+ !deepEqual(news.readAttach ?? [], olds.readAttach ?? [])
148
+ ) {
149
+ return false;
150
+ }
151
+ if (output.expiresAt === undefined) return true;
152
+ // Re-mint a token that expires within the hour, so a deploy never hands
153
+ // consumers a token about to lapse.
154
+ const now = yield* Clock.currentTimeMillis;
155
+ return output.expiresAt - now > RENEW_WITHIN_MS;
156
+ });
157
+
158
+ export const DatabaseTokenProvider = () =>
159
+ Provider.effect(
160
+ DatabaseToken,
161
+ Effect.gen(function* () {
162
+ return {
163
+ stables: ["organization"],
164
+ diff: diffSqlToken,
165
+ reconcile: Effect.fn(function* ({ news, olds, output }) {
166
+ const org = output?.organization ?? (yield* organization);
167
+
168
+ // Observe — the token is bound to the database's UUID, so a
169
+ // database replaced under the same name invalidates it.
170
+ const { database } = yield* turso.getDatabase({
171
+ organizationSlug: org,
172
+ databaseName: news.database,
173
+ });
174
+ const dbId = database?.DbId ?? "";
175
+ if (
176
+ output !== undefined &&
177
+ output.database === news.database &&
178
+ output.dbId === dbId &&
179
+ (yield* isTokenCurrent(output, olds, news))
180
+ ) {
181
+ return output;
182
+ }
183
+
184
+ // Ensure — mint.
185
+ const authorization = news.authorization ?? "full-access";
186
+ const { jwt } = yield* turso.createDatabaseToken({
187
+ organizationSlug: org,
188
+ databaseName: news.database,
189
+ authorization,
190
+ expiration: news.expiration,
191
+ permissions: news.readAttach
192
+ ? { read_attach: { databases: news.readAttach } }
193
+ : undefined,
194
+ });
195
+ if (!jwt) {
196
+ return yield* new DatabaseTokenUnexpectedResponse({
197
+ message: `Turso returned no token for ${news.database}`,
198
+ });
199
+ }
200
+ return {
201
+ token: jwt,
202
+ organization: org,
203
+ authorization,
204
+ expiresAt: tokenExpiry(jwt),
205
+ database: news.database,
206
+ dbId,
207
+ };
208
+ }),
209
+ // Turso cannot revoke a single token; it simply stops being tracked.
210
+ delete: () => Effect.void,
211
+ };
212
+ }),
213
+ );
package/src/Group.ts ADDED
@@ -0,0 +1,269 @@
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 { organization } from "./Credentials.ts";
10
+ import type { Providers } from "./Providers.ts";
11
+
12
+ export class GroupUnexpectedResponse extends Data.TaggedError("GroupUnexpectedResponse")<{
13
+ readonly message: string;
14
+ readonly cause?: unknown;
15
+ }> {}
16
+
17
+ /**
18
+ * A Turso location code, e.g. `"aws-us-east-1"`. List the locations
19
+ * available to your organization with `turso db locations`.
20
+ */
21
+ export type Location =
22
+ | "aws-ap-northeast-1"
23
+ | "aws-ap-south-1"
24
+ | "aws-ap-southeast-2"
25
+ | "aws-ca-central-1"
26
+ | "aws-eu-north-1"
27
+ | "aws-eu-west-1"
28
+ | "aws-sa-east-1"
29
+ | "aws-us-east-1"
30
+ | "aws-us-east-2"
31
+ | "aws-us-west-2"
32
+ | (string & {});
33
+
34
+ export interface GroupProps {
35
+ /**
36
+ * Group name, unique within the organization. Lowercase letters,
37
+ * numbers, and dashes. If omitted, a unique name is generated from the
38
+ * stack, stage, and logical ID. Changing it replaces the group.
39
+ */
40
+ name?: string;
41
+ /**
42
+ * Primary location of the group. Every database in the group lives here.
43
+ * Changing it replaces the group (and every database in it).
44
+ * @default "aws-us-east-1"
45
+ */
46
+ location?: Location;
47
+ /**
48
+ * Additional replica locations. Reconciled in place by adding and
49
+ * removing locations. AWS-hosted groups do not support replication, so
50
+ * this only applies to organizations with legacy Fly-hosted groups.
51
+ */
52
+ replicas?: Location[];
53
+ /**
54
+ * SQLite extensions enabled for the group's databases: `"all"`, or a list
55
+ * of extension names. Set at creation; changing it replaces the group.
56
+ */
57
+ extensions?: "all" | string[];
58
+ /**
59
+ * Prevent the group (and its databases) from being deleted. While
60
+ * enabled, destroying or replacing the group fails; set it to `false`
61
+ * and deploy before deleting.
62
+ * @default false
63
+ */
64
+ deleteProtection?: boolean;
65
+ }
66
+
67
+ export interface GroupAttributes {
68
+ /** Group name. */
69
+ name: string;
70
+ /** Group UUID. */
71
+ uuid: string;
72
+ /** The organization slug that owns the group. */
73
+ organization: string;
74
+ /** Primary location of the group. */
75
+ location: string;
76
+ /** Every location the group runs in (primary first). */
77
+ locations: string[];
78
+ /** The libSQL server version the group's databases run. */
79
+ version: string;
80
+ /** Whether delete protection is enabled. */
81
+ deleteProtection: boolean;
82
+ }
83
+
84
+ export type Group = Resource<"Turso.Group", GroupProps, GroupAttributes, never, Providers>;
85
+
86
+ /**
87
+ * A Turso group. Every Turso database belongs to one group, and the group
88
+ * decides where its databases run.
89
+ * @see https://docs.turso.tech/concepts#groups
90
+ *
91
+ * ### Creating a Group
92
+ * **Example:** Group in a single location
93
+ * ```typescript
94
+ * const group = yield* Turso.Group("Group", {
95
+ * location: "aws-us-east-1",
96
+ * });
97
+ * ```
98
+ *
99
+ * **Example:** Protected group with all extensions enabled
100
+ * ```typescript
101
+ * const group = yield* Turso.Group("Group", {
102
+ * name: "production",
103
+ * location: "aws-eu-west-1",
104
+ * extensions: "all",
105
+ * deleteProtection: true,
106
+ * });
107
+ * ```
108
+ *
109
+ * ### Adding databases
110
+ * **Example:** Create a database in the group
111
+ * ```typescript
112
+ * const db = yield* Turso.Database("Db", { group: group.name });
113
+ * ```
114
+ *
115
+ * @resource
116
+ * @product Group
117
+ */
118
+ export const Group = Resource<Group>("Turso.Group");
119
+
120
+ const DEFAULT_LOCATION = "aws-us-east-1";
121
+
122
+ export const GroupProvider = () =>
123
+ Provider.effect(
124
+ Group,
125
+ Effect.gen(function* () {
126
+ const groupName = (id: string, name: string | undefined) =>
127
+ name
128
+ ? Effect.succeed(name)
129
+ : createPhysicalName({ id, lowercase: true, maxLength: 32, delimiter: "-" });
130
+
131
+ const observe = (org: string, name: string) =>
132
+ turso.getGroup({ organizationSlug: org, groupName: name }).pipe(
133
+ Effect.map((res) => res.group),
134
+ Effect.catchTag("NotFound", () => Effect.undefined),
135
+ );
136
+
137
+ const toAttrs = (
138
+ org: string,
139
+ group: turso.Group,
140
+ deleteProtection: boolean,
141
+ ): GroupAttributes => ({
142
+ name: group.name!,
143
+ uuid: group.uuid ?? "",
144
+ organization: org,
145
+ location: group.primary ?? "",
146
+ locations: group.locations ?? [],
147
+ version: group.version ?? "",
148
+ deleteProtection,
149
+ });
150
+
151
+ return {
152
+ stables: ["name", "uuid", "organization", "location"],
153
+ diff: Effect.fn(function* ({ id, olds, news, output }) {
154
+ if (!isResolved(news)) return undefined;
155
+ const name = output?.name ?? (yield* groupName(id, olds?.name));
156
+ if (news.name !== undefined && news.name !== name) {
157
+ return { action: "replace" } as const;
158
+ }
159
+ if ((news.location ?? DEFAULT_LOCATION) !== (olds?.location ?? DEFAULT_LOCATION)) {
160
+ return { action: "replace", deleteFirst: news.name !== undefined } as const;
161
+ }
162
+ if (!deepEqual(news.extensions, olds?.extensions)) {
163
+ return { action: "replace", deleteFirst: news.name !== undefined } as const;
164
+ }
165
+ }),
166
+ read: Effect.fn(function* ({ id, olds, output, previous }) {
167
+ const org = output?.organization ?? (yield* organization);
168
+ const name = output?.name ?? (yield* groupName(id, olds?.name));
169
+ const group = yield* observe(org, name);
170
+ if (!group) return undefined;
171
+ const attrs = toAttrs(org, group, group.delete_protection ?? false);
172
+ // Turso has no tags: a group found under an explicit, user-chosen
173
+ // name without prior state may belong to someone else — unless it
174
+ // is the generation a delete-first replacement is about to free.
175
+ const ours =
176
+ output !== undefined ||
177
+ olds?.name === undefined ||
178
+ (previous?.organization === org && previous.name === name);
179
+ return ours ? attrs : Unowned(attrs);
180
+ }),
181
+ reconcile: Effect.fn(function* ({ id, news, output, session }) {
182
+ const org = output?.organization ?? (yield* organization);
183
+ const name = output?.name ?? (yield* groupName(id, news.name));
184
+ const location = news.location ?? DEFAULT_LOCATION;
185
+
186
+ // Observe
187
+ let group = yield* observe(org, name);
188
+
189
+ // Ensure
190
+ if (!group) {
191
+ yield* session.note(`Creating group ${name}...`);
192
+ group = yield* turso
193
+ .createGroup({
194
+ organizationSlug: org,
195
+ name,
196
+ location,
197
+ extensions: news.extensions,
198
+ })
199
+ .pipe(
200
+ Effect.map((res) => res.group),
201
+ Effect.catchTag("Conflict", () => observe(org, name)),
202
+ );
203
+ if (!group) {
204
+ return yield* new GroupUnexpectedResponse({
205
+ message: `Turso group ${name} vanished after create`,
206
+ });
207
+ }
208
+ }
209
+
210
+ // Sync replica locations against the observed set.
211
+ const primary = group.primary ?? location;
212
+ const desired = new Set([primary, ...(news.replicas ?? [])]);
213
+ const observed = new Set(group.locations ?? [primary]);
214
+ for (const loc of desired) {
215
+ if (!observed.has(loc)) {
216
+ yield* session.note(`Adding location ${loc}...`);
217
+ group =
218
+ (yield* turso.addLocationToGroup({
219
+ organizationSlug: org,
220
+ groupName: name,
221
+ location: loc,
222
+ })).group ?? group;
223
+ }
224
+ }
225
+ for (const loc of observed) {
226
+ if (!desired.has(loc)) {
227
+ yield* session.note(`Removing location ${loc}...`);
228
+ group =
229
+ (yield* turso.removeLocationFromGroup({
230
+ organizationSlug: org,
231
+ groupName: name,
232
+ location: loc,
233
+ })).group ?? group;
234
+ }
235
+ }
236
+
237
+ // Sync configuration.
238
+ const config = yield* turso.getGroupConfiguration({
239
+ organizationSlug: org,
240
+ groupName: name,
241
+ });
242
+ let deleteProtection = config.delete_protection ?? false;
243
+ const desiredProtection = news.deleteProtection ?? false;
244
+ if (deleteProtection !== desiredProtection) {
245
+ const updated = yield* turso.updateGroupConfiguration({
246
+ organizationSlug: org,
247
+ groupName: name,
248
+ delete_protection: desiredProtection,
249
+ });
250
+ deleteProtection = updated.delete_protection ?? desiredProtection;
251
+ }
252
+
253
+ return toAttrs(org, group, deleteProtection);
254
+ }),
255
+ delete: Effect.fn(function* ({ output }) {
256
+ yield* turso
257
+ .deleteGroup({ organizationSlug: output.organization, groupName: output.name })
258
+ .pipe(Effect.catchTag("NotFound", () => Effect.void));
259
+ }),
260
+ list: Effect.fn(function* () {
261
+ const org = yield* organization;
262
+ const { groups } = yield* turso.listGroups({ organizationSlug: org });
263
+ return (groups ?? [])
264
+ .filter((group) => group.name !== undefined)
265
+ .map((group) => toAttrs(org, group, group.delete_protection ?? false));
266
+ }),
267
+ };
268
+ }),
269
+ );
@@ -0,0 +1,127 @@
1
+ import * as turso from "@distilled.cloud/turso/turso";
2
+ import * as Provider from "alchemy/Provider";
3
+ import { Resource } from "alchemy/Resource";
4
+ import * as Data from "effect/Data";
5
+ import * as Effect from "effect/Effect";
6
+ import { organization } from "./Credentials.ts";
7
+ import {
8
+ diffSqlToken,
9
+ isTokenCurrent,
10
+ tokenExpiry,
11
+ type SqlTokenAttributes,
12
+ type SqlTokenProps,
13
+ } from "./DatabaseToken.ts";
14
+ import type { Providers } from "./Providers.ts";
15
+
16
+ export class GroupTokenUnexpectedResponse extends Data.TaggedError("GroupTokenUnexpectedResponse")<{
17
+ readonly message: string;
18
+ readonly cause?: unknown;
19
+ }> {}
20
+
21
+ export interface GroupTokenProps extends SqlTokenProps {
22
+ /** Name of the group whose databases the token grants access to. */
23
+ group: string;
24
+ }
25
+
26
+ export interface GroupTokenAttributes extends SqlTokenAttributes {
27
+ /** Name of the group the token grants access to. */
28
+ group: string;
29
+ /** UUID of the group the token was minted for. */
30
+ groupId: string;
31
+ }
32
+
33
+ export type GroupToken = Resource<
34
+ "Turso.GroupToken",
35
+ GroupTokenProps,
36
+ GroupTokenAttributes,
37
+ never,
38
+ Providers
39
+ >;
40
+
41
+ /**
42
+ * A token that lets an app run SQL on every database in a Turso
43
+ * {@link Group}, even databases created later. Useful when each customer
44
+ * has their own database.
45
+ *
46
+ * Like {@link DatabaseToken}, you can't cancel a single group token. Changing
47
+ * a prop creates a new token, but the old one keeps working until it expires.
48
+ * @see https://docs.turso.tech/api-reference/groups/create-token
49
+ *
50
+ * ### Creating a Token
51
+ * **Example:** Full-access token for every database in a group
52
+ * ```typescript
53
+ * const token = yield* Turso.GroupToken("Token", {
54
+ * group: group.name,
55
+ * });
56
+ * ```
57
+ *
58
+ * **Example:** Read-only token that expires after a week
59
+ * ```typescript
60
+ * const token = yield* Turso.GroupToken("ReadOnly", {
61
+ * group: group.name,
62
+ * authorization: "read-only",
63
+ * expiration: "7d",
64
+ * });
65
+ * ```
66
+ *
67
+ * @resource
68
+ * @product Group
69
+ */
70
+ export const GroupToken = Resource<GroupToken>("Turso.GroupToken");
71
+
72
+ export const GroupTokenProvider = () =>
73
+ Provider.effect(
74
+ GroupToken,
75
+ Effect.gen(function* () {
76
+ return {
77
+ stables: ["organization"],
78
+ diff: diffSqlToken,
79
+ reconcile: Effect.fn(function* ({ news, olds, output }) {
80
+ const org = output?.organization ?? (yield* organization);
81
+
82
+ // Observe — the token is bound to the group's UUID, so a group
83
+ // replaced under the same name invalidates it.
84
+ const { group } = yield* turso.getGroup({
85
+ organizationSlug: org,
86
+ groupName: news.group,
87
+ });
88
+ const groupId = group?.uuid ?? "";
89
+ if (
90
+ output !== undefined &&
91
+ output.group === news.group &&
92
+ output.groupId === groupId &&
93
+ (yield* isTokenCurrent(output, olds, news))
94
+ ) {
95
+ return output;
96
+ }
97
+
98
+ // Ensure — mint.
99
+ const authorization = news.authorization ?? "full-access";
100
+ const { jwt } = yield* turso.createGroupToken({
101
+ organizationSlug: org,
102
+ groupName: news.group,
103
+ authorization,
104
+ expiration: news.expiration,
105
+ permissions: news.readAttach
106
+ ? { read_attach: { databases: news.readAttach } }
107
+ : undefined,
108
+ });
109
+ if (!jwt) {
110
+ return yield* new GroupTokenUnexpectedResponse({
111
+ message: `Turso returned no token for group ${news.group}`,
112
+ });
113
+ }
114
+ return {
115
+ token: jwt,
116
+ organization: org,
117
+ authorization,
118
+ expiresAt: tokenExpiry(jwt),
119
+ group: news.group,
120
+ groupId,
121
+ };
122
+ }),
123
+ // Turso cannot revoke a single token; it simply stops being tracked.
124
+ delete: () => Effect.void,
125
+ };
126
+ }),
127
+ );