@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.
- package/LICENSE +201 -0
- package/README.md +68 -4
- package/lib/ApiToken.d.ts +73 -0
- package/lib/ApiToken.d.ts.map +1 -0
- package/lib/ApiToken.js +120 -0
- package/lib/ApiToken.js.map +1 -0
- package/lib/AuthProvider.d.ts +36 -0
- package/lib/AuthProvider.d.ts.map +1 -0
- package/lib/AuthProvider.js +85 -0
- package/lib/AuthProvider.js.map +1 -0
- package/lib/Connect.d.ts +79 -0
- package/lib/Connect.d.ts.map +1 -0
- package/lib/Connect.js +22 -0
- package/lib/Connect.js.map +1 -0
- package/lib/Contributions.d.ts +7 -0
- package/lib/Contributions.d.ts.map +1 -0
- package/lib/Contributions.js +6 -0
- package/lib/Contributions.js.map +1 -0
- package/lib/Credentials.d.ts +39 -0
- package/lib/Credentials.d.ts.map +1 -0
- package/lib/Credentials.js +80 -0
- package/lib/Credentials.js.map +1 -0
- package/lib/Database.d.ts +217 -0
- package/lib/Database.d.ts.map +1 -0
- package/lib/Database.js +271 -0
- package/lib/Database.js.map +1 -0
- package/lib/DatabaseToken.d.ts +95 -0
- package/lib/DatabaseToken.d.ts.map +1 -0
- package/lib/DatabaseToken.js +128 -0
- package/lib/DatabaseToken.js.map +1 -0
- package/lib/Group.d.ts +93 -0
- package/lib/Group.d.ts.map +1 -0
- package/lib/Group.js +162 -0
- package/lib/Group.js.map +1 -0
- package/lib/GroupToken.d.ts +48 -0
- package/lib/GroupToken.d.ts.map +1 -0
- package/lib/GroupToken.js +83 -0
- package/lib/GroupToken.js.map +1 -0
- package/lib/Hrana.d.ts +42 -0
- package/lib/Hrana.d.ts.map +1 -0
- package/lib/Hrana.js +105 -0
- package/lib/Hrana.js.map +1 -0
- package/lib/ManageDatabases.d.ts +73 -0
- package/lib/ManageDatabases.d.ts.map +1 -0
- package/lib/ManageDatabases.js +81 -0
- package/lib/ManageDatabases.js.map +1 -0
- package/lib/OrganizationInvite.d.ts +46 -0
- package/lib/OrganizationInvite.d.ts.map +1 -0
- package/lib/OrganizationInvite.js +72 -0
- package/lib/OrganizationInvite.js.map +1 -0
- package/lib/OrganizationMember.d.ts +47 -0
- package/lib/OrganizationMember.d.ts.map +1 -0
- package/lib/OrganizationMember.js +81 -0
- package/lib/OrganizationMember.js.map +1 -0
- package/lib/OrganizationSettings.d.ts +52 -0
- package/lib/OrganizationSettings.d.ts.map +1 -0
- package/lib/OrganizationSettings.js +93 -0
- package/lib/OrganizationSettings.js.map +1 -0
- package/lib/Providers.d.ts +24 -0
- package/lib/Providers.d.ts.map +1 -0
- package/lib/Providers.js +42 -0
- package/lib/Providers.js.map +1 -0
- package/lib/index.d.ts +14 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +14 -0
- package/lib/index.js.map +1 -0
- package/package.json +54 -3
- package/src/ApiToken.ts +197 -0
- package/src/AuthProvider.ts +113 -0
- package/src/Connect.ts +103 -0
- package/src/Contributions.ts +9 -0
- package/src/Credentials.ts +139 -0
- package/src/Database.ts +504 -0
- package/src/DatabaseToken.ts +203 -0
- package/src/Group.ts +256 -0
- package/src/GroupToken.ts +119 -0
- package/src/Hrana.ts +182 -0
- package/src/ManageDatabases.ts +195 -0
- package/src/OrganizationInvite.ts +118 -0
- package/src/OrganizationMember.ts +122 -0
- package/src/OrganizationSettings.ts +145 -0
- package/src/Providers.ts +67 -0
- 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
|
+
};
|