Skip to content
Source
Sign inSign up
Open History to see the last commit

Database.ts

519 lines18.1 KB

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