@alchemy.run/planetscale 0.0.0 → 2.0.0-beta.85
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +83 -4
- package/lib/AuthProvider.d.ts +64 -0
- package/lib/AuthProvider.d.ts.map +1 -0
- package/lib/AuthProvider.js +395 -0
- package/lib/AuthProvider.js.map +1 -0
- package/lib/Branch.d.ts +128 -0
- package/lib/Branch.d.ts.map +1 -0
- package/lib/Branch.js +420 -0
- package/lib/Branch.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 +33 -0
- package/lib/Credentials.d.ts.map +1 -0
- package/lib/Credentials.js +59 -0
- package/lib/Credentials.js.map +1 -0
- package/lib/Database.d.ts +138 -0
- package/lib/Database.d.ts.map +1 -0
- package/lib/Database.js +1 -0
- package/lib/Database.js.map +1 -0
- package/lib/MySQL/MySQLBranch.d.ts +87 -0
- package/lib/MySQL/MySQLBranch.d.ts.map +1 -0
- package/lib/MySQL/MySQLBranch.js +15 -0
- package/lib/MySQL/MySQLBranch.js.map +1 -0
- package/lib/MySQL/MySQLClusterSize.d.ts +32 -0
- package/lib/MySQL/MySQLClusterSize.d.ts.map +1 -0
- package/lib/MySQL/MySQLClusterSize.js +111 -0
- package/lib/MySQL/MySQLClusterSize.js.map +1 -0
- package/lib/MySQL/MySQLDatabase.d.ts +141 -0
- package/lib/MySQL/MySQLDatabase.d.ts.map +1 -0
- package/lib/MySQL/MySQLDatabase.js +294 -0
- package/lib/MySQL/MySQLDatabase.js.map +1 -0
- package/lib/MySQL/MySQLMigrations.d.ts +20 -0
- package/lib/MySQL/MySQLMigrations.d.ts.map +1 -0
- package/lib/MySQL/MySQLMigrations.js +72 -0
- package/lib/MySQL/MySQLMigrations.js.map +1 -0
- package/lib/MySQL/MySQLOrigin.d.ts +15 -0
- package/lib/MySQL/MySQLOrigin.d.ts.map +1 -0
- package/lib/MySQL/MySQLOrigin.js +2 -0
- package/lib/MySQL/MySQLOrigin.js.map +1 -0
- package/lib/MySQL/MySQLPassword.d.ts +116 -0
- package/lib/MySQL/MySQLPassword.d.ts.map +1 -0
- package/lib/MySQL/MySQLPassword.js +212 -0
- package/lib/MySQL/MySQLPassword.js.map +1 -0
- package/lib/MySQL/index.d.ts +6 -0
- package/lib/MySQL/index.d.ts.map +1 -0
- package/lib/MySQL/index.js +6 -0
- package/lib/MySQL/index.js.map +1 -0
- package/lib/OAuthClient.d.ts +32 -0
- package/lib/OAuthClient.d.ts.map +1 -0
- package/lib/OAuthClient.js +52 -0
- package/lib/OAuthClient.js.map +1 -0
- package/lib/Postgres/PostgresBranch.d.ts +75 -0
- package/lib/Postgres/PostgresBranch.d.ts.map +1 -0
- package/lib/Postgres/PostgresBranch.js +15 -0
- package/lib/Postgres/PostgresBranch.js.map +1 -0
- package/lib/Postgres/PostgresClusterSize.d.ts +77 -0
- package/lib/Postgres/PostgresClusterSize.d.ts.map +1 -0
- package/lib/Postgres/PostgresClusterSize.js +89 -0
- package/lib/Postgres/PostgresClusterSize.js.map +1 -0
- package/lib/Postgres/PostgresDatabase.d.ts +95 -0
- package/lib/Postgres/PostgresDatabase.d.ts.map +1 -0
- package/lib/Postgres/PostgresDatabase.js +294 -0
- package/lib/Postgres/PostgresDatabase.js.map +1 -0
- package/lib/Postgres/PostgresDefaultRole.d.ts +94 -0
- package/lib/Postgres/PostgresDefaultRole.d.ts.map +1 -0
- package/lib/Postgres/PostgresDefaultRole.js +187 -0
- package/lib/Postgres/PostgresDefaultRole.js.map +1 -0
- package/lib/Postgres/PostgresMigrations.d.ts +16 -0
- package/lib/Postgres/PostgresMigrations.d.ts.map +1 -0
- package/lib/Postgres/PostgresMigrations.js +65 -0
- package/lib/Postgres/PostgresMigrations.js.map +1 -0
- package/lib/Postgres/PostgresOrigin.d.ts +15 -0
- package/lib/Postgres/PostgresOrigin.d.ts.map +1 -0
- package/lib/Postgres/PostgresOrigin.js +2 -0
- package/lib/Postgres/PostgresOrigin.js.map +1 -0
- package/lib/Postgres/PostgresRole.d.ts +142 -0
- package/lib/Postgres/PostgresRole.d.ts.map +1 -0
- package/lib/Postgres/PostgresRole.js +268 -0
- package/lib/Postgres/PostgresRole.js.map +1 -0
- package/lib/Postgres/index.d.ts +7 -0
- package/lib/Postgres/index.d.ts.map +1 -0
- package/lib/Postgres/index.js +7 -0
- package/lib/Postgres/index.js.map +1 -0
- package/lib/Providers.d.ts +24 -0
- package/lib/Providers.d.ts.map +1 -0
- package/lib/Providers.js +40 -0
- package/lib/Providers.js.map +1 -0
- package/lib/Util.d.ts +93 -0
- package/lib/Util.d.ts.map +1 -0
- package/lib/Util.js +135 -0
- package/lib/Util.js.map +1 -0
- package/lib/index.d.ts +9 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +9 -0
- package/lib/index.js.map +1 -0
- package/package.json +79 -3
- package/src/AuthProvider.ts +584 -0
- package/src/Branch.ts +657 -0
- package/src/Contributions.ts +9 -0
- package/src/Credentials.ts +92 -0
- package/src/Database.ts +152 -0
- package/src/MySQL/MySQLBranch.ts +109 -0
- package/src/MySQL/MySQLClusterSize.ts +185 -0
- package/src/MySQL/MySQLDatabase.ts +521 -0
- package/src/MySQL/MySQLMigrations.ts +133 -0
- package/src/MySQL/MySQLOrigin.ts +15 -0
- package/src/MySQL/MySQLPassword.ts +428 -0
- package/src/MySQL/index.ts +5 -0
- package/src/OAuthClient.ts +59 -0
- package/src/Postgres/PostgresBranch.ts +97 -0
- package/src/Postgres/PostgresClusterSize.ts +262 -0
- package/src/Postgres/PostgresDatabase.ts +469 -0
- package/src/Postgres/PostgresDefaultRole.ts +340 -0
- package/src/Postgres/PostgresMigrations.ts +122 -0
- package/src/Postgres/PostgresOrigin.ts +15 -0
- package/src/Postgres/PostgresRole.ts +547 -0
- package/src/Postgres/index.ts +6 -0
- package/src/Providers.ts +67 -0
- package/src/Util.ts +205 -0
- package/src/index.ts +14 -0
|
@@ -0,0 +1,521 @@
|
|
|
1
|
+
import {
|
|
2
|
+
diffMigrations,
|
|
3
|
+
migrationsAttrs,
|
|
4
|
+
migrationsInputOf,
|
|
5
|
+
stampedOf,
|
|
6
|
+
} from "@alchemy.run/sql/Migrations";
|
|
7
|
+
import { hashImports } from "@alchemy.run/sql/SqlFile";
|
|
8
|
+
import * as planetscale from "@distilled.cloud/planetscale";
|
|
9
|
+
import { Credentials } from "@distilled.cloud/planetscale/Credentials";
|
|
10
|
+
import { havePropsChanged, isResolved } from "alchemy/Diff";
|
|
11
|
+
import { createPhysicalName } from "alchemy/PhysicalName";
|
|
12
|
+
import * as Provider from "alchemy/Provider";
|
|
13
|
+
import { Resource } from "alchemy/Resource";
|
|
14
|
+
import { recordsEqual } from "alchemy/Util/equal";
|
|
15
|
+
import * as Effect from "effect/Effect";
|
|
16
|
+
import * as Stream from "effect/Stream";
|
|
17
|
+
import type { BaseDatabaseAttributes, BaseDatabaseProps } from "../Database.ts";
|
|
18
|
+
import type { Providers } from "../Providers.ts";
|
|
19
|
+
import {
|
|
20
|
+
deleteUnprotectedDatabase,
|
|
21
|
+
PlanetscaleConflict,
|
|
22
|
+
replaceDatabase,
|
|
23
|
+
waitForBranchReady,
|
|
24
|
+
waitForDatabaseReady,
|
|
25
|
+
} from "../Util.ts";
|
|
26
|
+
import {
|
|
27
|
+
ensureMySQLProductionBranchClusterSize,
|
|
28
|
+
observeDefaultKeyspaceReplicas,
|
|
29
|
+
type MySQLClusterSize,
|
|
30
|
+
} from "./MySQLClusterSize.ts";
|
|
31
|
+
import { runMySQLImports, runMySQLMigrations } from "./MySQLMigrations.ts";
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Properties for creating or updating a MySQL PlanetScale database.
|
|
35
|
+
*/
|
|
36
|
+
export interface MySQLDatabaseProps extends BaseDatabaseProps {
|
|
37
|
+
/**
|
|
38
|
+
* The MySQL database cluster size. Required.
|
|
39
|
+
* @see https://planetscale.com/docs/concepts/cluster-size
|
|
40
|
+
*/
|
|
41
|
+
clusterSize: MySQLClusterSize;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Desired total number of replicas for the database's default keyspace.
|
|
45
|
+
* Each cluster size includes a fixed number of replicas (2 for
|
|
46
|
+
* production `PS_*` sizes); higher values add extra replicas. Changes
|
|
47
|
+
* are reconciled in place via PlanetScale's keyspace resize lifecycle —
|
|
48
|
+
* never by replacing the database.
|
|
49
|
+
* @see https://planetscale.com/docs/vitess/sharding/keyspaces
|
|
50
|
+
*/
|
|
51
|
+
replicas?: number;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Whether to copy migration data to new branches and in deploy requests.
|
|
55
|
+
*/
|
|
56
|
+
automaticMigrations?: boolean;
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Migration framework to use on the database.
|
|
60
|
+
*/
|
|
61
|
+
migrationFramework?: string;
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Name of the migration table.
|
|
65
|
+
*/
|
|
66
|
+
migrationTableName?: string;
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Whether data branching is allowed on the database.
|
|
70
|
+
*/
|
|
71
|
+
allowDataBranching?: boolean;
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Whether foreign key constraints are allowed on the database.
|
|
75
|
+
*/
|
|
76
|
+
allowForeignKeyConstraints?: boolean;
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Whether full queries should be collected from the database.
|
|
80
|
+
*/
|
|
81
|
+
insightsRawQueries?: boolean;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Output attributes of a deployed MySQL PlanetScale database.
|
|
86
|
+
*/
|
|
87
|
+
export interface MySQLDatabaseAttributes extends BaseDatabaseAttributes {
|
|
88
|
+
/**
|
|
89
|
+
* Observed total replica count of the default keyspace on the default
|
|
90
|
+
* branch, or `undefined` while the keyspace is still provisioning.
|
|
91
|
+
*/
|
|
92
|
+
replicas: number | undefined;
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Whether to copy migration data to new branches and in deploy requests.
|
|
96
|
+
*/
|
|
97
|
+
automaticMigrations: boolean;
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Migration framework to use on the database.
|
|
101
|
+
*/
|
|
102
|
+
migrationFramework?: string;
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Name of the migration table.
|
|
106
|
+
*/
|
|
107
|
+
migrationTableName?: string;
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Whether data branching is allowed on the database.
|
|
111
|
+
*/
|
|
112
|
+
allowDataBranching: boolean;
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Whether foreign key constraints are allowed on the database.
|
|
116
|
+
*/
|
|
117
|
+
allowForeignKeyConstraints: boolean;
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Whether full queries should be collected from the database.
|
|
121
|
+
*/
|
|
122
|
+
insightsRawQueries: boolean;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* A MySQL PlanetScale database (powered by Vitess). For PostgreSQL use
|
|
127
|
+
* {@link PostgresDatabase} instead.
|
|
128
|
+
*
|
|
129
|
+
* ### Creating a MySQL Database
|
|
130
|
+
* **Example:** Basic MySQL database
|
|
131
|
+
* ```typescript
|
|
132
|
+
* const db = yield* Planetscale.MySQLDatabase("MyDb", {
|
|
133
|
+
* clusterSize: "PS_10",
|
|
134
|
+
* });
|
|
135
|
+
* ```
|
|
136
|
+
*
|
|
137
|
+
* **Example:** MySQL with Vitess migration tooling
|
|
138
|
+
* ```typescript
|
|
139
|
+
* const db = yield* Planetscale.MySQLDatabase("MyDb", {
|
|
140
|
+
* clusterSize: "PS_10",
|
|
141
|
+
* automaticMigrations: true,
|
|
142
|
+
* migrationFramework: "rails",
|
|
143
|
+
* migrationTableName: "schema_migrations",
|
|
144
|
+
* allowDataBranching: true,
|
|
145
|
+
* });
|
|
146
|
+
* ```
|
|
147
|
+
*
|
|
148
|
+
* ### Migrations and seed data
|
|
149
|
+
* **Example:** Apply migrations and seed files
|
|
150
|
+
* ```typescript
|
|
151
|
+
* const db = yield* Planetscale.MySQLDatabase("MyDb", {
|
|
152
|
+
* clusterSize: "PS_10",
|
|
153
|
+
* migrationsDir: "./migrations/mysql",
|
|
154
|
+
* importFiles: ["./seed/mysql.sql"],
|
|
155
|
+
* });
|
|
156
|
+
* ```
|
|
157
|
+
*
|
|
158
|
+
* ### Deletion protection
|
|
159
|
+
* **Example:** Refuse deletes of a production database
|
|
160
|
+
* ```typescript
|
|
161
|
+
* const db = yield* Planetscale.MySQLDatabase("MyDb", {
|
|
162
|
+
* clusterSize: "PS_10",
|
|
163
|
+
* deletionProtection: true,
|
|
164
|
+
* });
|
|
165
|
+
* ```
|
|
166
|
+
*
|
|
167
|
+
* ### Adoption
|
|
168
|
+
* **Example:** Adopting an existing database
|
|
169
|
+
* ```typescript
|
|
170
|
+
* import { adopt } from "alchemy/AdoptPolicy";
|
|
171
|
+
*
|
|
172
|
+
* const db = yield* Planetscale.MySQLDatabase("Existing", {
|
|
173
|
+
* name: "existing-db",
|
|
174
|
+
* clusterSize: "PS_10",
|
|
175
|
+
* }).pipe(adopt());
|
|
176
|
+
* ```
|
|
177
|
+
*/
|
|
178
|
+
export type MySQLDatabase = Resource<
|
|
179
|
+
"Planetscale.MySQLDatabase",
|
|
180
|
+
MySQLDatabaseProps,
|
|
181
|
+
MySQLDatabaseAttributes,
|
|
182
|
+
never,
|
|
183
|
+
Providers
|
|
184
|
+
>;
|
|
185
|
+
|
|
186
|
+
/** @resource */
|
|
187
|
+
export const MySQLDatabase = Resource<MySQLDatabase>("Planetscale.MySQLDatabase");
|
|
188
|
+
|
|
189
|
+
export const MySQLDatabaseProvider = () =>
|
|
190
|
+
Provider.succeed(MySQLDatabase, {
|
|
191
|
+
stables: ["id", "organization", "region"],
|
|
192
|
+
diff: Effect.fn(function* ({ news, olds, output }) {
|
|
193
|
+
if (!isResolved(news)) return undefined;
|
|
194
|
+
|
|
195
|
+
// Database names are rename-mutable (reconcile folds `new_name`
|
|
196
|
+
// into the settings sync), so `name` cannot live in the
|
|
197
|
+
// provider-level stables. Almost no update is a rename though —
|
|
198
|
+
// for those, advertise `name` as stable on the update so
|
|
199
|
+
// downstream consumers (branches, passwords) still resolve
|
|
200
|
+
// `database.name` at plan time instead of seeing `undefined` and
|
|
201
|
+
// falsely planning a replacement. The name only changes when the
|
|
202
|
+
// `name` prop itself changes: an explicit name renames iff it
|
|
203
|
+
// differs from the observed name, and an omitted name is
|
|
204
|
+
// engine-generated deterministically (stable across updates).
|
|
205
|
+
const nameIsStable =
|
|
206
|
+
output?.name !== undefined &&
|
|
207
|
+
(news.name !== undefined ? news.name === output.name : olds?.name === undefined);
|
|
208
|
+
const stables = nameIsStable ? ["id", "organization", "region", "name"] : undefined;
|
|
209
|
+
|
|
210
|
+
if (
|
|
211
|
+
news.region?.slug !== undefined &&
|
|
212
|
+
output?.region?.slug !== undefined &&
|
|
213
|
+
news.region.slug !== output.region.slug
|
|
214
|
+
) {
|
|
215
|
+
return yield* replaceDatabase(news, output, "region");
|
|
216
|
+
}
|
|
217
|
+
// Replicas reconcile in place via a keyspace resize — never a
|
|
218
|
+
// replacement. Diff against the observed keyspace replica count so
|
|
219
|
+
// an adopted database whose live state already matches plans no-op.
|
|
220
|
+
if (news.replicas !== undefined && news.replicas !== (output?.replicas ?? olds.replicas)) {
|
|
221
|
+
return { action: "update", stables } as const;
|
|
222
|
+
}
|
|
223
|
+
if (yield* diffMigrations({ news, output })) {
|
|
224
|
+
return { action: "update", stables } as const;
|
|
225
|
+
}
|
|
226
|
+
if (news.importFiles?.length) {
|
|
227
|
+
const newHashes = yield* hashImports(news.importFiles, yield* rootDir);
|
|
228
|
+
if (!recordsEqual(newHashes, output?.importHashes ?? {})) {
|
|
229
|
+
return { action: "update", stables } as const;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
// Remaining prop changes (rename, settings, clusterSize, …) are
|
|
233
|
+
// in-place updates. Decide them here instead of falling back to
|
|
234
|
+
// the engine's default deep-compare so the conditional `name`
|
|
235
|
+
// stable above is attached — the default path uses the
|
|
236
|
+
// provider-level stables, which strip `name` from downstream plan
|
|
237
|
+
// resolution.
|
|
238
|
+
if (havePropsChanged(olds, news)) {
|
|
239
|
+
return { action: "update", stables } as const;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Nothing changed. Still advertise the conditional `name` stable so
|
|
243
|
+
// a `--force` deploy (which upgrades this noop to an update) keeps
|
|
244
|
+
// `name` resolvable downstream instead of falsely replacing
|
|
245
|
+
// consumers such as roles and passwords (#1832).
|
|
246
|
+
return stables ? ({ action: "noop", stables } as const) : undefined;
|
|
247
|
+
}),
|
|
248
|
+
|
|
249
|
+
read: Effect.fn(function* ({ id, output, olds }) {
|
|
250
|
+
const { organization } = yield* yield* Credentials;
|
|
251
|
+
const databaseName = output?.name ?? (yield* createDatabaseName(id, olds?.name));
|
|
252
|
+
return yield* planetscale
|
|
253
|
+
.getDatabase({
|
|
254
|
+
organization,
|
|
255
|
+
database: databaseName,
|
|
256
|
+
})
|
|
257
|
+
.pipe(
|
|
258
|
+
Effect.flatMap(
|
|
259
|
+
Effect.fn(function* (data) {
|
|
260
|
+
if (data.kind !== "mysql") {
|
|
261
|
+
return yield* new PlanetscaleConflict({
|
|
262
|
+
message:
|
|
263
|
+
`Planetscale database "${data.name}" has kind "${data.kind}" but this resource ` +
|
|
264
|
+
`is a MySQLDatabase. Use Planetscale.${data.kind === "postgresql" ? "PostgresDatabase" : data.kind}() instead, ` +
|
|
265
|
+
`or delete the existing database and retry.`,
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
// Observe the default keyspace's live replica configuration
|
|
269
|
+
// so adopted databases diff against reality.
|
|
270
|
+
const replicas = yield* observeDefaultKeyspaceReplicas(
|
|
271
|
+
organization,
|
|
272
|
+
data.name,
|
|
273
|
+
data.default_branch ?? "main",
|
|
274
|
+
);
|
|
275
|
+
return {
|
|
276
|
+
id: data.id,
|
|
277
|
+
name: data.name,
|
|
278
|
+
organization,
|
|
279
|
+
state: data.state,
|
|
280
|
+
defaultBranch: data.default_branch ?? "main",
|
|
281
|
+
replicas,
|
|
282
|
+
plan: data.plan ?? "hobby",
|
|
283
|
+
createdAt: data.created_at,
|
|
284
|
+
updatedAt: data.updated_at,
|
|
285
|
+
htmlUrl: data.html_url,
|
|
286
|
+
region: { slug: data.region.slug },
|
|
287
|
+
migrationsDir: output?.migrationsDir ?? (olds && migrationsInputOf(olds))?.dir,
|
|
288
|
+
migrationsTable:
|
|
289
|
+
output?.migrationsTable ?? (olds && migrationsInputOf(olds))?.table,
|
|
290
|
+
migrationsHashes: output?.migrationsHashes ?? {},
|
|
291
|
+
importHashes: output?.importHashes ?? {},
|
|
292
|
+
clusterSize: output?.clusterSize ?? "",
|
|
293
|
+
requireApprovalForDeploy: data.require_approval_for_deploy ?? false,
|
|
294
|
+
restrictBranchRegion: data.restrict_branch_region ?? false,
|
|
295
|
+
insightsRawQueries: data.insights_raw_queries ?? false,
|
|
296
|
+
productionBranchWebConsole: data.production_branch_web_console ?? false,
|
|
297
|
+
deletionProtection: data.deletion_protected ?? false,
|
|
298
|
+
automaticMigrations: data.automatic_migrations ?? false,
|
|
299
|
+
migrationFramework: data.migration_framework ?? undefined,
|
|
300
|
+
migrationTableName: data.migration_table_name ?? undefined,
|
|
301
|
+
allowDataBranching: data.allow_data_branching ?? false,
|
|
302
|
+
allowForeignKeyConstraints: data.foreign_keys_enabled ?? false,
|
|
303
|
+
};
|
|
304
|
+
}),
|
|
305
|
+
),
|
|
306
|
+
Effect.catchTag("NotFound", () => Effect.undefined),
|
|
307
|
+
);
|
|
308
|
+
}),
|
|
309
|
+
|
|
310
|
+
reconcile: Effect.fn(function* ({ id, news, output, session }) {
|
|
311
|
+
const { organization } = yield* yield* Credentials;
|
|
312
|
+
const newName = yield* createDatabaseName(id, news.name);
|
|
313
|
+
const clusterSize = news.clusterSize;
|
|
314
|
+
|
|
315
|
+
// Observe — read live state under either the cached name (rename
|
|
316
|
+
// / refresh) or the freshly-derived name (greenfield). Observing
|
|
317
|
+
// unconditionally absorbs the race between `read` and `reconcile`
|
|
318
|
+
// — if a foreign actor (or a previously-failed-to-persist
|
|
319
|
+
// create) produced a database under the desired name in the
|
|
320
|
+
// meantime, we'll find it here and skip the duplicate create.
|
|
321
|
+
// Adoption routing has already been gated by `read` returning
|
|
322
|
+
// `Unowned`, so by the time we reach reconcile any observed
|
|
323
|
+
// database is fair game.
|
|
324
|
+
const observedName = output?.name ?? newName;
|
|
325
|
+
let observed = yield* planetscale
|
|
326
|
+
.getDatabase({
|
|
327
|
+
organization,
|
|
328
|
+
database: observedName,
|
|
329
|
+
})
|
|
330
|
+
.pipe(Effect.catchTag("NotFound", () => Effect.undefined));
|
|
331
|
+
|
|
332
|
+
// Ensure — if missing, create.
|
|
333
|
+
if (!observed) {
|
|
334
|
+
yield* session.note("Creating database...");
|
|
335
|
+
// Note: the API rejects the `replicas` param for mysql databases;
|
|
336
|
+
// replicas are converged below via the keyspace resize lifecycle.
|
|
337
|
+
observed = yield* planetscale.createDatabase({
|
|
338
|
+
organization,
|
|
339
|
+
name: newName,
|
|
340
|
+
region: news.region?.slug,
|
|
341
|
+
kind: "mysql",
|
|
342
|
+
cluster_size: clusterSize,
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// Wait for the database to finish provisioning before any
|
|
347
|
+
// downstream sync runs. PlanetScale reports `state: "pending"`
|
|
348
|
+
// for a while after createDatabase returns, during which branch
|
|
349
|
+
// operations on this database (including our own settings PATCH)
|
|
350
|
+
// race against the provisioning fiber.
|
|
351
|
+
yield* waitForDatabaseReady(organization, observed.name, session);
|
|
352
|
+
|
|
353
|
+
if (observed.kind !== "mysql") {
|
|
354
|
+
return yield* new PlanetscaleConflict({
|
|
355
|
+
message:
|
|
356
|
+
`Planetscale database "${observed.name}" has kind "${observed.kind}" but this resource ` +
|
|
357
|
+
`is a MySQLDatabase. Use Planetscale.${observed.kind === "postgresql" ? "PostgresDatabase" : observed.kind}() instead, ` +
|
|
358
|
+
`or delete the existing database and retry.`,
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// Sync — ensure a non-`main` default branch exists before
|
|
363
|
+
// referencing it via `default_branch`. The branch is created
|
|
364
|
+
// empty (no cluster size yet); we'll size it below.
|
|
365
|
+
if (news.defaultBranch && news.defaultBranch !== "main") {
|
|
366
|
+
yield* waitForDatabaseReady(organization, observed.name, session);
|
|
367
|
+
const branchExists = yield* planetscale
|
|
368
|
+
.getBranch({
|
|
369
|
+
organization,
|
|
370
|
+
database: observed.name,
|
|
371
|
+
branch: news.defaultBranch,
|
|
372
|
+
})
|
|
373
|
+
.pipe(
|
|
374
|
+
Effect.map(() => true),
|
|
375
|
+
Effect.catchTag("NotFound", () => Effect.succeed(false)),
|
|
376
|
+
);
|
|
377
|
+
if (!branchExists) {
|
|
378
|
+
yield* planetscale.createBranch({
|
|
379
|
+
organization,
|
|
380
|
+
database: observed.name,
|
|
381
|
+
name: news.defaultBranch,
|
|
382
|
+
parent_branch: "main",
|
|
383
|
+
// create branch with cluster size to skip resizing when promoting the branch
|
|
384
|
+
cluster_size: clusterSize,
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// Sync settings — `updateSettings` is upsert-shaped, so we can
|
|
390
|
+
// call it with the full desired payload on every reconcile.
|
|
391
|
+
const updated = yield* planetscale.updateDatabaseSettings({
|
|
392
|
+
organization,
|
|
393
|
+
database: observed.name,
|
|
394
|
+
new_name: newName !== observed.name ? newName : undefined,
|
|
395
|
+
automatic_migrations: news.automaticMigrations,
|
|
396
|
+
migration_framework: news.migrationFramework,
|
|
397
|
+
migration_table_name: news.migrationTableName,
|
|
398
|
+
allow_foreign_key_constraints: news.allowForeignKeyConstraints,
|
|
399
|
+
allow_data_branching: news.allowDataBranching,
|
|
400
|
+
require_approval_for_deploy: news.requireApprovalForDeploy,
|
|
401
|
+
restrict_branch_region: news.restrictBranchRegion,
|
|
402
|
+
insights_raw_queries: news.insightsRawQueries,
|
|
403
|
+
production_branch_web_console: news.productionBranchWebConsole,
|
|
404
|
+
deletion_protected: news.deletionProtection,
|
|
405
|
+
default_branch: news.defaultBranch,
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
// Sync cluster size and replicas on the active default branch —
|
|
409
|
+
// both converge in place via PlanetScale's keyspace resize
|
|
410
|
+
// lifecycle, diffed against the observed default keyspace.
|
|
411
|
+
const branch = news.defaultBranch ?? updated.default_branch ?? "main";
|
|
412
|
+
const keyspace = yield* ensureMySQLProductionBranchClusterSize(
|
|
413
|
+
organization,
|
|
414
|
+
updated.name,
|
|
415
|
+
branch,
|
|
416
|
+
news.clusterSize,
|
|
417
|
+
news.replicas,
|
|
418
|
+
);
|
|
419
|
+
|
|
420
|
+
const migrationTarget = {
|
|
421
|
+
organization,
|
|
422
|
+
database: updated.name,
|
|
423
|
+
branch,
|
|
424
|
+
};
|
|
425
|
+
const migrationsInput = migrationsInputOf(news);
|
|
426
|
+
if (migrationsInput || news.importFiles?.length) {
|
|
427
|
+
yield* waitForBranchReady(organization, updated.name, branch, session);
|
|
428
|
+
}
|
|
429
|
+
const migrations = migrationsInput
|
|
430
|
+
? yield* runMySQLMigrations(migrationTarget, migrationsInput, stampedOf(output))
|
|
431
|
+
: undefined;
|
|
432
|
+
const importHashes = news.importFiles?.length
|
|
433
|
+
? yield* runMySQLImports(
|
|
434
|
+
migrationTarget,
|
|
435
|
+
news.importFiles,
|
|
436
|
+
yield* rootDir,
|
|
437
|
+
output?.importHashes ?? {},
|
|
438
|
+
)
|
|
439
|
+
: {};
|
|
440
|
+
|
|
441
|
+
return {
|
|
442
|
+
id: updated.id,
|
|
443
|
+
name: updated.name,
|
|
444
|
+
organization,
|
|
445
|
+
state: updated.state,
|
|
446
|
+
defaultBranch: updated.default_branch ?? branch,
|
|
447
|
+
plan: updated.plan ?? output?.plan ?? "hobby",
|
|
448
|
+
createdAt: updated.created_at,
|
|
449
|
+
updatedAt: updated.updated_at,
|
|
450
|
+
htmlUrl: updated.html_url,
|
|
451
|
+
region: { slug: updated.region.slug },
|
|
452
|
+
clusterSize,
|
|
453
|
+
replicas: keyspace.replicas,
|
|
454
|
+
...migrationsAttrs({ input: migrationsInput, run: migrations, output }),
|
|
455
|
+
importHashes,
|
|
456
|
+
requireApprovalForDeploy: updated.require_approval_for_deploy ?? false,
|
|
457
|
+
restrictBranchRegion: updated.restrict_branch_region ?? false,
|
|
458
|
+
insightsRawQueries: updated.insights_raw_queries ?? false,
|
|
459
|
+
productionBranchWebConsole: updated.production_branch_web_console ?? false,
|
|
460
|
+
deletionProtection: updated.deletion_protected ?? false,
|
|
461
|
+
automaticMigrations: updated.automatic_migrations ?? false,
|
|
462
|
+
migrationFramework: updated.migration_framework ?? undefined,
|
|
463
|
+
migrationTableName: updated.migration_table_name ?? undefined,
|
|
464
|
+
allowDataBranching: updated.allow_data_branching ?? false,
|
|
465
|
+
allowForeignKeyConstraints: updated.foreign_keys_enabled ?? false,
|
|
466
|
+
} satisfies MySQLDatabaseAttributes;
|
|
467
|
+
}),
|
|
468
|
+
|
|
469
|
+
delete: Effect.fn(function* ({ output }) {
|
|
470
|
+
yield* deleteUnprotectedDatabase(output.organization, output.name);
|
|
471
|
+
}),
|
|
472
|
+
|
|
473
|
+
list: Effect.fn(function* () {
|
|
474
|
+
const { organization } = yield* yield* Credentials;
|
|
475
|
+
|
|
476
|
+
return yield* planetscale.listDatabases.pages({ organization }).pipe(
|
|
477
|
+
Stream.runCollect,
|
|
478
|
+
Effect.map((chunk) =>
|
|
479
|
+
Array.from(chunk).flatMap((page) =>
|
|
480
|
+
page.data
|
|
481
|
+
.filter((db) => db.kind === "mysql")
|
|
482
|
+
.map((data): MySQLDatabase["Attributes"] => ({
|
|
483
|
+
id: data.id,
|
|
484
|
+
name: data.name,
|
|
485
|
+
organization,
|
|
486
|
+
state: data.state,
|
|
487
|
+
defaultBranch: data.default_branch ?? "main",
|
|
488
|
+
plan: data.plan ?? "hobby",
|
|
489
|
+
createdAt: data.created_at,
|
|
490
|
+
updatedAt: data.updated_at,
|
|
491
|
+
htmlUrl: data.html_url,
|
|
492
|
+
region: { slug: data.region.slug },
|
|
493
|
+
migrationsDir: undefined,
|
|
494
|
+
migrationsTable: undefined,
|
|
495
|
+
migrationsHashes: {},
|
|
496
|
+
importHashes: {},
|
|
497
|
+
clusterSize: "",
|
|
498
|
+
replicas: undefined,
|
|
499
|
+
requireApprovalForDeploy: data.require_approval_for_deploy ?? false,
|
|
500
|
+
restrictBranchRegion: data.restrict_branch_region ?? false,
|
|
501
|
+
insightsRawQueries: data.insights_raw_queries ?? false,
|
|
502
|
+
productionBranchWebConsole: data.production_branch_web_console ?? false,
|
|
503
|
+
deletionProtection: data.deletion_protected ?? false,
|
|
504
|
+
automaticMigrations: data.automatic_migrations ?? false,
|
|
505
|
+
migrationFramework: data.migration_framework ?? undefined,
|
|
506
|
+
migrationTableName: data.migration_table_name ?? undefined,
|
|
507
|
+
allowDataBranching: data.allow_data_branching ?? false,
|
|
508
|
+
allowForeignKeyConstraints: data.foreign_keys_enabled ?? false,
|
|
509
|
+
})),
|
|
510
|
+
),
|
|
511
|
+
),
|
|
512
|
+
);
|
|
513
|
+
}),
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
const createDatabaseName = (id: string, name: string | undefined) =>
|
|
517
|
+
Effect.gen(function* () {
|
|
518
|
+
return name ?? (yield* createPhysicalName({ id, lowercase: true, maxLength: 63 }));
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
const rootDir = Effect.sync(() => process.cwd());
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import {
|
|
2
|
+
runMigrations,
|
|
3
|
+
withMySqlExecutor,
|
|
4
|
+
type NormalizedMigrationsInput,
|
|
5
|
+
type SqlExecutor,
|
|
6
|
+
type StampedMigrationsState,
|
|
7
|
+
} from "@alchemy.run/sql/Migrations";
|
|
8
|
+
import { applySqlImports, splitSqlStatements } from "@alchemy.run/sql/SqlFile";
|
|
9
|
+
import * as ps from "@distilled.cloud/planetscale";
|
|
10
|
+
import * as Effect from "effect/Effect";
|
|
11
|
+
import * as Redacted from "effect/Redacted";
|
|
12
|
+
import * as Schedule from "effect/Schedule";
|
|
13
|
+
|
|
14
|
+
const MIGRATION_PASSWORD_TTL_SECONDS = 600;
|
|
15
|
+
|
|
16
|
+
export interface MySQLMigrationTarget {
|
|
17
|
+
organization: string;
|
|
18
|
+
database: string;
|
|
19
|
+
branch: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* PlanetScale MySQL's migration adaptation is exactly this: the shared
|
|
24
|
+
* pipeline with a temp-password-scoped mysql2 connection as its executor.
|
|
25
|
+
*/
|
|
26
|
+
export const runMySQLMigrations = (
|
|
27
|
+
target: MySQLMigrationTarget,
|
|
28
|
+
input: NormalizedMigrationsInput,
|
|
29
|
+
stamped: StampedMigrationsState,
|
|
30
|
+
) =>
|
|
31
|
+
runMigrations({
|
|
32
|
+
input,
|
|
33
|
+
stamped,
|
|
34
|
+
withExecutor: (apply) => withMySQLExecutor(target, apply),
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Apply `importFiles`, each once and again only when it changes. Vitess
|
|
39
|
+
* rejects drizzle-kit's `--> statement-breakpoint` markers, so statements
|
|
40
|
+
* run one at a time.
|
|
41
|
+
*/
|
|
42
|
+
export const runMySQLImports = (
|
|
43
|
+
target: MySQLMigrationTarget,
|
|
44
|
+
importFiles: ReadonlyArray<string>,
|
|
45
|
+
rootDir: string,
|
|
46
|
+
previous: Record<string, string>,
|
|
47
|
+
) =>
|
|
48
|
+
applySqlImports({
|
|
49
|
+
importFiles,
|
|
50
|
+
rootDir,
|
|
51
|
+
previous,
|
|
52
|
+
apply: (file) =>
|
|
53
|
+
withMySQLExecutor(target, (executor) =>
|
|
54
|
+
Effect.forEach(splitSqlStatements(file.sql), (statement) => executor.query(statement), {
|
|
55
|
+
discard: true,
|
|
56
|
+
}),
|
|
57
|
+
),
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
/** A verified-TLS `mysql2` session as a short-lived admin password. */
|
|
61
|
+
const withMySQLExecutor = <A, E, R>(
|
|
62
|
+
target: MySQLMigrationTarget,
|
|
63
|
+
use: (executor: SqlExecutor) => Effect.Effect<A, E, R>,
|
|
64
|
+
) =>
|
|
65
|
+
withTemporaryMySQLPassword(target, (password) =>
|
|
66
|
+
withMySqlExecutor(
|
|
67
|
+
{
|
|
68
|
+
host: password.host,
|
|
69
|
+
user: password.username,
|
|
70
|
+
password: Redacted.value(password.password),
|
|
71
|
+
database: target.database,
|
|
72
|
+
multipleStatements: true,
|
|
73
|
+
ssl: { rejectUnauthorized: true },
|
|
74
|
+
},
|
|
75
|
+
use,
|
|
76
|
+
),
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
const withTemporaryMySQLPassword = <A, E, R>(
|
|
80
|
+
target: MySQLMigrationTarget,
|
|
81
|
+
use: (password: {
|
|
82
|
+
id: string;
|
|
83
|
+
host: string;
|
|
84
|
+
username: string;
|
|
85
|
+
password: Redacted.Redacted<string>;
|
|
86
|
+
}) => Effect.Effect<A, E, R>,
|
|
87
|
+
) =>
|
|
88
|
+
Effect.acquireUseRelease(
|
|
89
|
+
Effect.gen(function* () {
|
|
90
|
+
const created = yield* ps.createPassword({
|
|
91
|
+
organization: target.organization,
|
|
92
|
+
database: target.database,
|
|
93
|
+
branch: target.branch,
|
|
94
|
+
role: "admin",
|
|
95
|
+
ttl: MIGRATION_PASSWORD_TTL_SECONDS,
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
id: created.id,
|
|
100
|
+
host: created.access_host_url,
|
|
101
|
+
username: created.username,
|
|
102
|
+
password: created.plain_text,
|
|
103
|
+
};
|
|
104
|
+
}),
|
|
105
|
+
use,
|
|
106
|
+
(password) =>
|
|
107
|
+
ps
|
|
108
|
+
.deletePassword({
|
|
109
|
+
organization: target.organization,
|
|
110
|
+
database: target.database,
|
|
111
|
+
branch: target.branch,
|
|
112
|
+
id: password.id,
|
|
113
|
+
})
|
|
114
|
+
.pipe(
|
|
115
|
+
// Already-deleted passwords are a success: nothing to clean up.
|
|
116
|
+
Effect.catchTag("NotFound", () => Effect.void),
|
|
117
|
+
Effect.retry({
|
|
118
|
+
schedule: Schedule.max([Schedule.exponential("500 millis"), Schedule.recurs(5)]),
|
|
119
|
+
}),
|
|
120
|
+
// Migrations succeeded; don't fail the parent over a release-step
|
|
121
|
+
// hiccup. The password's TTL bounds the orphan window; log loudly
|
|
122
|
+
// so an operator can clean it up manually if needed.
|
|
123
|
+
Effect.catch((cause: unknown) =>
|
|
124
|
+
Effect.logWarning(
|
|
125
|
+
`Failed to delete temporary Planetscale migration password after retries. ` +
|
|
126
|
+
`It will expire via TTL (~${MIGRATION_PASSWORD_TTL_SECONDS}s). ` +
|
|
127
|
+
`organization=${target.organization} database=${target.database} ` +
|
|
128
|
+
`branch=${target.branch} id=${password.id}`,
|
|
129
|
+
cause,
|
|
130
|
+
),
|
|
131
|
+
),
|
|
132
|
+
),
|
|
133
|
+
);
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import * as Redacted from "effect/Redacted";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The shape `Cloudflare.Hyperdrive` and other MySQL consumers accept as an
|
|
5
|
+
* origin. Materialized on `Planetscale.MySQLPassword` so callers can wire
|
|
6
|
+
* password credentials into Hyperdrive directly.
|
|
7
|
+
*/
|
|
8
|
+
export type MySQLOrigin = {
|
|
9
|
+
scheme: "mysql";
|
|
10
|
+
host: string;
|
|
11
|
+
port: number;
|
|
12
|
+
database: string;
|
|
13
|
+
user: string;
|
|
14
|
+
password: Redacted.Redacted<string>;
|
|
15
|
+
};
|