@alchemy.run/turso 0.0.0 → 2.0.0-beta.84

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 +73 -0
  4. package/lib/ApiToken.d.ts.map +1 -0
  5. package/lib/ApiToken.js +120 -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 +80 -0
  22. package/lib/Credentials.js.map +1 -0
  23. package/lib/Database.d.ts +217 -0
  24. package/lib/Database.d.ts.map +1 -0
  25. package/lib/Database.js +271 -0
  26. package/lib/Database.js.map +1 -0
  27. package/lib/DatabaseToken.d.ts +95 -0
  28. package/lib/DatabaseToken.d.ts.map +1 -0
  29. package/lib/DatabaseToken.js +128 -0
  30. package/lib/DatabaseToken.js.map +1 -0
  31. package/lib/Group.d.ts +93 -0
  32. package/lib/Group.d.ts.map +1 -0
  33. package/lib/Group.js +162 -0
  34. package/lib/Group.js.map +1 -0
  35. package/lib/GroupToken.d.ts +48 -0
  36. package/lib/GroupToken.d.ts.map +1 -0
  37. package/lib/GroupToken.js +83 -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 +105 -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 +197 -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 +139 -0
  73. package/src/Database.ts +504 -0
  74. package/src/DatabaseToken.ts +203 -0
  75. package/src/Group.ts +256 -0
  76. package/src/GroupToken.ts +119 -0
  77. package/src/Hrana.ts +182 -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,504 @@
1
+ import {
2
+ diffMigrations,
3
+ migrationsAttrs,
4
+ migrationsInputOf,
5
+ runMigrations,
6
+ stampedOf,
7
+ type MigrationsInput,
8
+ } from "@alchemy.run/sql/Migrations";
9
+ import { hashImports, readSqlFile, splitSqlStatements } from "@alchemy.run/sql/SqlFile";
10
+ import * as turso from "@distilled.cloud/turso/turso";
11
+ import { Unowned } from "alchemy/AdoptPolicy";
12
+ import { deepEqual, isResolved } from "alchemy/Diff";
13
+ import { createPhysicalName } from "alchemy/PhysicalName";
14
+ import * as Provider from "alchemy/Provider";
15
+ import { Resource } from "alchemy/Resource";
16
+ import * as Effect from "effect/Effect";
17
+ import * as HttpClient from "effect/http/HttpClient";
18
+ import * as Redacted from "effect/Redacted";
19
+ import { organization } from "./Credentials.ts";
20
+ import * as Hrana from "./Hrana.ts";
21
+ import type { Providers } from "./Providers.ts";
22
+
23
+ export interface DatabaseSeed {
24
+ /**
25
+ * `"database"` copies an existing database (optionally at a point in
26
+ * time). `"database_upload"` creates an empty database awaiting an upload
27
+ * of a SQLite file.
28
+ */
29
+ type: "database" | "database_upload";
30
+ /** Name of the source database when `type` is `"database"`. */
31
+ name?: string;
32
+ /**
33
+ * ISO 8601 recovery point within the source database's retention window
34
+ * (24 hours, or 30 days on the Scaler plan).
35
+ */
36
+ timestamp?: string;
37
+ }
38
+
39
+ export interface DatabaseEncryption {
40
+ /**
41
+ * Base64-encoded encryption key: 32 bytes for `aes256gcm`,
42
+ * `chacha20poly1305`, and the `aegis256` variants; 16 bytes for
43
+ * `aes128gcm` and the `aegis128l` variants.
44
+ */
45
+ key: Redacted.Redacted<string>;
46
+ /** Encryption cipher. */
47
+ cipher:
48
+ | "aes256gcm"
49
+ | "aes128gcm"
50
+ | "chacha20poly1305"
51
+ | "aegis128l"
52
+ | "aegis128x2"
53
+ | "aegis128x4"
54
+ | "aegis256"
55
+ | "aegis256x2"
56
+ | "aegis256x4"
57
+ | (string & {});
58
+ }
59
+
60
+ export interface DatabaseProps {
61
+ /**
62
+ * Database name, unique within the organization. Lowercase letters,
63
+ * numbers, and dashes, at most 64 characters. If omitted, a unique name
64
+ * is generated from the stack, stage, and logical ID. Changing it
65
+ * replaces the database.
66
+ */
67
+ name?: string;
68
+ /**
69
+ * Name of the group the database is created in (e.g. `group.name`).
70
+ * Changing it replaces the database.
71
+ */
72
+ group: string;
73
+ /**
74
+ * Seed the new database from an existing database or a pending upload.
75
+ * Only used when the database is created.
76
+ */
77
+ seed?: DatabaseSeed;
78
+ /**
79
+ * Encrypt the database at rest with your own key. Only used when the
80
+ * database is created; changing it replaces the database. Requires a Pro
81
+ * or Enterprise plan.
82
+ */
83
+ encryption?: DatabaseEncryption;
84
+ /**
85
+ * Maximum size of the database, in bytes or with a unit (e.g. `"256mb"`,
86
+ * `"1gb"`). When omitted, the current limit is left unchanged. Only
87
+ * legacy databases support it — Turso rejects it for databases created on
88
+ * the current platform ("size_limit is not supported for db-api
89
+ * controlled databases").
90
+ */
91
+ sizeLimit?: string;
92
+ /**
93
+ * Allow other databases to be attached to this one with `ATTACH`. Not
94
+ * supported on AWS-hosted databases, which reject the setting.
95
+ * @default false
96
+ */
97
+ allowAttach?: boolean;
98
+ /**
99
+ * Reject every read query.
100
+ * @default false
101
+ */
102
+ blockReads?: boolean;
103
+ /**
104
+ * Reject every write query.
105
+ * @default false
106
+ */
107
+ blockWrites?: boolean;
108
+ /**
109
+ * Prevent the database from being deleted. While enabled, destroying or
110
+ * replacing the database fails; set it to `false` and deploy first.
111
+ * @default false
112
+ */
113
+ deleteProtection?: boolean;
114
+ /**
115
+ * IP addresses and CIDR blocks allowed to connect. Empty or omitted means
116
+ * no IP restriction. Requires a paid plan.
117
+ */
118
+ allowedIps?: string[];
119
+ /**
120
+ * AWS VPC endpoint IDs (`vpce-...`) allowed to connect. Empty or omitted
121
+ * means no VPC endpoint restriction. Requires a paid plan.
122
+ */
123
+ allowedAwsVpcIds?: string[];
124
+ /**
125
+ * SQL migrations to apply on deploy. Accepts a directory path, a
126
+ * `Drizzle.Schema` resource, or `{ dir, table? }`.
127
+ *
128
+ * Bookkeeping lives in Alchemy's `__alchemy_migrations` table. A database
129
+ * previously migrated by drizzle-kit is adopted by a one-way conversion on
130
+ * first deploy.
131
+ */
132
+ migrations?: MigrationsInput;
133
+ /**
134
+ * Paths to additional `.sql` files to apply after migrations. Each file is
135
+ * hashed; only files whose contents change are re-applied.
136
+ */
137
+ importFiles?: string[];
138
+ }
139
+
140
+ export interface DatabaseAttributes {
141
+ /** Database UUID. */
142
+ dbId: string;
143
+ /** Database name. */
144
+ name: string;
145
+ /** The organization slug that owns the database. */
146
+ organization: string;
147
+ /** Name of the group the database belongs to. */
148
+ group: string;
149
+ /** DNS hostname for libSQL and HTTP connections. */
150
+ hostname: string;
151
+ /** libSQL connection URL (`libsql://<hostname>`), for `@libsql/client`. */
152
+ url: string;
153
+ /** HTTPS URL (`https://<hostname>`) of the Hrana-over-HTTP endpoint. */
154
+ httpUrl: string;
155
+ /** Primary location of the database. */
156
+ primaryRegion: string;
157
+ /** Every location the database runs in. */
158
+ regions: string[];
159
+ /** Maximum database size, or `undefined` when unlimited. */
160
+ sizeLimit: string | undefined;
161
+ /** Whether other databases may be attached. */
162
+ allowAttach: boolean;
163
+ /** Whether reads are blocked. */
164
+ blockReads: boolean;
165
+ /** Whether writes are blocked. */
166
+ blockWrites: boolean;
167
+ /** Whether delete protection is enabled. */
168
+ deleteProtection: boolean;
169
+ /** IP addresses and CIDR blocks allowed to connect. */
170
+ allowedIps: string[];
171
+ /** AWS VPC endpoint IDs allowed to connect. */
172
+ allowedAwsVpcIds: string[];
173
+ /** Directory containing migration files, if configured. */
174
+ migrationsDir: string | undefined;
175
+ /** Table used to track applied migrations, if configured. */
176
+ migrationsTable: string | undefined;
177
+ /** Content hashes for the last applied migration files. */
178
+ migrationsHashes: Record<string, string>;
179
+ /** Content hashes for the last applied import files. */
180
+ importHashes: Record<string, string>;
181
+ }
182
+
183
+ export type Database = Resource<
184
+ "Turso.Database",
185
+ DatabaseProps,
186
+ DatabaseAttributes,
187
+ never,
188
+ Providers
189
+ >;
190
+
191
+ /**
192
+ * A Turso database: a SQLite database that lives in a {@link Group}.
193
+ * Connect to it from a Worker or Lambda with {@link Connect}, or create a
194
+ * {@link DatabaseToken} for other apps.
195
+ * @see https://docs.turso.tech/concepts#databases
196
+ *
197
+ * ### Creating a Database
198
+ * **Example:** Database in a group
199
+ * ```typescript
200
+ * const group = yield* Turso.Group("Group", { location: "aws-us-east-1" });
201
+ * const db = yield* Turso.Database("Db", { group: group.name });
202
+ * ```
203
+ *
204
+ * **Example:** Database with delete protection and an IP allowlist
205
+ * ```typescript
206
+ * const db = yield* Turso.Database("Db", {
207
+ * group: group.name,
208
+ * deleteProtection: true,
209
+ * allowedIps: ["203.0.113.0/24"],
210
+ * });
211
+ * ```
212
+ *
213
+ * ### Branching
214
+ * **Example:** Copy an existing database
215
+ * ```typescript
216
+ * const preview = yield* Turso.Database("Preview", {
217
+ * group: group.name,
218
+ * seed: { type: "database", name: db.name },
219
+ * });
220
+ * ```
221
+ *
222
+ * ### Migrations
223
+ * Point `migrations` at a folder of `.sql` files (or a `Drizzle.Schema`).
224
+ * Pending migrations are applied in order on each deploy.
225
+ *
226
+ * **Example:** Apply migrations from a directory
227
+ * ```typescript
228
+ * const db = yield* Turso.Database("Db", {
229
+ * group: group.name,
230
+ * migrations: "./migrations",
231
+ * });
232
+ * ```
233
+ *
234
+ * **Example:** Seed with SQL files
235
+ * ```typescript
236
+ * const db = yield* Turso.Database("Db", {
237
+ * group: group.name,
238
+ * importFiles: ["./seed/users.sql"],
239
+ * });
240
+ * ```
241
+ *
242
+ * ### Querying from a Worker
243
+ * **Example:** Drizzle over a Turso database
244
+ * ```typescript
245
+ * const conn = yield* Turso.Connect(Db);
246
+ * const db = yield* Drizzle.LibSQL(conn);
247
+ * ```
248
+ *
249
+ * @resource
250
+ * @product Database
251
+ */
252
+ export const Database = Resource<Database>("Turso.Database");
253
+
254
+ const databaseName = (id: string, name: string | undefined) =>
255
+ name
256
+ ? Effect.succeed(name)
257
+ : createPhysicalName({ id, lowercase: true, maxLength: 32, delimiter: "-" });
258
+
259
+ const rootDir = Effect.sync(() => process.cwd());
260
+
261
+ /**
262
+ * Run `use` against a short-lived (10 minute) full-access database token,
263
+ * for deploy-time SQL (migrations and imports).
264
+ */
265
+ const withConnection = <A, E, R>(
266
+ org: string,
267
+ database: { name: string; hostname: string },
268
+ use: (connection: Hrana.HranaConnection, client: HttpClient.HttpClient) => Effect.Effect<A, E, R>,
269
+ ) =>
270
+ Effect.gen(function* () {
271
+ const client = yield* HttpClient.HttpClient;
272
+ const { jwt } = yield* turso.createDatabaseToken({
273
+ organizationSlug: org,
274
+ databaseName: database.name,
275
+ expiration: "10m",
276
+ authorization: "full-access",
277
+ });
278
+ if (!jwt) {
279
+ return yield* Effect.fail(new Error(`Turso returned no token for ${database.name}`));
280
+ }
281
+ return yield* use({ url: `https://${database.hostname}`, authToken: jwt }, client);
282
+ });
283
+
284
+ export const DatabaseProvider = () =>
285
+ Provider.effect(
286
+ Database,
287
+ Effect.gen(function* () {
288
+ const observe = (org: string, name: string) =>
289
+ turso.getDatabase({ organizationSlug: org, databaseName: name }).pipe(
290
+ Effect.map((res) => res.database),
291
+ Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
292
+ );
293
+
294
+ const toAttrs = (
295
+ org: string,
296
+ db: turso.Database,
297
+ config: turso.DatabaseConfigurationResponse,
298
+ rest: Pick<
299
+ DatabaseAttributes,
300
+ "migrationsDir" | "migrationsTable" | "migrationsHashes" | "importHashes"
301
+ >,
302
+ ): DatabaseAttributes => ({
303
+ dbId: db.DbId ?? "",
304
+ name: db.Name!,
305
+ organization: org,
306
+ group: db.group ?? "",
307
+ hostname: db.Hostname ?? "",
308
+ url: `libsql://${db.Hostname}`,
309
+ httpUrl: `https://${db.Hostname}`,
310
+ primaryRegion: db.primaryRegion ?? "",
311
+ regions: db.regions ?? [],
312
+ sizeLimit: config.size_limit || undefined,
313
+ allowAttach: config.allow_attach ?? false,
314
+ blockReads: config.block_reads ?? false,
315
+ blockWrites: config.block_writes ?? false,
316
+ deleteProtection: config.delete_protection ?? false,
317
+ allowedIps: config.allowed_ips ?? [],
318
+ allowedAwsVpcIds: config.allowed_aws_vpc_ids ?? [],
319
+ ...rest,
320
+ });
321
+
322
+ const getConfig = (org: string, name: string) =>
323
+ turso.getDatabaseConfiguration({ organizationSlug: org, databaseName: name });
324
+
325
+ return {
326
+ stables: ["dbId", "name", "organization", "group", "hostname", "url", "httpUrl"],
327
+ diff: Effect.fn(function* ({ id, olds, news, output }) {
328
+ if (!isResolved(news)) return undefined;
329
+ const name = output?.name ?? (yield* databaseName(id, olds?.name));
330
+ if (news.name !== undefined && news.name !== name) {
331
+ return { action: "replace" } as const;
332
+ }
333
+ if (news.group !== (output?.group ?? olds?.group)) {
334
+ return { action: "replace", deleteFirst: news.name !== undefined } as const;
335
+ }
336
+ if (
337
+ news.encryption?.cipher !== olds?.encryption?.cipher ||
338
+ (news.encryption &&
339
+ olds?.encryption &&
340
+ Redacted.value(news.encryption.key) !== Redacted.value(olds.encryption.key))
341
+ ) {
342
+ return { action: "replace", deleteFirst: news.name !== undefined } as const;
343
+ }
344
+ if (yield* diffMigrations({ news, output })) {
345
+ return { action: "update" } as const;
346
+ }
347
+ if (news.importFiles?.length) {
348
+ const hashes = yield* hashImports(news.importFiles, yield* rootDir);
349
+ if (!deepEqual(hashes, output?.importHashes ?? {})) {
350
+ return { action: "update" } as const;
351
+ }
352
+ }
353
+ }),
354
+ read: Effect.fn(function* ({ id, olds, output }) {
355
+ const org = output?.organization ?? (yield* organization);
356
+ const name = output?.name ?? (yield* databaseName(id, olds?.name));
357
+ const db = yield* observe(org, name);
358
+ if (!db) return undefined;
359
+ const attrs = toAttrs(org, db, yield* getConfig(org, name), {
360
+ migrationsDir: output?.migrationsDir,
361
+ migrationsTable: output?.migrationsTable,
362
+ migrationsHashes: output?.migrationsHashes ?? {},
363
+ importHashes: output?.importHashes ?? {},
364
+ });
365
+ // Turso has no tags: a database found under an explicit,
366
+ // user-chosen name without prior state may belong to someone else.
367
+ return output === undefined && olds?.name !== undefined ? Unowned(attrs) : attrs;
368
+ }),
369
+ reconcile: Effect.fn(function* ({ id, news, output, session }) {
370
+ const org = output?.organization ?? (yield* organization);
371
+ const name = output?.name ?? (yield* databaseName(id, news.name));
372
+
373
+ // Observe
374
+ let db = yield* observe(org, name);
375
+
376
+ // Ensure
377
+ if (!db) {
378
+ yield* session.note(`Creating database ${name}...`);
379
+ yield* turso
380
+ .createDatabase({
381
+ organizationSlug: org,
382
+ name,
383
+ group: news.group,
384
+ seed: news.seed,
385
+ size_limit: news.sizeLimit,
386
+ remote_encryption: news.encryption
387
+ ? {
388
+ encryption_key: Redacted.value(news.encryption.key),
389
+ encryption_cipher: news.encryption.cipher,
390
+ }
391
+ : undefined,
392
+ })
393
+ .pipe(Effect.catchTag("Conflict", () => Effect.void));
394
+ db = yield* observe(org, name);
395
+ if (!db) {
396
+ return yield* Effect.fail(new Error(`Turso database ${name} vanished after create`));
397
+ }
398
+ }
399
+
400
+ // Sync configuration — diff observed settings against desired
401
+ // and PATCH only the fields that differ.
402
+ let config = yield* getConfig(org, name);
403
+ const patch: Omit<turso.UpdateDatabaseConfigurationRequest, "organizationSlug"> = {
404
+ databaseName: name,
405
+ };
406
+ let dirty = false;
407
+ const set = <K extends keyof typeof patch>(
408
+ key: K,
409
+ observed: unknown,
410
+ desired: (typeof patch)[K],
411
+ ) => {
412
+ if (!deepEqual(observed, desired)) {
413
+ patch[key] = desired;
414
+ dirty = true;
415
+ }
416
+ };
417
+ if (news.sizeLimit !== undefined) set("size_limit", config.size_limit, news.sizeLimit);
418
+ // AWS-hosted databases reject `allow_attach` outright, so it is
419
+ // only sent when explicitly configured.
420
+ if (news.allowAttach !== undefined) {
421
+ set("allow_attach", config.allow_attach ?? false, news.allowAttach);
422
+ }
423
+ set("block_reads", config.block_reads ?? false, news.blockReads ?? false);
424
+ set("block_writes", config.block_writes ?? false, news.blockWrites ?? false);
425
+ set(
426
+ "delete_protection",
427
+ config.delete_protection ?? false,
428
+ news.deleteProtection ?? false,
429
+ );
430
+ set("allowed_ips", config.allowed_ips ?? [], news.allowedIps ?? []);
431
+ set("allowed_aws_vpc_ids", config.allowed_aws_vpc_ids ?? [], news.allowedAwsVpcIds ?? []);
432
+ if (dirty) {
433
+ config = yield* turso.updateDatabaseConfiguration({ organizationSlug: org, ...patch });
434
+ }
435
+
436
+ // Sync schema — migrations, then SQL imports.
437
+ const target = { name, hostname: db.Hostname! };
438
+ const migrationsInput = migrationsInputOf(news);
439
+ const migrations = migrationsInput
440
+ ? yield* runMigrations({
441
+ input: migrationsInput,
442
+ stamped: stampedOf(output),
443
+ withExecutor: (apply) =>
444
+ withConnection(org, target, (connection, client) =>
445
+ apply(Hrana.makeMigrationExecutor(connection, client)),
446
+ ),
447
+ })
448
+ : undefined;
449
+
450
+ const previousImports = output?.importHashes ?? {};
451
+ const importHashes: Record<string, string> = {};
452
+ if (news.importFiles?.length) {
453
+ const root = yield* rootDir;
454
+ for (const filePath of news.importFiles) {
455
+ const file = yield* readSqlFile(root, filePath);
456
+ if (previousImports[filePath] !== file.hash) {
457
+ yield* session.note(`Importing ${filePath}...`);
458
+ yield* withConnection(org, target, (connection, client) =>
459
+ Hrana.transaction(connection, splitSqlStatements(file.sql)).pipe(
460
+ Effect.provideService(HttpClient.HttpClient, client),
461
+ ),
462
+ );
463
+ }
464
+ importHashes[filePath] = file.hash;
465
+ }
466
+ }
467
+
468
+ return toAttrs(org, db, config, {
469
+ ...migrationsAttrs({ input: migrationsInput, run: migrations, output }),
470
+ importHashes,
471
+ });
472
+ }),
473
+ delete: Effect.fn(function* ({ output }) {
474
+ yield* turso
475
+ .deleteDatabase({ organizationSlug: output.organization, databaseName: output.name })
476
+ .pipe(Effect.catchTag("NotFound", () => Effect.void));
477
+ }),
478
+ list: Effect.fn(function* () {
479
+ const org = yield* organization;
480
+ const { databases } = yield* turso.listDatabases({ organizationSlug: org });
481
+ return (databases ?? [])
482
+ .filter((db) => db.Name !== undefined)
483
+ .map((db) =>
484
+ toAttrs(
485
+ org,
486
+ db,
487
+ {
488
+ allow_attach: db.allow_attach,
489
+ block_reads: db.block_reads,
490
+ block_writes: db.block_writes,
491
+ delete_protection: db.delete_protection,
492
+ },
493
+ {
494
+ migrationsDir: undefined,
495
+ migrationsTable: undefined,
496
+ migrationsHashes: {},
497
+ importHashes: {},
498
+ },
499
+ ),
500
+ );
501
+ }),
502
+ };
503
+ }),
504
+ );
@@ -0,0 +1,203 @@
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 Effect from "effect/Effect";
7
+ import * as Redacted from "effect/Redacted";
8
+ import { organization } from "./Credentials.ts";
9
+ import type { Providers } from "./Providers.ts";
10
+
11
+ /** Access level of a Turso SQL auth token. */
12
+ export type TokenAuthorization = "full-access" | "read-only";
13
+
14
+ /** Props shared by {@link DatabaseToken} and {@link GroupToken}. */
15
+ export interface SqlTokenProps {
16
+ /**
17
+ * Access level granted by the token.
18
+ * @default "full-access"
19
+ */
20
+ authorization?: TokenAuthorization;
21
+ /**
22
+ * Token lifetime, e.g. `"2w"`, `"7d"`, `"1h30m"`. Omit for a token that
23
+ * never expires. An expired token is re-minted on the next deploy.
24
+ */
25
+ expiration?: string;
26
+ /**
27
+ * Databases this token may `ATTACH` for reading (requires `allowAttach`
28
+ * on those databases). Not supported on AWS-hosted databases.
29
+ */
30
+ readAttach?: string[];
31
+ }
32
+
33
+ /** Attributes shared by {@link DatabaseToken} and {@link GroupToken}. */
34
+ export interface SqlTokenAttributes {
35
+ /** The token (a JWT), sent as `Authorization: Bearer <token>`. */
36
+ token: Redacted.Redacted<string>;
37
+ /** The organization slug the token belongs to. */
38
+ organization: string;
39
+ /** Access level granted by the token. */
40
+ authorization: TokenAuthorization;
41
+ /** Expiry as epoch milliseconds, or `undefined` for a non-expiring token. */
42
+ expiresAt: number | undefined;
43
+ }
44
+
45
+ export interface DatabaseTokenProps extends SqlTokenProps {
46
+ /** Name of the database the token grants access to (e.g. `db.name`). */
47
+ database: string;
48
+ }
49
+
50
+ export interface DatabaseTokenAttributes extends SqlTokenAttributes {
51
+ /** Name of the database the token grants access to. */
52
+ database: string;
53
+ /** UUID of the database the token was minted for. */
54
+ dbId: string;
55
+ }
56
+
57
+ export type DatabaseToken = Resource<
58
+ "Turso.DatabaseToken",
59
+ DatabaseTokenProps,
60
+ DatabaseTokenAttributes,
61
+ never,
62
+ Providers
63
+ >;
64
+
65
+ /**
66
+ * A token that lets an app run SQL on one Turso database. Workers and
67
+ * Lambdas should use {@link Connect}, which creates one for you. Use
68
+ * `DatabaseToken` for anything else, such as a container or a CI job.
69
+ *
70
+ * You can't cancel a single token. Changing a prop creates a new token, but
71
+ * the old one keeps working until it expires. Destroying the resource doesn't
72
+ * stop it either. Give tokens you share an `expiration`, or cancel every token
73
+ * for the database with `turso db tokens invalidate`.
74
+ * @see https://docs.turso.tech/api-reference/databases/create-token
75
+ *
76
+ * ### Creating a Token
77
+ * **Example:** Full-access token
78
+ * ```typescript
79
+ * const token = yield* Turso.DatabaseToken("Token", {
80
+ * database: db.name,
81
+ * });
82
+ * ```
83
+ *
84
+ * **Example:** Read-only token that expires after two weeks
85
+ * ```typescript
86
+ * const token = yield* Turso.DatabaseToken("ReadOnly", {
87
+ * database: db.name,
88
+ * authorization: "read-only",
89
+ * expiration: "2w",
90
+ * });
91
+ * ```
92
+ *
93
+ * @resource
94
+ * @product Database
95
+ */
96
+ export const DatabaseToken = Resource<DatabaseToken>("Turso.DatabaseToken");
97
+
98
+ /** Decode a Turso JWT's `exp` claim (seconds) to epoch milliseconds. */
99
+ export const tokenExpiry = (token: Redacted.Redacted<string>): number | undefined => {
100
+ const payload = Redacted.value(token).split(".")[1];
101
+ if (!payload) return undefined;
102
+ try {
103
+ const claims = JSON.parse(Buffer.from(payload, "base64url").toString("utf8")) as {
104
+ exp?: number;
105
+ };
106
+ return typeof claims.exp === "number" ? claims.exp * 1000 : undefined;
107
+ } catch {
108
+ return undefined;
109
+ }
110
+ };
111
+
112
+ /** Re-mint a token that expires within the hour. */
113
+ const RENEW_WITHIN_MS = 60 * 60 * 1000;
114
+
115
+ /**
116
+ * `diff` for SQL tokens: an unchanged token that is about to expire still
117
+ * needs an update, so the deploy re-mints it.
118
+ */
119
+ export const diffSqlToken = Effect.fn(function* ({
120
+ output,
121
+ }: {
122
+ output: SqlTokenAttributes | undefined;
123
+ }) {
124
+ if (output?.expiresAt === undefined) return undefined;
125
+ const now = yield* Clock.currentTimeMillis;
126
+ return output.expiresAt - now <= RENEW_WITHIN_MS ? ({ action: "update" } as const) : undefined;
127
+ });
128
+
129
+ /** Whether a previously minted token can be kept as-is. */
130
+ export const isTokenCurrent = Effect.fn(function* (
131
+ output: SqlTokenAttributes | undefined,
132
+ olds: SqlTokenProps | undefined,
133
+ news: SqlTokenProps,
134
+ ) {
135
+ if (output === undefined || olds === undefined) return false;
136
+ if (
137
+ (news.authorization ?? "full-access") !== (olds.authorization ?? "full-access") ||
138
+ news.expiration !== olds.expiration ||
139
+ !deepEqual(news.readAttach ?? [], olds.readAttach ?? [])
140
+ ) {
141
+ return false;
142
+ }
143
+ if (output.expiresAt === undefined) return true;
144
+ // Re-mint a token that expires within the hour, so a deploy never hands
145
+ // consumers a token about to lapse.
146
+ const now = yield* Clock.currentTimeMillis;
147
+ return output.expiresAt - now > RENEW_WITHIN_MS;
148
+ });
149
+
150
+ export const DatabaseTokenProvider = () =>
151
+ Provider.effect(
152
+ DatabaseToken,
153
+ Effect.gen(function* () {
154
+ return {
155
+ stables: ["organization"],
156
+ diff: diffSqlToken,
157
+ reconcile: Effect.fn(function* ({ news, olds, output }) {
158
+ const org = output?.organization ?? (yield* organization);
159
+
160
+ // Observe — the token is bound to the database's UUID, so a
161
+ // database replaced under the same name invalidates it.
162
+ const { database } = yield* turso.getDatabase({
163
+ organizationSlug: org,
164
+ databaseName: news.database,
165
+ });
166
+ const dbId = database?.DbId ?? "";
167
+ if (
168
+ output !== undefined &&
169
+ output.database === news.database &&
170
+ output.dbId === dbId &&
171
+ (yield* isTokenCurrent(output, olds, news))
172
+ ) {
173
+ return output;
174
+ }
175
+
176
+ // Ensure — mint.
177
+ const authorization = news.authorization ?? "full-access";
178
+ const { jwt } = yield* turso.createDatabaseToken({
179
+ organizationSlug: org,
180
+ databaseName: news.database,
181
+ authorization,
182
+ expiration: news.expiration,
183
+ permissions: news.readAttach
184
+ ? { read_attach: { databases: news.readAttach } }
185
+ : undefined,
186
+ });
187
+ if (!jwt) {
188
+ return yield* Effect.fail(new Error(`Turso returned no token for ${news.database}`));
189
+ }
190
+ return {
191
+ token: jwt,
192
+ organization: org,
193
+ authorization,
194
+ expiresAt: tokenExpiry(jwt),
195
+ database: news.database,
196
+ dbId,
197
+ };
198
+ }),
199
+ // Turso cannot revoke a single token; it simply stops being tracked.
200
+ delete: () => Effect.void,
201
+ };
202
+ }),
203
+ );