@alchemy.run/planetscale 0.0.0-stage → 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 +87 -2
- package/lib/AuthProvider.d.ts +64 -0
- package/lib/AuthProvider.d.ts.map +1 -0
- package/lib/AuthProvider.js +384 -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 +65 -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 +24 -0
- package/lib/MySQL/MySQLMigrations.d.ts.map +1 -0
- package/lib/MySQL/MySQLMigrations.js +106 -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 +24 -0
- package/lib/Postgres/PostgresMigrations.d.ts.map +1 -0
- package/lib/Postgres/PostgresMigrations.js +116 -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 +94 -3
- package/src/AuthProvider.ts +571 -0
- package/src/Branch.ts +657 -0
- package/src/Contributions.ts +9 -0
- package/src/Credentials.ts +116 -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 +182 -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 +193 -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
package/src/Branch.ts
ADDED
|
@@ -0,0 +1,657 @@
|
|
|
1
|
+
import {
|
|
2
|
+
classifyMigrationHistory,
|
|
3
|
+
describeRewrittenHistory,
|
|
4
|
+
migrationsAttrs,
|
|
5
|
+
migrationsInputOf,
|
|
6
|
+
RewrittenMigrationHistoryError,
|
|
7
|
+
stampedOf,
|
|
8
|
+
type MigrationRun,
|
|
9
|
+
type MigrationsInput,
|
|
10
|
+
type NormalizedMigrationsInput,
|
|
11
|
+
type StampedMigrationsState,
|
|
12
|
+
} from "@alchemy.run/sql/Migrations";
|
|
13
|
+
import { hashImports } from "@alchemy.run/sql/SqlFile";
|
|
14
|
+
import * as planetscale from "@distilled.cloud/planetscale";
|
|
15
|
+
import { Credentials } from "@distilled.cloud/planetscale/Credentials";
|
|
16
|
+
import { Unowned } from "alchemy/AdoptPolicy";
|
|
17
|
+
import { havePropsChanged, isResolved } from "alchemy/Diff";
|
|
18
|
+
import { createPhysicalName } from "alchemy/PhysicalName";
|
|
19
|
+
import * as Provider from "alchemy/Provider";
|
|
20
|
+
import type { ResourceClass, ResourceLike } from "alchemy/Resource";
|
|
21
|
+
import { recordsEqual } from "alchemy/Util/equal";
|
|
22
|
+
import * as Effect from "effect/Effect";
|
|
23
|
+
import * as Schedule from "effect/Schedule";
|
|
24
|
+
import * as Stream from "effect/Stream";
|
|
25
|
+
import { ensureMySQLProductionBranchClusterSize } from "./MySQL/MySQLClusterSize.ts";
|
|
26
|
+
import {
|
|
27
|
+
ensurePostgresProductionBranchClusterSize,
|
|
28
|
+
toPostgresClusterArch,
|
|
29
|
+
toPostgresClusterSku,
|
|
30
|
+
waitForPendingPostgresChanges,
|
|
31
|
+
} from "./Postgres/PostgresClusterSize.ts";
|
|
32
|
+
import { PlanetscaleConflict, isKnownError, waitForBranchReady } from "./Util.ts";
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Props shared between {@link MySQLBranch} and {@link PostgresBranch}. The
|
|
36
|
+
* `database` and `parentBranch` fields are typed by each engine's resource.
|
|
37
|
+
*/
|
|
38
|
+
export interface BaseBranchProps {
|
|
39
|
+
/**
|
|
40
|
+
* Branch name. If omitted, a name is generated from the stack/stage/id.
|
|
41
|
+
*/
|
|
42
|
+
name?: string;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* If provided, restores the backup's schema and data to the new branch.
|
|
46
|
+
* Ignored if the branch already exists.
|
|
47
|
+
*/
|
|
48
|
+
backupId?: string;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* If provided, restores the last successful backup's schema and data.
|
|
52
|
+
* Ignored if the branch already exists.
|
|
53
|
+
*/
|
|
54
|
+
seedData?: "last_successful_backup";
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Whether safe migrations are enabled on the branch.
|
|
58
|
+
*/
|
|
59
|
+
safeMigrations?: boolean;
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Region in which to create the branch. Defaults to the database's
|
|
63
|
+
* region. The actual region is validated on adopt/update.
|
|
64
|
+
*/
|
|
65
|
+
region?: { slug: string };
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* SQL migrations to apply against this branch. Accepts a directory path,
|
|
69
|
+
* a `Drizzle.Schema` resource, or `{ dir, table? }`. Bookkeeping lives
|
|
70
|
+
* in Alchemy's `__alchemy_migrations` table; drizzle/prisma history is
|
|
71
|
+
* converted one-way on first deploy.
|
|
72
|
+
*
|
|
73
|
+
* Adding a file is an in-place update. Editing or removing an
|
|
74
|
+
* already-applied file replaces a non-production branch (re-forked from
|
|
75
|
+
* its parent) and fails on a current or desired production branch — add
|
|
76
|
+
* a forward migration instead.
|
|
77
|
+
*/
|
|
78
|
+
migrations?: MigrationsInput;
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Paths to additional `.sql` files to apply after migrations. Each file is
|
|
82
|
+
* hashed; only files whose contents change are re-applied on later deploys.
|
|
83
|
+
*/
|
|
84
|
+
importFiles?: string[];
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Attributes shared between {@link MySQLBranch} and {@link PostgresBranch}.
|
|
89
|
+
*/
|
|
90
|
+
export interface BaseBranchAttributes {
|
|
91
|
+
/** The branch name. */
|
|
92
|
+
name: string;
|
|
93
|
+
/** The PlanetScale organization slug. */
|
|
94
|
+
organization: string;
|
|
95
|
+
/** The database name. */
|
|
96
|
+
database: string;
|
|
97
|
+
/** The parent branch name. */
|
|
98
|
+
parentBranch: string;
|
|
99
|
+
/** Whether this is a production branch. */
|
|
100
|
+
production: boolean;
|
|
101
|
+
/** Time at which the branch was created (ISO 8601). */
|
|
102
|
+
createdAt: string;
|
|
103
|
+
/**
|
|
104
|
+
* Time at which the branch was last updated (ISO 8601), as observed by the
|
|
105
|
+
* last deploy. PlanetScale bumps this timestamp on its own, so it is not
|
|
106
|
+
* refreshed by drift detection.
|
|
107
|
+
*/
|
|
108
|
+
updatedAt: string;
|
|
109
|
+
/** HTML URL for accessing the branch in the dashboard. */
|
|
110
|
+
htmlUrl: string;
|
|
111
|
+
/** The region of the branch as reported by PlanetScale. */
|
|
112
|
+
region: { slug: string };
|
|
113
|
+
/** Directory containing migration files, if configured. */
|
|
114
|
+
migrationsDir: string | undefined;
|
|
115
|
+
/** Table used to track applied migrations, if configured. */
|
|
116
|
+
migrationsTable: string | undefined;
|
|
117
|
+
/** Content hashes for the last applied migration files. */
|
|
118
|
+
migrationsHashes: Record<string, string>;
|
|
119
|
+
/** Content hashes for the last applied import files. */
|
|
120
|
+
importHashes: Record<string, string>;
|
|
121
|
+
/**
|
|
122
|
+
* Desired total replica count requested by the resource input, when known.
|
|
123
|
+
* PlanetScale's branch read API exposes only boolean HA state, so this is
|
|
124
|
+
* persisted from Alchemy state rather than observed from live provider data.
|
|
125
|
+
*/
|
|
126
|
+
desiredReplicas: number | undefined;
|
|
127
|
+
/** Whether the branch currently has HA replicas. */
|
|
128
|
+
hasReplicas: boolean | undefined;
|
|
129
|
+
/** Whether the branch currently has read-only replicas. */
|
|
130
|
+
hasReadOnlyReplicas: boolean | undefined;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Runtime-resolved Database/Branch references. `news.database` can be a
|
|
134
|
+
// plain string OR (after resolution) the Database resource's attributes —
|
|
135
|
+
// which contain plain values for `name` / `organization`. The static
|
|
136
|
+
// Database type has those fields as `Output<...>`, so we cast through a
|
|
137
|
+
// structural shape here.
|
|
138
|
+
type DatabaseRef = string | { name: string; organization?: string };
|
|
139
|
+
type BranchRef = string | { name: string };
|
|
140
|
+
|
|
141
|
+
const resolveDatabase = (database: unknown): { name: string; organization?: string } => {
|
|
142
|
+
const ref = database as DatabaseRef | undefined;
|
|
143
|
+
if (!ref) return { name: "" };
|
|
144
|
+
return typeof ref === "string"
|
|
145
|
+
? { name: ref }
|
|
146
|
+
: { name: ref.name, organization: ref.organization };
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
const resolveParent = (parent: unknown): string => {
|
|
150
|
+
const ref = parent as BranchRef | undefined;
|
|
151
|
+
return !ref ? "main" : typeof ref === "string" ? ref : ref.name;
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
const createBranchName = (id: string, name: string | undefined) =>
|
|
155
|
+
Effect.gen(function* () {
|
|
156
|
+
return name ?? (yield* createPhysicalName({ id, lowercase: true, maxLength: 63 }));
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Shape of the engine-specific migration runners used by
|
|
161
|
+
* {@link makeBranchProvider}. MySQL and Postgres each supply their own
|
|
162
|
+
* implementations in their respective `*Migrations.ts` modules.
|
|
163
|
+
*/
|
|
164
|
+
export interface BranchMigrationRunners {
|
|
165
|
+
runMigrations: (
|
|
166
|
+
target: { organization: string; database: string; branch: string },
|
|
167
|
+
input: NormalizedMigrationsInput,
|
|
168
|
+
stamped: StampedMigrationsState,
|
|
169
|
+
) => Effect.Effect<MigrationRun, any, any>;
|
|
170
|
+
runImports: (
|
|
171
|
+
target: { organization: string; database: string; branch: string },
|
|
172
|
+
importFiles: string[],
|
|
173
|
+
rootDir: string,
|
|
174
|
+
previousHashes: Record<string, string>,
|
|
175
|
+
) => Effect.Effect<Record<string, string>, any, any>;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const rootDir = Effect.sync(() => process.cwd());
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Matches the UnprocessableEntity errors PlanetScale returns when a
|
|
182
|
+
* branch promotion/demotion races an in-flight cluster resize, e.g.
|
|
183
|
+
* "Branches with a cluster resize in progress cannot be demoted."
|
|
184
|
+
*/
|
|
185
|
+
const isResizeInProgress = (error: unknown): boolean =>
|
|
186
|
+
typeof error === "object" &&
|
|
187
|
+
error !== null &&
|
|
188
|
+
(error as { readonly _tag?: unknown })._tag === "UnprocessableEntity" &&
|
|
189
|
+
typeof (error as { readonly message?: unknown }).message === "string" &&
|
|
190
|
+
(error as { readonly message: string }).message.includes("cluster resize in progress");
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Build a branch provider for a specific PlanetScale engine. The
|
|
194
|
+
* caller supplies the typed `Resource` token and the engine's migration
|
|
195
|
+
* runners; everything else (observe / ensure / sync / delete) is shared.
|
|
196
|
+
*/
|
|
197
|
+
export const makeBranchProvider = <R extends ResourceLike>(opts: {
|
|
198
|
+
resource: ResourceClass<R>;
|
|
199
|
+
expectedKind: "mysql" | "postgresql";
|
|
200
|
+
engineLabel: string;
|
|
201
|
+
runners: BranchMigrationRunners;
|
|
202
|
+
}) =>
|
|
203
|
+
Provider.succeed(opts.resource, {
|
|
204
|
+
stables: ["organization", "database"],
|
|
205
|
+
|
|
206
|
+
// PARENT FAN-OUT: PlanetScale branches are nested under a database within
|
|
207
|
+
// an organization, so there is no flat per-org branch enumeration API.
|
|
208
|
+
// Enumerate every database in the credentialed org, then list each
|
|
209
|
+
// database's branches concurrently (bounded), exhaustively paginating
|
|
210
|
+
// both levels, and keep only the branches whose engine kind matches this
|
|
211
|
+
// resource. Each item is hydrated into the exact `read` Attributes shape.
|
|
212
|
+
list: Effect.fn(function* () {
|
|
213
|
+
const { organization } = yield* yield* Credentials;
|
|
214
|
+
|
|
215
|
+
const databases = yield* planetscale.listDatabases.pages({ organization }).pipe(
|
|
216
|
+
Stream.runCollect,
|
|
217
|
+
Effect.map((chunk) => Array.from(chunk).flatMap((page) => page.data)),
|
|
218
|
+
);
|
|
219
|
+
|
|
220
|
+
const rows = yield* Effect.forEach(
|
|
221
|
+
databases,
|
|
222
|
+
(db) =>
|
|
223
|
+
planetscale.listBranches.pages({ organization, database: db.name }).pipe(
|
|
224
|
+
Stream.runCollect,
|
|
225
|
+
Effect.map((chunk) =>
|
|
226
|
+
Array.from(chunk).flatMap((page) =>
|
|
227
|
+
page.data
|
|
228
|
+
.filter((branch) => branch.kind === opts.expectedKind)
|
|
229
|
+
.map(
|
|
230
|
+
(branch) =>
|
|
231
|
+
({
|
|
232
|
+
name: branch.name,
|
|
233
|
+
organization,
|
|
234
|
+
database: db.name,
|
|
235
|
+
parentBranch: branch.parent_branch ?? "main",
|
|
236
|
+
production: branch.production,
|
|
237
|
+
createdAt: branch.created_at,
|
|
238
|
+
updatedAt: branch.updated_at,
|
|
239
|
+
htmlUrl: branch.html_url,
|
|
240
|
+
region: { slug: branch.region.slug },
|
|
241
|
+
migrationsDir: undefined,
|
|
242
|
+
migrationsTable: undefined,
|
|
243
|
+
migrationsHashes: {},
|
|
244
|
+
importHashes: {},
|
|
245
|
+
desiredReplicas: undefined,
|
|
246
|
+
hasReplicas: branch.has_replicas,
|
|
247
|
+
hasReadOnlyReplicas: branch.has_read_only_replicas,
|
|
248
|
+
}) satisfies BaseBranchAttributes,
|
|
249
|
+
),
|
|
250
|
+
),
|
|
251
|
+
),
|
|
252
|
+
// A database can be deleted between enumeration and the
|
|
253
|
+
// per-database branch list — skip it rather than fail.
|
|
254
|
+
Effect.catchTag("NotFound", () => Effect.succeed([] as BaseBranchAttributes[])),
|
|
255
|
+
),
|
|
256
|
+
{ concurrency: 10 },
|
|
257
|
+
);
|
|
258
|
+
|
|
259
|
+
return rows.flat();
|
|
260
|
+
}),
|
|
261
|
+
|
|
262
|
+
diff: Effect.fn(function* ({ news, olds, output }: any) {
|
|
263
|
+
if (!isResolved(news)) return undefined;
|
|
264
|
+
|
|
265
|
+
// Branch names are rename-mutable (reconcile syncs `news.name` in
|
|
266
|
+
// place), so `name` cannot live in the provider-level stables. But
|
|
267
|
+
// almost no update is a rename — for those, advertise `name` as
|
|
268
|
+
// stable on the update so downstream consumers referencing this
|
|
269
|
+
// branch still resolve `branch.name` at plan time. Without it, a
|
|
270
|
+
// metadata-only update (e.g. an embedded database ref whose
|
|
271
|
+
// `migrationsHashes` moved) resolves consumer refs to just
|
|
272
|
+
// `{ organization, database }`, and identity-sensitive consumers
|
|
273
|
+
// (PostgresRole) falsely plan a replacement against `name: undefined`.
|
|
274
|
+
//
|
|
275
|
+
// The name only changes when the `name` prop itself changes: an
|
|
276
|
+
// explicit name renames iff it differs from the observed name, and
|
|
277
|
+
// an omitted name is engine-generated deterministically (stable
|
|
278
|
+
// across updates — the instance id only rotates on replacement).
|
|
279
|
+
const nameIsStable =
|
|
280
|
+
output?.name !== undefined &&
|
|
281
|
+
(news.name !== undefined ? news.name === output.name : olds?.name === undefined);
|
|
282
|
+
const stables = nameIsStable ? ["organization", "database", "name"] : undefined;
|
|
283
|
+
|
|
284
|
+
const newDb = resolveDatabase(news.database).name;
|
|
285
|
+
const oldDbRef = output?.database ?? olds.database;
|
|
286
|
+
if (oldDbRef) {
|
|
287
|
+
const oldDb = resolveDatabase(oldDbRef).name;
|
|
288
|
+
if (newDb !== oldDb) {
|
|
289
|
+
return { action: "replace" } as const;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const newParent = resolveParent(news.parentBranch);
|
|
294
|
+
const oldParent = output?.parentBranch ?? resolveParent(olds.parentBranch);
|
|
295
|
+
if (newParent !== oldParent) {
|
|
296
|
+
return { action: "replace" } as const;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
if (news.region?.slug && output?.region?.slug && news.region.slug !== output.region.slug) {
|
|
300
|
+
return { action: "replace" } as const;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
if (news.replicas !== undefined) {
|
|
304
|
+
if (output?.desiredReplicas !== news.replicas) {
|
|
305
|
+
return { action: "update", stables } as const;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const desiredHasReplicas = news.replicas > 0;
|
|
309
|
+
if (output?.hasReplicas !== undefined && output.hasReplicas !== desiredHasReplicas) {
|
|
310
|
+
return { action: "update", stables } as const;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const migrationChange = yield* classifyMigrationHistory({
|
|
315
|
+
news,
|
|
316
|
+
output,
|
|
317
|
+
});
|
|
318
|
+
if (migrationChange.kind === "rewritten") {
|
|
319
|
+
// Editing or deleting an already-applied file cannot be replayed
|
|
320
|
+
// in place (apply is name-keyed). Development branches re-fork
|
|
321
|
+
// from the parent and apply from scratch; production must add a
|
|
322
|
+
// forward migration instead.
|
|
323
|
+
const production = output?.production === true || news.isProduction === true;
|
|
324
|
+
if (production) {
|
|
325
|
+
return yield* new RewrittenMigrationHistoryError({
|
|
326
|
+
changed: migrationChange.changed,
|
|
327
|
+
removed: migrationChange.removed,
|
|
328
|
+
message:
|
|
329
|
+
`Cannot rewrite applied migration history on production PlanetScale branch ` +
|
|
330
|
+
`"${output?.name ?? news.name ?? "unknown"}" ` +
|
|
331
|
+
`(${describeRewrittenHistory(migrationChange)}). ` +
|
|
332
|
+
"Add a new forward migration instead of editing or deleting already-applied files.",
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
return { action: "replace" } as const;
|
|
336
|
+
}
|
|
337
|
+
if (migrationChange.kind === "pending") {
|
|
338
|
+
return { action: "update", stables } as const;
|
|
339
|
+
}
|
|
340
|
+
if (news.importFiles?.length) {
|
|
341
|
+
const newHashes = yield* hashImports(news.importFiles, yield* rootDir);
|
|
342
|
+
if (!recordsEqual(newHashes, output?.importHashes ?? {})) {
|
|
343
|
+
return { action: "update", stables } as const;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// Remaining prop changes (rename, safeMigrations, clusterSize,
|
|
348
|
+
// metadata embedded in resource refs) are all in-place updates.
|
|
349
|
+
// Decide them here instead of falling back to the engine's default
|
|
350
|
+
// deep-compare so the conditional `name` stable above is attached —
|
|
351
|
+
// the default path uses the provider-level stables, which strip
|
|
352
|
+
// `name` from downstream plan resolution.
|
|
353
|
+
if (havePropsChanged(olds, news)) {
|
|
354
|
+
return { action: "update", stables } as const;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// Nothing changed. Still advertise the conditional `name` stable so
|
|
358
|
+
// a `--force` deploy (which upgrades this noop to an update) keeps
|
|
359
|
+
// `name` resolvable downstream instead of falsely replacing
|
|
360
|
+
// consumers such as roles and passwords (#1832).
|
|
361
|
+
return stables ? ({ action: "noop", stables } as const) : undefined;
|
|
362
|
+
}),
|
|
363
|
+
|
|
364
|
+
read: Effect.fn(function* ({ id, olds, output }: any) {
|
|
365
|
+
// If we have neither a cached output nor a usable `olds.database`
|
|
366
|
+
// there's no way to identify which branch to refresh — most often
|
|
367
|
+
// this is destroy of a partially-created resource whose props
|
|
368
|
+
// never got fully persisted. Return `undefined` so the engine
|
|
369
|
+
// treats it as already-gone and drops the state entry.
|
|
370
|
+
if (!output && !olds?.database) {
|
|
371
|
+
return undefined;
|
|
372
|
+
}
|
|
373
|
+
const dbInfo = output
|
|
374
|
+
? { name: output.database, organization: output.organization }
|
|
375
|
+
: resolveDatabase(olds.database);
|
|
376
|
+
const { organization: envOrg } = yield* yield* Credentials;
|
|
377
|
+
const organization = output?.organization ?? dbInfo.organization ?? envOrg;
|
|
378
|
+
const databaseName = output?.database ?? dbInfo.name;
|
|
379
|
+
const branchName = output?.name ?? (yield* createBranchName(id, olds.name));
|
|
380
|
+
|
|
381
|
+
return yield* planetscale
|
|
382
|
+
.getBranch({
|
|
383
|
+
organization,
|
|
384
|
+
database: databaseName,
|
|
385
|
+
branch: branchName,
|
|
386
|
+
})
|
|
387
|
+
.pipe(
|
|
388
|
+
Effect.map((data) => {
|
|
389
|
+
const attrs = {
|
|
390
|
+
name: data.name,
|
|
391
|
+
organization,
|
|
392
|
+
database: databaseName,
|
|
393
|
+
parentBranch: data.parent_branch ?? "main",
|
|
394
|
+
production: data.production,
|
|
395
|
+
createdAt: data.created_at,
|
|
396
|
+
// PlanetScale bumps `updated_at` asynchronously (e.g. seconds
|
|
397
|
+
// after a deploy, on role creation, maintenance) without any
|
|
398
|
+
// config change, so the observed value is volatile. Keep the
|
|
399
|
+
// value recorded by the last reconcile so drift detection only
|
|
400
|
+
// flags real configuration changes (#1955).
|
|
401
|
+
updatedAt: output?.updatedAt ?? data.updated_at,
|
|
402
|
+
htmlUrl: data.html_url,
|
|
403
|
+
region: { slug: data.region.slug },
|
|
404
|
+
migrationsDir: output?.migrationsDir ?? olds?.migrationsDir,
|
|
405
|
+
migrationsTable: output?.migrationsTable ?? olds?.migrationsTable,
|
|
406
|
+
migrationsHashes: output?.migrationsHashes ?? {},
|
|
407
|
+
importHashes: output?.importHashes ?? {},
|
|
408
|
+
desiredReplicas: output?.desiredReplicas ?? olds?.desiredReplicas ?? olds?.replicas,
|
|
409
|
+
hasReplicas: data.has_replicas,
|
|
410
|
+
hasReadOnlyReplicas: data.has_read_only_replicas,
|
|
411
|
+
} satisfies BaseBranchAttributes;
|
|
412
|
+
|
|
413
|
+
return output ? attrs : Unowned(attrs);
|
|
414
|
+
}),
|
|
415
|
+
Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
|
|
416
|
+
);
|
|
417
|
+
}),
|
|
418
|
+
|
|
419
|
+
reconcile: Effect.fn(function* ({ id, news, output, session }: any) {
|
|
420
|
+
const { organization: envOrg } = yield* yield* Credentials;
|
|
421
|
+
const dbInfo = resolveDatabase(news.database);
|
|
422
|
+
const organization = output?.organization ?? dbInfo.organization ?? envOrg;
|
|
423
|
+
const databaseName = output?.database ?? dbInfo.name;
|
|
424
|
+
const desiredBranchName = yield* createBranchName(id, news.name);
|
|
425
|
+
const observedBranchName = output?.name ?? desiredBranchName;
|
|
426
|
+
const parentBranchName = resolveParent(news.parentBranch);
|
|
427
|
+
|
|
428
|
+
// If parentBranch is a plain string (an unmanaged branch reference),
|
|
429
|
+
// wait for it to be ready before we touch the child branch. When it
|
|
430
|
+
// is a Branch resource, Alchemy's resource graph already guarantees
|
|
431
|
+
// readiness before this resource's inputs are resolved.
|
|
432
|
+
if (news.parentBranch && typeof news.parentBranch === "string") {
|
|
433
|
+
yield* waitForBranchReady(organization, databaseName, parentBranchName, session);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// Observe — fetch the live branch state.
|
|
437
|
+
let current = yield* planetscale
|
|
438
|
+
.getBranch({
|
|
439
|
+
organization,
|
|
440
|
+
database: databaseName,
|
|
441
|
+
branch: observedBranchName,
|
|
442
|
+
})
|
|
443
|
+
.pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
|
|
444
|
+
|
|
445
|
+
// Ensure — if missing, create. The parent must be ready before
|
|
446
|
+
// the child can fork from it. PostgreSQL short cluster sizes need
|
|
447
|
+
// to be expanded against the parent's region so the API call lands
|
|
448
|
+
// with a valid sku.
|
|
449
|
+
if (!current) {
|
|
450
|
+
const parent = yield* waitForBranchReady(
|
|
451
|
+
organization,
|
|
452
|
+
databaseName,
|
|
453
|
+
parentBranchName,
|
|
454
|
+
session,
|
|
455
|
+
);
|
|
456
|
+
const parentClusterSize = news.clusterSize
|
|
457
|
+
? parent.kind === "postgresql"
|
|
458
|
+
? toPostgresClusterSku({
|
|
459
|
+
size: news.clusterSize,
|
|
460
|
+
arch: toPostgresClusterArch(parent.cluster_architecture),
|
|
461
|
+
region: parent.region.slug,
|
|
462
|
+
})
|
|
463
|
+
: news.clusterSize
|
|
464
|
+
: undefined;
|
|
465
|
+
yield* session.note("Creating branch...");
|
|
466
|
+
current = yield* planetscale.createBranch({
|
|
467
|
+
organization,
|
|
468
|
+
database: databaseName,
|
|
469
|
+
name: desiredBranchName,
|
|
470
|
+
parent_branch: parentBranchName,
|
|
471
|
+
backup_id: news.backupId,
|
|
472
|
+
seed_data: news.seedData,
|
|
473
|
+
region: news.region?.slug,
|
|
474
|
+
cluster_size: parentClusterSize,
|
|
475
|
+
});
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
if (current.kind !== opts.expectedKind) {
|
|
479
|
+
return yield* new PlanetscaleConflict({
|
|
480
|
+
message:
|
|
481
|
+
`Planetscale branch "${current.name}" (database "${databaseName}") has kind ` +
|
|
482
|
+
`"${current.kind}" but this resource is a ${opts.engineLabel}. ` +
|
|
483
|
+
`Use the matching ${current.kind === "mysql" ? "MySQLBranch" : "PostgresBranch"} ` +
|
|
484
|
+
`resource instead.`,
|
|
485
|
+
});
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
yield* waitForBranchReady(organization, databaseName, current.name, session);
|
|
489
|
+
|
|
490
|
+
// Sync name — branch names are mutable. Continue subsequent syncs
|
|
491
|
+
// under the name returned by the API.
|
|
492
|
+
if (current.name !== desiredBranchName) {
|
|
493
|
+
current = yield* planetscale.updateBranch({
|
|
494
|
+
organization,
|
|
495
|
+
database: databaseName,
|
|
496
|
+
branch: current.name,
|
|
497
|
+
new_name: desiredBranchName,
|
|
498
|
+
});
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
const branchName = current.name;
|
|
502
|
+
|
|
503
|
+
// Sync production status before branch settings that depend on it.
|
|
504
|
+
// PlanetScale only supports explicit branch promotion/demotion for MySQL.
|
|
505
|
+
if (opts.expectedKind === "mysql") {
|
|
506
|
+
const desiredProduction = news.isProduction ?? false;
|
|
507
|
+
if (current.production !== desiredProduction) {
|
|
508
|
+
// A keyspace can report `resizing: false` while the resize
|
|
509
|
+
// workflow is still finalizing, during which promote/demote is
|
|
510
|
+
// rejected with an UnprocessableEntity. Retry until it clears.
|
|
511
|
+
const retryWhileResizing = Effect.retry({
|
|
512
|
+
while: isResizeInProgress,
|
|
513
|
+
schedule: Schedule.max([Schedule.spaced("5 seconds"), Schedule.recurs(120)]),
|
|
514
|
+
});
|
|
515
|
+
current = desiredProduction
|
|
516
|
+
? yield* planetscale
|
|
517
|
+
.promoteBranch({
|
|
518
|
+
organization,
|
|
519
|
+
database: databaseName,
|
|
520
|
+
branch: branchName,
|
|
521
|
+
})
|
|
522
|
+
.pipe(retryWhileResizing)
|
|
523
|
+
: yield* planetscale
|
|
524
|
+
.demoteBranch({
|
|
525
|
+
organization,
|
|
526
|
+
database: databaseName,
|
|
527
|
+
branch: branchName,
|
|
528
|
+
})
|
|
529
|
+
.pipe(retryWhileResizing);
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// Sync safeMigrations — observed via `current.safe_migrations`,
|
|
534
|
+
// skip the API call entirely on no-op.
|
|
535
|
+
if (news.safeMigrations !== undefined && current.safe_migrations !== news.safeMigrations) {
|
|
536
|
+
if (news.safeMigrations) {
|
|
537
|
+
yield* planetscale.enableSafeMigrations({
|
|
538
|
+
organization,
|
|
539
|
+
database: databaseName,
|
|
540
|
+
branch: branchName,
|
|
541
|
+
});
|
|
542
|
+
} else {
|
|
543
|
+
yield* planetscale.disableSafeMigrations({
|
|
544
|
+
organization,
|
|
545
|
+
database: databaseName,
|
|
546
|
+
branch: branchName,
|
|
547
|
+
});
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
if (opts.expectedKind === "postgresql" && news.replicas !== undefined) {
|
|
552
|
+
const desiredReplicas = news.replicas;
|
|
553
|
+
const desiredHasReplicas = desiredReplicas > 0;
|
|
554
|
+
const shouldApplyReplicaChange =
|
|
555
|
+
current.has_replicas !== desiredHasReplicas ||
|
|
556
|
+
(desiredHasReplicas && output?.desiredReplicas !== desiredReplicas);
|
|
557
|
+
|
|
558
|
+
if (shouldApplyReplicaChange) {
|
|
559
|
+
yield* waitForPendingPostgresChanges(organization, databaseName, branchName);
|
|
560
|
+
const change = yield* planetscale.updateBranchChangeRequest({
|
|
561
|
+
organization,
|
|
562
|
+
database: databaseName,
|
|
563
|
+
branch: branchName,
|
|
564
|
+
replicas: desiredReplicas,
|
|
565
|
+
});
|
|
566
|
+
yield* waitForPendingPostgresChanges(organization, databaseName, branchName, change.id);
|
|
567
|
+
current = yield* planetscale.getBranch({
|
|
568
|
+
organization,
|
|
569
|
+
database: databaseName,
|
|
570
|
+
branch: branchName,
|
|
571
|
+
});
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
// Sync clusterSize — only meaningful on production branches.
|
|
576
|
+
if (news.clusterSize) {
|
|
577
|
+
if (current.kind === "postgresql") {
|
|
578
|
+
yield* ensurePostgresProductionBranchClusterSize(
|
|
579
|
+
organization,
|
|
580
|
+
databaseName,
|
|
581
|
+
branchName,
|
|
582
|
+
news.clusterSize,
|
|
583
|
+
);
|
|
584
|
+
} else {
|
|
585
|
+
yield* ensureMySQLProductionBranchClusterSize(
|
|
586
|
+
organization,
|
|
587
|
+
databaseName,
|
|
588
|
+
branchName,
|
|
589
|
+
news.clusterSize,
|
|
590
|
+
);
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
// Re-read so the returned attributes reflect any mutation.
|
|
595
|
+
const updated = yield* planetscale.getBranch({
|
|
596
|
+
organization,
|
|
597
|
+
database: databaseName,
|
|
598
|
+
branch: branchName,
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
const migrationTarget = {
|
|
602
|
+
organization,
|
|
603
|
+
database: databaseName,
|
|
604
|
+
branch: updated.name,
|
|
605
|
+
};
|
|
606
|
+
|
|
607
|
+
const migrationsInput = migrationsInputOf(news);
|
|
608
|
+
const migrations = migrationsInput
|
|
609
|
+
? yield* opts.runners.runMigrations(migrationTarget, migrationsInput, stampedOf(output))
|
|
610
|
+
: undefined;
|
|
611
|
+
const importHashes = news.importFiles?.length
|
|
612
|
+
? yield* opts.runners.runImports(
|
|
613
|
+
migrationTarget,
|
|
614
|
+
news.importFiles,
|
|
615
|
+
yield* rootDir,
|
|
616
|
+
output?.importHashes ?? {},
|
|
617
|
+
)
|
|
618
|
+
: {};
|
|
619
|
+
|
|
620
|
+
return {
|
|
621
|
+
name: updated.name,
|
|
622
|
+
organization,
|
|
623
|
+
database: databaseName,
|
|
624
|
+
parentBranch: updated.parent_branch ?? parentBranchName,
|
|
625
|
+
production: updated.production,
|
|
626
|
+
createdAt: updated.created_at,
|
|
627
|
+
updatedAt: updated.updated_at,
|
|
628
|
+
htmlUrl: updated.html_url,
|
|
629
|
+
region: { slug: updated.region.slug },
|
|
630
|
+
...migrationsAttrs({ input: migrationsInput, run: migrations, output }),
|
|
631
|
+
importHashes,
|
|
632
|
+
desiredReplicas: news.replicas ?? output?.desiredReplicas,
|
|
633
|
+
hasReplicas: updated.has_replicas,
|
|
634
|
+
hasReadOnlyReplicas: updated.has_read_only_replicas,
|
|
635
|
+
} satisfies BaseBranchAttributes;
|
|
636
|
+
}),
|
|
637
|
+
|
|
638
|
+
delete: Effect.fn(function* ({ output }: any) {
|
|
639
|
+
// If `read` returned undefined (e.g. destroy of a partially-
|
|
640
|
+
// created branch whose props never finished persisting), there
|
|
641
|
+
// is nothing addressable to delete. Drop the state entry.
|
|
642
|
+
if (!output) return;
|
|
643
|
+
yield* planetscale
|
|
644
|
+
.deleteBranch({
|
|
645
|
+
organization: output.organization,
|
|
646
|
+
database: output.database,
|
|
647
|
+
branch: output.name,
|
|
648
|
+
})
|
|
649
|
+
.pipe(
|
|
650
|
+
Effect.catchTag("NotFound", () => Effect.void),
|
|
651
|
+
Effect.catchIf(
|
|
652
|
+
isKnownError("UnprocessableEntity", "The default branch cannot be deleted."),
|
|
653
|
+
() => Effect.void,
|
|
654
|
+
),
|
|
655
|
+
);
|
|
656
|
+
}),
|
|
657
|
+
} as any);
|