@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
package/src/Group.ts ADDED
@@ -0,0 +1,256 @@
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 Effect from "effect/Effect";
8
+ import { organization } from "./Credentials.ts";
9
+ import type { Providers } from "./Providers.ts";
10
+
11
+ /**
12
+ * A Turso location code, e.g. `"aws-us-east-1"`. List the locations
13
+ * available to your organization with `turso db locations`.
14
+ */
15
+ export type Location =
16
+ | "aws-ap-northeast-1"
17
+ | "aws-ap-south-1"
18
+ | "aws-ap-southeast-2"
19
+ | "aws-ca-central-1"
20
+ | "aws-eu-north-1"
21
+ | "aws-eu-west-1"
22
+ | "aws-sa-east-1"
23
+ | "aws-us-east-1"
24
+ | "aws-us-east-2"
25
+ | "aws-us-west-2"
26
+ | (string & {});
27
+
28
+ export interface GroupProps {
29
+ /**
30
+ * Group name, unique within the organization. Lowercase letters,
31
+ * numbers, and dashes. If omitted, a unique name is generated from the
32
+ * stack, stage, and logical ID. Changing it replaces the group.
33
+ */
34
+ name?: string;
35
+ /**
36
+ * Primary location of the group. Every database in the group lives here.
37
+ * Changing it replaces the group (and every database in it).
38
+ * @default "aws-us-east-1"
39
+ */
40
+ location?: Location;
41
+ /**
42
+ * Additional replica locations. Reconciled in place by adding and
43
+ * removing locations. AWS-hosted groups do not support replication, so
44
+ * this only applies to organizations with legacy Fly-hosted groups.
45
+ */
46
+ replicas?: Location[];
47
+ /**
48
+ * SQLite extensions enabled for the group's databases: `"all"`, or a list
49
+ * of extension names. Set at creation; changing it replaces the group.
50
+ */
51
+ extensions?: "all" | string[];
52
+ /**
53
+ * Prevent the group (and its databases) from being deleted. While
54
+ * enabled, destroying or replacing the group fails; set it to `false`
55
+ * and deploy before deleting.
56
+ * @default false
57
+ */
58
+ deleteProtection?: boolean;
59
+ }
60
+
61
+ export interface GroupAttributes {
62
+ /** Group name. */
63
+ name: string;
64
+ /** Group UUID. */
65
+ uuid: string;
66
+ /** The organization slug that owns the group. */
67
+ organization: string;
68
+ /** Primary location of the group. */
69
+ location: string;
70
+ /** Every location the group runs in (primary first). */
71
+ locations: string[];
72
+ /** The libSQL server version the group's databases run. */
73
+ version: string;
74
+ /** Whether delete protection is enabled. */
75
+ deleteProtection: boolean;
76
+ }
77
+
78
+ export type Group = Resource<"Turso.Group", GroupProps, GroupAttributes, never, Providers>;
79
+
80
+ /**
81
+ * A Turso group. Every Turso database belongs to one group, and the group
82
+ * decides where its databases run.
83
+ * @see https://docs.turso.tech/concepts#groups
84
+ *
85
+ * ### Creating a Group
86
+ * **Example:** Group in a single location
87
+ * ```typescript
88
+ * const group = yield* Turso.Group("Group", {
89
+ * location: "aws-us-east-1",
90
+ * });
91
+ * ```
92
+ *
93
+ * **Example:** Protected group with all extensions enabled
94
+ * ```typescript
95
+ * const group = yield* Turso.Group("Group", {
96
+ * name: "production",
97
+ * location: "aws-eu-west-1",
98
+ * extensions: "all",
99
+ * deleteProtection: true,
100
+ * });
101
+ * ```
102
+ *
103
+ * ### Adding databases
104
+ * **Example:** Create a database in the group
105
+ * ```typescript
106
+ * const db = yield* Turso.Database("Db", { group: group.name });
107
+ * ```
108
+ *
109
+ * @resource
110
+ * @product Group
111
+ */
112
+ export const Group = Resource<Group>("Turso.Group");
113
+
114
+ const DEFAULT_LOCATION = "aws-us-east-1";
115
+
116
+ export const GroupProvider = () =>
117
+ Provider.effect(
118
+ Group,
119
+ Effect.gen(function* () {
120
+ const groupName = (id: string, name: string | undefined) =>
121
+ name
122
+ ? Effect.succeed(name)
123
+ : createPhysicalName({ id, lowercase: true, maxLength: 32, delimiter: "-" });
124
+
125
+ const observe = (org: string, name: string) =>
126
+ turso.getGroup({ organizationSlug: org, groupName: name }).pipe(
127
+ Effect.map((res) => res.group),
128
+ Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
129
+ );
130
+
131
+ const toAttrs = (
132
+ org: string,
133
+ group: turso.Group,
134
+ deleteProtection: boolean,
135
+ ): GroupAttributes => ({
136
+ name: group.name!,
137
+ uuid: group.uuid ?? "",
138
+ organization: org,
139
+ location: group.primary ?? "",
140
+ locations: group.locations ?? [],
141
+ version: group.version ?? "",
142
+ deleteProtection,
143
+ });
144
+
145
+ return {
146
+ stables: ["name", "uuid", "organization", "location"],
147
+ diff: Effect.fn(function* ({ id, olds, news, output }) {
148
+ if (!isResolved(news)) return undefined;
149
+ const name = output?.name ?? (yield* groupName(id, olds?.name));
150
+ if (news.name !== undefined && news.name !== name) {
151
+ return { action: "replace" } as const;
152
+ }
153
+ if ((news.location ?? DEFAULT_LOCATION) !== (olds?.location ?? DEFAULT_LOCATION)) {
154
+ return { action: "replace", deleteFirst: news.name !== undefined } as const;
155
+ }
156
+ if (!deepEqual(news.extensions, olds?.extensions)) {
157
+ return { action: "replace", deleteFirst: news.name !== undefined } as const;
158
+ }
159
+ }),
160
+ read: Effect.fn(function* ({ id, olds, output }) {
161
+ const org = output?.organization ?? (yield* organization);
162
+ const name = output?.name ?? (yield* groupName(id, olds?.name));
163
+ const group = yield* observe(org, name);
164
+ if (!group) return undefined;
165
+ const attrs = toAttrs(org, group, group.delete_protection ?? false);
166
+ // Turso has no tags: a group found under an explicit, user-chosen
167
+ // name without prior state may belong to someone else.
168
+ return output === undefined && olds?.name !== undefined ? Unowned(attrs) : attrs;
169
+ }),
170
+ reconcile: Effect.fn(function* ({ id, news, output, session }) {
171
+ const org = output?.organization ?? (yield* organization);
172
+ const name = output?.name ?? (yield* groupName(id, news.name));
173
+ const location = news.location ?? DEFAULT_LOCATION;
174
+
175
+ // Observe
176
+ let group = yield* observe(org, name);
177
+
178
+ // Ensure
179
+ if (!group) {
180
+ yield* session.note(`Creating group ${name}...`);
181
+ group = yield* turso
182
+ .createGroup({
183
+ organizationSlug: org,
184
+ name,
185
+ location,
186
+ extensions: news.extensions,
187
+ })
188
+ .pipe(
189
+ Effect.map((res) => res.group),
190
+ Effect.catchTag("Conflict", () => observe(org, name)),
191
+ );
192
+ if (!group) {
193
+ return yield* Effect.fail(new Error(`Turso group ${name} vanished after create`));
194
+ }
195
+ }
196
+
197
+ // Sync replica locations against the observed set.
198
+ const primary = group.primary ?? location;
199
+ const desired = new Set([primary, ...(news.replicas ?? [])]);
200
+ const observed = new Set(group.locations ?? [primary]);
201
+ for (const loc of desired) {
202
+ if (!observed.has(loc)) {
203
+ yield* session.note(`Adding location ${loc}...`);
204
+ group =
205
+ (yield* turso.addLocationToGroup({
206
+ organizationSlug: org,
207
+ groupName: name,
208
+ location: loc,
209
+ })).group ?? group;
210
+ }
211
+ }
212
+ for (const loc of observed) {
213
+ if (!desired.has(loc)) {
214
+ yield* session.note(`Removing location ${loc}...`);
215
+ group =
216
+ (yield* turso.removeLocationFromGroup({
217
+ organizationSlug: org,
218
+ groupName: name,
219
+ location: loc,
220
+ })).group ?? group;
221
+ }
222
+ }
223
+
224
+ // Sync configuration.
225
+ const config = yield* turso.getGroupConfiguration({
226
+ organizationSlug: org,
227
+ groupName: name,
228
+ });
229
+ let deleteProtection = config.delete_protection ?? false;
230
+ const desiredProtection = news.deleteProtection ?? false;
231
+ if (deleteProtection !== desiredProtection) {
232
+ const updated = yield* turso.updateGroupConfiguration({
233
+ organizationSlug: org,
234
+ groupName: name,
235
+ delete_protection: desiredProtection,
236
+ });
237
+ deleteProtection = updated.delete_protection ?? desiredProtection;
238
+ }
239
+
240
+ return toAttrs(org, group, deleteProtection);
241
+ }),
242
+ delete: Effect.fn(function* ({ output }) {
243
+ yield* turso
244
+ .deleteGroup({ organizationSlug: output.organization, groupName: output.name })
245
+ .pipe(Effect.catchTag("NotFound", () => Effect.void));
246
+ }),
247
+ list: Effect.fn(function* () {
248
+ const org = yield* organization;
249
+ const { groups } = yield* turso.listGroups({ organizationSlug: org });
250
+ return (groups ?? [])
251
+ .filter((group) => group.name !== undefined)
252
+ .map((group) => toAttrs(org, group, group.delete_protection ?? false));
253
+ }),
254
+ };
255
+ }),
256
+ );
@@ -0,0 +1,119 @@
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 Effect from "effect/Effect";
5
+ import { organization } from "./Credentials.ts";
6
+ import {
7
+ diffSqlToken,
8
+ isTokenCurrent,
9
+ tokenExpiry,
10
+ type SqlTokenAttributes,
11
+ type SqlTokenProps,
12
+ } from "./DatabaseToken.ts";
13
+ import type { Providers } from "./Providers.ts";
14
+
15
+ export interface GroupTokenProps extends SqlTokenProps {
16
+ /** Name of the group whose databases the token grants access to. */
17
+ group: string;
18
+ }
19
+
20
+ export interface GroupTokenAttributes extends SqlTokenAttributes {
21
+ /** Name of the group the token grants access to. */
22
+ group: string;
23
+ /** UUID of the group the token was minted for. */
24
+ groupId: string;
25
+ }
26
+
27
+ export type GroupToken = Resource<
28
+ "Turso.GroupToken",
29
+ GroupTokenProps,
30
+ GroupTokenAttributes,
31
+ never,
32
+ Providers
33
+ >;
34
+
35
+ /**
36
+ * A token that lets an app run SQL on every database in a Turso
37
+ * {@link Group}, even databases created later. Useful when each customer
38
+ * has their own database.
39
+ *
40
+ * Like {@link DatabaseToken}, you can't cancel a single group token. Changing
41
+ * a prop creates a new token, but the old one keeps working until it expires.
42
+ * @see https://docs.turso.tech/api-reference/groups/create-token
43
+ *
44
+ * ### Creating a Token
45
+ * **Example:** Full-access token for every database in a group
46
+ * ```typescript
47
+ * const token = yield* Turso.GroupToken("Token", {
48
+ * group: group.name,
49
+ * });
50
+ * ```
51
+ *
52
+ * **Example:** Read-only token that expires after a week
53
+ * ```typescript
54
+ * const token = yield* Turso.GroupToken("ReadOnly", {
55
+ * group: group.name,
56
+ * authorization: "read-only",
57
+ * expiration: "7d",
58
+ * });
59
+ * ```
60
+ *
61
+ * @resource
62
+ * @product Group
63
+ */
64
+ export const GroupToken = Resource<GroupToken>("Turso.GroupToken");
65
+
66
+ export const GroupTokenProvider = () =>
67
+ Provider.effect(
68
+ GroupToken,
69
+ Effect.gen(function* () {
70
+ return {
71
+ stables: ["organization"],
72
+ diff: diffSqlToken,
73
+ reconcile: Effect.fn(function* ({ news, olds, output }) {
74
+ const org = output?.organization ?? (yield* organization);
75
+
76
+ // Observe — the token is bound to the group's UUID, so a group
77
+ // replaced under the same name invalidates it.
78
+ const { group } = yield* turso.getGroup({
79
+ organizationSlug: org,
80
+ groupName: news.group,
81
+ });
82
+ const groupId = group?.uuid ?? "";
83
+ if (
84
+ output !== undefined &&
85
+ output.group === news.group &&
86
+ output.groupId === groupId &&
87
+ (yield* isTokenCurrent(output, olds, news))
88
+ ) {
89
+ return output;
90
+ }
91
+
92
+ // Ensure — mint.
93
+ const authorization = news.authorization ?? "full-access";
94
+ const { jwt } = yield* turso.createGroupToken({
95
+ organizationSlug: org,
96
+ groupName: news.group,
97
+ authorization,
98
+ expiration: news.expiration,
99
+ permissions: news.readAttach
100
+ ? { read_attach: { databases: news.readAttach } }
101
+ : undefined,
102
+ });
103
+ if (!jwt) {
104
+ return yield* Effect.fail(new Error(`Turso returned no token for group ${news.group}`));
105
+ }
106
+ return {
107
+ token: jwt,
108
+ organization: org,
109
+ authorization,
110
+ expiresAt: tokenExpiry(jwt),
111
+ group: news.group,
112
+ groupId,
113
+ };
114
+ }),
115
+ // Turso cannot revoke a single token; it simply stops being tracked.
116
+ delete: () => Effect.void,
117
+ };
118
+ }),
119
+ );
package/src/Hrana.ts ADDED
@@ -0,0 +1,182 @@
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
+ return { type: "text", value: String(value) };
65
+ };
66
+
67
+ const fromHranaValue = (value: HranaValue): unknown => {
68
+ switch (value.type) {
69
+ case "null":
70
+ return null;
71
+ case "integer":
72
+ return Number(value.value);
73
+ case "float":
74
+ return value.value;
75
+ case "text":
76
+ return value.value;
77
+ case "blob":
78
+ return Buffer.from(value.base64, "base64");
79
+ }
80
+ };
81
+
82
+ const toRows = (result: HranaExecuteResult): Array<Record<string, unknown>> =>
83
+ result.rows.map((row) =>
84
+ Object.fromEntries(
85
+ result.cols.map((col, i) => [col.name ?? String(i), fromHranaValue(row[i]!)]),
86
+ ),
87
+ );
88
+
89
+ /** Connection details for one Turso database. */
90
+ export interface HranaConnection {
91
+ /** `https://<hostname>` of the database. */
92
+ url: string;
93
+ /** A database auth token (JWT). */
94
+ authToken: Redacted.Redacted<string>;
95
+ }
96
+
97
+ const pipeline = (connection: HranaConnection, requests: ReadonlyArray<unknown>) =>
98
+ Effect.gen(function* () {
99
+ const client = yield* HttpClient.HttpClient;
100
+ const response = yield* client
101
+ .execute(
102
+ HttpClientRequest.post(`${connection.url.replace(/\/$/, "")}/v2/pipeline`).pipe(
103
+ HttpClientRequest.bearerToken(Redacted.value(connection.authToken)),
104
+ HttpClientRequest.bodyJsonUnsafe({ requests: [...requests, { type: "close" }] }),
105
+ ),
106
+ )
107
+ .pipe(Effect.mapError((cause) => new HranaError({ message: String(cause), cause })));
108
+ if (response.status < 200 || response.status >= 300) {
109
+ const text = yield* response.text.pipe(Effect.orElseSucceed(() => ""));
110
+ return yield* new HranaError({
111
+ message: `Turso SQL request failed with HTTP ${response.status}: ${text}`,
112
+ });
113
+ }
114
+ const body = (yield* response.json.pipe(
115
+ Effect.mapError((cause) => new HranaError({ message: String(cause), cause })),
116
+ )) as unknown as PipelineResponse;
117
+ return body.results.slice(0, requests.length);
118
+ });
119
+
120
+ /** Execute one statement and return its rows as objects. */
121
+ export const query = (
122
+ connection: HranaConnection,
123
+ sql: string,
124
+ params?: ReadonlyArray<unknown>,
125
+ ): Effect.Effect<Array<Record<string, unknown>>, HranaError, HttpClient.HttpClient> =>
126
+ Effect.gen(function* () {
127
+ const stmt: HranaStmt = { sql, args: (params ?? []).map(toHranaValue) };
128
+ const [result] = yield* pipeline(connection, [{ type: "execute", stmt }]);
129
+ if (result === undefined) {
130
+ return yield* new HranaError({ message: "Turso returned no result" });
131
+ }
132
+ if (result.type === "error") {
133
+ return yield* new HranaError({ message: result.error.message, code: result.error.code });
134
+ }
135
+ return toRows(result.response.result as HranaExecuteResult);
136
+ });
137
+
138
+ /**
139
+ * Execute statements atomically. Each entry may hold several
140
+ * `;`-separated statements (a whole migration file or seed script), so they
141
+ * run as one Hrana `sequence` wrapped in `BEGIN`/`COMMIT`. A failing
142
+ * statement stops the sequence before `COMMIT`, and closing the stream
143
+ * rolls the open transaction back.
144
+ */
145
+ export const transaction = (
146
+ connection: HranaConnection,
147
+ statements: ReadonlyArray<string>,
148
+ ): Effect.Effect<void, HranaError, HttpClient.HttpClient> =>
149
+ Effect.gen(function* () {
150
+ const body = statements.flatMap(splitSqlStatements);
151
+ if (body.length === 0) return;
152
+ const sql = ["BEGIN", ...body.map((s) => s.replace(/;\s*$/, "")), "COMMIT"].join(";\n");
153
+ const [result] = yield* pipeline(connection, [{ type: "sequence", sql }]);
154
+ if (result === undefined) {
155
+ return yield* new HranaError({ message: "Turso returned no result" });
156
+ }
157
+ if (result.type === "error") {
158
+ return yield* new HranaError({ message: result.error.message, code: result.error.code });
159
+ }
160
+ });
161
+
162
+ /** Adapt a Turso database connection into the migration registry's executor. */
163
+ export const makeMigrationExecutor = (
164
+ connection: HranaConnection,
165
+ client: HttpClient.HttpClient,
166
+ ): SqlExecutor => {
167
+ const toMigrationError = (error: HranaError) =>
168
+ new MigrationError({ message: `turso: ${error.message}`, cause: error });
169
+ return {
170
+ dialect: "sqlite",
171
+ query: (sql, params) =>
172
+ query(connection, sql, params).pipe(
173
+ Effect.provideService(HttpClient.HttpClient, client),
174
+ Effect.mapError(toMigrationError),
175
+ ),
176
+ batch: (statements) =>
177
+ transaction(connection, statements).pipe(
178
+ Effect.provideService(HttpClient.HttpClient, client),
179
+ Effect.mapError(toMigrationError),
180
+ ),
181
+ };
182
+ };