@alchemy.run/planetscale 0.0.0 → 2.0.0-beta.84

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (123) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +83 -4
  3. package/lib/AuthProvider.d.ts +64 -0
  4. package/lib/AuthProvider.d.ts.map +1 -0
  5. package/lib/AuthProvider.js +384 -0
  6. package/lib/AuthProvider.js.map +1 -0
  7. package/lib/Branch.d.ts +128 -0
  8. package/lib/Branch.d.ts.map +1 -0
  9. package/lib/Branch.js +420 -0
  10. package/lib/Branch.js.map +1 -0
  11. package/lib/Contributions.d.ts +7 -0
  12. package/lib/Contributions.d.ts.map +1 -0
  13. package/lib/Contributions.js +6 -0
  14. package/lib/Contributions.js.map +1 -0
  15. package/lib/Credentials.d.ts +33 -0
  16. package/lib/Credentials.d.ts.map +1 -0
  17. package/lib/Credentials.js +65 -0
  18. package/lib/Credentials.js.map +1 -0
  19. package/lib/Database.d.ts +138 -0
  20. package/lib/Database.d.ts.map +1 -0
  21. package/lib/Database.js +1 -0
  22. package/lib/Database.js.map +1 -0
  23. package/lib/MySQL/MySQLBranch.d.ts +87 -0
  24. package/lib/MySQL/MySQLBranch.d.ts.map +1 -0
  25. package/lib/MySQL/MySQLBranch.js +15 -0
  26. package/lib/MySQL/MySQLBranch.js.map +1 -0
  27. package/lib/MySQL/MySQLClusterSize.d.ts +32 -0
  28. package/lib/MySQL/MySQLClusterSize.d.ts.map +1 -0
  29. package/lib/MySQL/MySQLClusterSize.js +111 -0
  30. package/lib/MySQL/MySQLClusterSize.js.map +1 -0
  31. package/lib/MySQL/MySQLDatabase.d.ts +141 -0
  32. package/lib/MySQL/MySQLDatabase.d.ts.map +1 -0
  33. package/lib/MySQL/MySQLDatabase.js +294 -0
  34. package/lib/MySQL/MySQLDatabase.js.map +1 -0
  35. package/lib/MySQL/MySQLMigrations.d.ts +24 -0
  36. package/lib/MySQL/MySQLMigrations.d.ts.map +1 -0
  37. package/lib/MySQL/MySQLMigrations.js +106 -0
  38. package/lib/MySQL/MySQLMigrations.js.map +1 -0
  39. package/lib/MySQL/MySQLOrigin.d.ts +15 -0
  40. package/lib/MySQL/MySQLOrigin.d.ts.map +1 -0
  41. package/lib/MySQL/MySQLOrigin.js +2 -0
  42. package/lib/MySQL/MySQLOrigin.js.map +1 -0
  43. package/lib/MySQL/MySQLPassword.d.ts +116 -0
  44. package/lib/MySQL/MySQLPassword.d.ts.map +1 -0
  45. package/lib/MySQL/MySQLPassword.js +212 -0
  46. package/lib/MySQL/MySQLPassword.js.map +1 -0
  47. package/lib/MySQL/index.d.ts +6 -0
  48. package/lib/MySQL/index.d.ts.map +1 -0
  49. package/lib/MySQL/index.js +6 -0
  50. package/lib/MySQL/index.js.map +1 -0
  51. package/lib/OAuthClient.d.ts +32 -0
  52. package/lib/OAuthClient.d.ts.map +1 -0
  53. package/lib/OAuthClient.js +52 -0
  54. package/lib/OAuthClient.js.map +1 -0
  55. package/lib/Postgres/PostgresBranch.d.ts +75 -0
  56. package/lib/Postgres/PostgresBranch.d.ts.map +1 -0
  57. package/lib/Postgres/PostgresBranch.js +15 -0
  58. package/lib/Postgres/PostgresBranch.js.map +1 -0
  59. package/lib/Postgres/PostgresClusterSize.d.ts +77 -0
  60. package/lib/Postgres/PostgresClusterSize.d.ts.map +1 -0
  61. package/lib/Postgres/PostgresClusterSize.js +89 -0
  62. package/lib/Postgres/PostgresClusterSize.js.map +1 -0
  63. package/lib/Postgres/PostgresDatabase.d.ts +95 -0
  64. package/lib/Postgres/PostgresDatabase.d.ts.map +1 -0
  65. package/lib/Postgres/PostgresDatabase.js +294 -0
  66. package/lib/Postgres/PostgresDatabase.js.map +1 -0
  67. package/lib/Postgres/PostgresDefaultRole.d.ts +94 -0
  68. package/lib/Postgres/PostgresDefaultRole.d.ts.map +1 -0
  69. package/lib/Postgres/PostgresDefaultRole.js +187 -0
  70. package/lib/Postgres/PostgresDefaultRole.js.map +1 -0
  71. package/lib/Postgres/PostgresMigrations.d.ts +24 -0
  72. package/lib/Postgres/PostgresMigrations.d.ts.map +1 -0
  73. package/lib/Postgres/PostgresMigrations.js +116 -0
  74. package/lib/Postgres/PostgresMigrations.js.map +1 -0
  75. package/lib/Postgres/PostgresOrigin.d.ts +15 -0
  76. package/lib/Postgres/PostgresOrigin.d.ts.map +1 -0
  77. package/lib/Postgres/PostgresOrigin.js +2 -0
  78. package/lib/Postgres/PostgresOrigin.js.map +1 -0
  79. package/lib/Postgres/PostgresRole.d.ts +142 -0
  80. package/lib/Postgres/PostgresRole.d.ts.map +1 -0
  81. package/lib/Postgres/PostgresRole.js +268 -0
  82. package/lib/Postgres/PostgresRole.js.map +1 -0
  83. package/lib/Postgres/index.d.ts +7 -0
  84. package/lib/Postgres/index.d.ts.map +1 -0
  85. package/lib/Postgres/index.js +7 -0
  86. package/lib/Postgres/index.js.map +1 -0
  87. package/lib/Providers.d.ts +24 -0
  88. package/lib/Providers.d.ts.map +1 -0
  89. package/lib/Providers.js +40 -0
  90. package/lib/Providers.js.map +1 -0
  91. package/lib/Util.d.ts +93 -0
  92. package/lib/Util.d.ts.map +1 -0
  93. package/lib/Util.js +135 -0
  94. package/lib/Util.js.map +1 -0
  95. package/lib/index.d.ts +9 -0
  96. package/lib/index.d.ts.map +1 -0
  97. package/lib/index.js +9 -0
  98. package/lib/index.js.map +1 -0
  99. package/package.json +79 -3
  100. package/src/AuthProvider.ts +571 -0
  101. package/src/Branch.ts +657 -0
  102. package/src/Contributions.ts +9 -0
  103. package/src/Credentials.ts +116 -0
  104. package/src/Database.ts +152 -0
  105. package/src/MySQL/MySQLBranch.ts +109 -0
  106. package/src/MySQL/MySQLClusterSize.ts +185 -0
  107. package/src/MySQL/MySQLDatabase.ts +521 -0
  108. package/src/MySQL/MySQLMigrations.ts +182 -0
  109. package/src/MySQL/MySQLOrigin.ts +15 -0
  110. package/src/MySQL/MySQLPassword.ts +428 -0
  111. package/src/MySQL/index.ts +5 -0
  112. package/src/OAuthClient.ts +59 -0
  113. package/src/Postgres/PostgresBranch.ts +97 -0
  114. package/src/Postgres/PostgresClusterSize.ts +262 -0
  115. package/src/Postgres/PostgresDatabase.ts +469 -0
  116. package/src/Postgres/PostgresDefaultRole.ts +340 -0
  117. package/src/Postgres/PostgresMigrations.ts +193 -0
  118. package/src/Postgres/PostgresOrigin.ts +15 -0
  119. package/src/Postgres/PostgresRole.ts +547 -0
  120. package/src/Postgres/index.ts +6 -0
  121. package/src/Providers.ts +67 -0
  122. package/src/Util.ts +205 -0
  123. package/src/index.ts +14 -0
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,IAAI,MAAM,mBAAmB,CAAC;AAC1C,OAAO,EAAE,KAAK,oBAAoB,EAAE,KAAK,eAAe,EAAE,kBAAkB,EAAE,MAAM,aAAa,CAAC;AAClG,OAAO,EACL,WAAW,EACX,kBAAkB,EAClB,oBAAoB,EACpB,gBAAgB,EAChB,SAAS,GACV,MAAM,kBAAkB,CAAC;AAC1B,cAAc,eAAe,CAAC;AAC9B,cAAc,kBAAkB,CAAC;AACjC,cAAc,qBAAqB,CAAC;AACpC,cAAc,gBAAgB,CAAC;AAC/B,cAAc,WAAW,CAAC"}
package/lib/index.js ADDED
@@ -0,0 +1,9 @@
1
+ export * as Auth from "./AuthProvider.js";
2
+ export { makeBranchProvider } from "./Branch.js";
3
+ export { Credentials, CredentialsFromEnv, DEFAULT_API_BASE_URL, fromAuthProvider, fromToken, } from "./Credentials.js";
4
+ export * from "./Database.js";
5
+ export * from "./MySQL/index.js";
6
+ export * from "./Postgres/index.js";
7
+ export * from "./Providers.js";
8
+ export * from "./Util.js";
9
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,IAAI,MAAM,mBAAmB,CAAC;AAC1C,OAAO,EAAmD,kBAAkB,EAAE,MAAM,aAAa,CAAC;AAClG,OAAO,EACL,WAAW,EACX,kBAAkB,EAClB,oBAAoB,EACpB,gBAAgB,EAChB,SAAS,GACV,MAAM,kBAAkB,CAAC;AAC1B,cAAc,eAAe,CAAC;AAC9B,cAAc,kBAAkB,CAAC;AACjC,cAAc,qBAAqB,CAAC;AACpC,cAAc,gBAAgB,CAAC;AAC/B,cAAc,WAAW,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alchemy.run/planetscale",
3
- "version": "0.0.0",
3
+ "version": "2.0.0-beta.84",
4
4
  "description": "PlanetScale MySQL and Postgres databases for Alchemy.",
5
5
  "keywords": [
6
6
  "alchemy",
@@ -10,12 +10,88 @@
10
10
  "planetscale",
11
11
  "postgres"
12
12
  ],
13
+ "homepage": "https://alchemy.run",
14
+ "bugs": {
15
+ "url": "https://github.com/alchemy-run/alchemy/issues"
16
+ },
13
17
  "license": "Apache-2.0",
14
18
  "author": "Sam Goodwin <sam@alchemy.run>",
15
- "homepage": "https://alchemy.run",
16
19
  "repository": {
17
20
  "type": "git",
18
21
  "url": "git+https://github.com/alchemy-run/alchemy.git",
19
22
  "directory": "packages/planetscale"
23
+ },
24
+ "files": [
25
+ "lib",
26
+ "src"
27
+ ],
28
+ "type": "module",
29
+ "sideEffects": false,
30
+ "exports": {
31
+ ".": {
32
+ "types": "./lib/index.d.ts",
33
+ "bun": "./src/index.ts",
34
+ "worker": "./src/index.ts",
35
+ "default": "./lib/index.js"
36
+ },
37
+ "./MySQL": {
38
+ "types": "./lib/MySQL/index.d.ts",
39
+ "bun": "./src/MySQL/index.ts",
40
+ "worker": "./src/MySQL/index.ts",
41
+ "default": "./lib/MySQL/index.js"
42
+ },
43
+ "./Postgres": {
44
+ "types": "./lib/Postgres/index.d.ts",
45
+ "bun": "./src/Postgres/index.ts",
46
+ "worker": "./src/Postgres/index.ts",
47
+ "default": "./lib/Postgres/index.js"
48
+ },
49
+ "./*": {
50
+ "types": "./lib/*.d.ts",
51
+ "bun": "./src/*.ts",
52
+ "worker": "./src/*.ts",
53
+ "default": "./lib/*.js"
54
+ },
55
+ "./package.json": "./package.json"
56
+ },
57
+ "publishConfig": {
58
+ "access": "public"
59
+ },
60
+ "dependencies": {
61
+ "@alchemy.run/sql": "2.0.0-beta.84",
62
+ "@distilled.cloud/core": "1.0.0-rc.17",
63
+ "@distilled.cloud/planetscale": "1.0.0-rc.17"
64
+ },
65
+ "devDependencies": {
66
+ "@types/bun": "1.4.3",
67
+ "@types/pg": "^8.20.0",
68
+ "alchemy": "2.0.0-beta.84",
69
+ "effect": "^4.0.5",
70
+ "mysql2": "^3.24.2",
71
+ "pg": "^8.22.0",
72
+ "typescript": "^7.0.2"
73
+ },
74
+ "peerDependencies": {
75
+ "alchemy": "2.0.0-beta.84",
76
+ "effect": "^4.0.5",
77
+ "mysql2": "^3.24.2",
78
+ "pg": "^8.22.0"
79
+ },
80
+ "peerDependenciesMeta": {
81
+ "mysql2": {
82
+ "optional": true
83
+ },
84
+ "pg": {
85
+ "optional": true
86
+ }
87
+ },
88
+ "alchemy": {
89
+ "contributes": [
90
+ "@alchemy.run/planetscale/Contributions"
91
+ ]
92
+ },
93
+ "scripts": {
94
+ "build": "pnpm -r --filter \"{.}...\" run build:package",
95
+ "build:package": "node scripts/build.ts"
20
96
  }
21
- }
97
+ }
@@ -0,0 +1,571 @@
1
+ import { listOrganizations } from "@distilled.cloud/planetscale";
2
+ import * as PsCredentialsModule from "@distilled.cloud/planetscale/Credentials";
3
+ import {
4
+ AuthError,
5
+ AuthProviderLayer,
6
+ refreshHint,
7
+ type ConfigureField,
8
+ type ConfigureMethod,
9
+ NeedsReauth,
10
+ type ProviderDetails,
11
+ } from "alchemy/Auth/AuthProvider";
12
+ import { browserOAuth } from "alchemy/Auth/BrowserOAuth";
13
+ import { displayRedacted } from "alchemy/Auth/Credentials";
14
+ import { getEnvRedactedRequired, getEnvRequired, mapPromptCancellation } from "alchemy/Auth/Env";
15
+ import { withProfileCredentialsLock } from "alchemy/Auth/Lock";
16
+ import {
17
+ storedSecret,
18
+ storedValueText,
19
+ validateFieldValues,
20
+ } from "alchemy/Auth/StoredAuthProvider";
21
+ import * as Interaction from "alchemy/Interaction";
22
+ import * as Clock from "effect/Clock";
23
+ import * as Duration from "effect/Duration";
24
+ import * as Effect from "effect/Effect";
25
+ import * as FetchHttpClient from "effect/http/FetchHttpClient";
26
+ import * as HttpClient from "effect/http/HttpClient";
27
+ import * as Layer from "effect/Layer";
28
+ import * as Match from "effect/Match";
29
+ import * as Redacted from "effect/Redacted";
30
+ import * as Schema from "effect/Schema";
31
+ import * as OAuthClient from "./OAuthClient.ts";
32
+
33
+ /**
34
+ * Canonical name registered in {@link AuthProviders}. Use this key to look
35
+ * up the PlanetScale {@link AuthProvider} from inside provider Layers.
36
+ */
37
+ export const PLANETSCALE_AUTH_PROVIDER_NAME = "Planetscale";
38
+
39
+ /**
40
+ * Provide PlanetScale `Credentials` + `HttpClient` to an Effect using a
41
+ * just-obtained OAuth access token. Used during configure to call
42
+ * org-discovery endpoints before the user has chosen an org.
43
+ *
44
+ * `organization` is required by the credential type but isn't consulted by
45
+ * `listOrganizations` (it's a user-scoped endpoint), so an empty string is
46
+ * fine here.
47
+ */
48
+ const withOAuthCredentials = <A, E, R>(
49
+ accessToken: string,
50
+ effect: Effect.Effect<A, E, R | PsCredentialsModule.Credentials | HttpClient.HttpClient>,
51
+ ): Effect.Effect<A, E, R> =>
52
+ Effect.provide(
53
+ effect,
54
+ Layer.mergeAll(
55
+ PsCredentialsModule.fromOAuth({
56
+ accessToken: Redacted.make(accessToken),
57
+ organization: "",
58
+ }),
59
+ FetchHttpClient.layer,
60
+ ),
61
+ );
62
+
63
+ /**
64
+ * List the organizations the OAuth user belongs to and either auto-pick
65
+ * (one org) or prompt the user to choose. Returns the org's URL slug
66
+ * (`name` field, used as `{organization}` in API paths).
67
+ */
68
+ const selectOrganization = (accessToken: string) =>
69
+ Effect.gen(function* () {
70
+ const interaction = Interaction.accessors;
71
+ const list = yield* listOrganizations;
72
+ const response = yield* list({});
73
+ const orgs = response.data;
74
+ if (orgs.length === 0) {
75
+ return yield* AuthError.make({
76
+ message: "Planetscale: no organizations found for this credential.",
77
+ });
78
+ }
79
+ if (orgs.length === 1) {
80
+ const org = orgs[0];
81
+ if (org === undefined) {
82
+ return yield* AuthError.make({
83
+ message: "Planetscale: organization response was unexpectedly empty.",
84
+ });
85
+ }
86
+ yield* interaction.output.info(`Planetscale: using organization: ${org.name} (${org.id})`);
87
+ return org.name;
88
+ }
89
+ return yield* interaction.prompt
90
+ .select({
91
+ message: "Select a Planetscale organization",
92
+ options: orgs.map((o) => ({
93
+ value: o.name,
94
+ label: o.name,
95
+ description: o.id,
96
+ })),
97
+ })
98
+ .pipe(mapPromptCancellation);
99
+ }).pipe((e) => withOAuthCredentials(accessToken, e));
100
+
101
+ const options: Array<{
102
+ value: PlanetscaleAuthConfig["method"];
103
+ label: string;
104
+ description?: string;
105
+ }> = [
106
+ {
107
+ value: "oauth",
108
+ label: "OAuth",
109
+ description: "recommended — browser-based login with automatic token refresh",
110
+ },
111
+ {
112
+ value: "stored",
113
+ label: "Service Token",
114
+ description: "enter a service token and store it inline in the provider file",
115
+ },
116
+ ];
117
+
118
+ /**
119
+ * Typed values stored in a PlanetScale provider profile document. Both
120
+ * service-token and OAuth values are inline. PlanetScale has no PKCE flow,
121
+ * so the OAuth application's
122
+ * `client_secret` ships in the CLI — see {@link OAuthClient}.
123
+ */
124
+ export const PlanetscaleAuthConfigSchema = Schema.Union([
125
+ Schema.Struct({
126
+ method: Schema.Literal("stored"),
127
+ tokenId: Schema.String,
128
+ token: Schema.String,
129
+ organization: Schema.String,
130
+ }),
131
+ Schema.Struct({
132
+ method: Schema.Literal("oauth"),
133
+ organization: Schema.String,
134
+ clientId: Schema.optional(Schema.String),
135
+ access: Schema.String,
136
+ refresh: Schema.String,
137
+ expires: Schema.Number,
138
+ scopes: Schema.String.pipe(Schema.Array, Schema.mutable),
139
+ }),
140
+ ]);
141
+ export type PlanetscaleAuthConfig = typeof PlanetscaleAuthConfigSchema.Type;
142
+
143
+ /**
144
+ * Resolved in-memory PlanetScale credentials returned by
145
+ * {@link AuthProviderImpl.read}. Either a service token (`tokenId`/`token`)
146
+ * or an OAuth access token.
147
+ */
148
+ export type PlanetscaleResolvedCredentials =
149
+ | {
150
+ type: "apiToken";
151
+ tokenId: Redacted.Redacted<string>;
152
+ token: Redacted.Redacted<string>;
153
+ organization: string;
154
+ source: {
155
+ type: PlanetscaleAuthConfig["method"] | "env";
156
+ details?: string;
157
+ };
158
+ }
159
+ | {
160
+ type: "oauth";
161
+ accessToken: Redacted.Redacted<string>;
162
+ expires: number;
163
+ organization: string;
164
+ source: {
165
+ type: PlanetscaleAuthConfig["method"] | "env";
166
+ details?: string;
167
+ };
168
+ };
169
+
170
+ /**
171
+ * Layer that registers the PlanetScale {@link AuthProvider} into the
172
+ * {@link AuthProviders} registry when built. Include this in the
173
+ * PlanetScale `providers()` layer so the alchemy CLI can discover it.
174
+ *
175
+ * Supported methods:
176
+ * - `stored`: prompts for a service token and stores it inline.
177
+ * - `oauth`: browser-based login storing access/refresh values inline.
178
+ */
179
+ export const PlanetscaleAuth = AuthProviderLayer<
180
+ PlanetscaleAuthConfig,
181
+ PlanetscaleResolvedCredentials
182
+ >()(
183
+ PLANETSCALE_AUTH_PROVIDER_NAME,
184
+ Effect.gen(function* () {
185
+ const interaction = Interaction.accessors;
186
+ const oauthLogin = (_profileName: string) =>
187
+ Effect.gen(function* () {
188
+ const authorization = yield* OAuthClient.authorize();
189
+
190
+ const credentials = yield* browserOAuth({
191
+ provider: "Planetscale",
192
+ url: authorization.url,
193
+ callback: OAuthClient.callback(authorization),
194
+ exchange: (input) => OAuthClient.exchangeCallbackInput(input, authorization),
195
+ });
196
+ yield* interaction.output.success("Planetscale: OAuth credentials saved.");
197
+ return credentials;
198
+ });
199
+
200
+ const configureOAuth = Effect.fn(function* (profileName: string) {
201
+ const oauthCreds = yield* oauthLogin(profileName);
202
+
203
+ // Use the just-issued access token to list the user's orgs and let
204
+ // them pick (mirrors Cloudflare's selectAccount). Requires the
205
+ // `user:read_organizations` scope. If the call fails for any
206
+ // reason — missing scope, network, off-spec response — fall back
207
+ // to a manual prompt so login still completes.
208
+ const organization = yield* oauthCreds.access.pipe(
209
+ Redacted.value,
210
+ selectOrganization,
211
+ Effect.catch((e) =>
212
+ Effect.gen(function* () {
213
+ yield* interaction.output.warning(
214
+ `Planetscale: could not auto-list organizations (${String(e)}). Falling back to manual entry.`,
215
+ );
216
+ return yield* interaction.prompt
217
+ .text({
218
+ message: "Planetscale Organization (URL slug)",
219
+ validate: (v) => (v.length === 0 ? "Required" : undefined),
220
+ })
221
+ .pipe(mapPromptCancellation);
222
+ }),
223
+ ),
224
+ );
225
+
226
+ return {
227
+ method: "oauth" as const,
228
+ organization,
229
+ clientId: oauthCreds.clientId,
230
+ access: Redacted.value(oauthCreds.access),
231
+ refresh: Redacted.value(oauthCreds.refresh),
232
+ expires: oauthCreds.expires,
233
+ scopes: oauthCreds.scopes,
234
+ };
235
+ });
236
+
237
+ const loginStored = Effect.fn(function* (_profileName: string) {
238
+ const tokenId = yield* interaction.prompt
239
+ .text({
240
+ message: "Planetscale Service Token ID",
241
+ validate: (v) => (v.length === 0 ? "Required" : undefined),
242
+ })
243
+ .pipe(mapPromptCancellation);
244
+
245
+ const token = yield* interaction.prompt
246
+ .password({
247
+ message: "Planetscale Service Token",
248
+ validate: (v) => (v.length === 0 ? "Required" : undefined),
249
+ })
250
+ .pipe(mapPromptCancellation);
251
+
252
+ const organization = yield* interaction.prompt
253
+ .text({
254
+ message: "Planetscale Organization (URL slug)",
255
+ validate: (v) => (v.length === 0 ? "Required" : undefined),
256
+ })
257
+ .pipe(mapPromptCancellation);
258
+
259
+ yield* interaction.output.success("Planetscale: credentials saved.");
260
+ return { method: "stored" as const, tokenId, token, organization };
261
+ });
262
+
263
+ const configureInteractive = (profileName: string) =>
264
+ interaction.prompt
265
+ .select({
266
+ message: "Planetscale authentication method",
267
+ options,
268
+ })
269
+ .pipe(
270
+ Effect.flatMap((method) =>
271
+ Match.value(method).pipe(
272
+ Match.when("oauth", () => configureOAuth(profileName)),
273
+ Match.when("stored", () => loginStored(profileName)),
274
+ Match.exhaustive,
275
+ ),
276
+ ),
277
+ );
278
+
279
+ const configureCredentials = (profileName: string) =>
280
+ configureInteractive(profileName).pipe(
281
+ Effect.mapError((e) =>
282
+ AuthError.make({
283
+ message: "failed to configure credentials",
284
+ cause: e,
285
+ }),
286
+ ),
287
+ );
288
+
289
+ /**
290
+ * Flag-driven (`--method stored --set ...`) fields, mirroring the
291
+ * interactive service-token prompts. OAuth requires a browser and stays
292
+ * interactive-only, so it is deliberately absent from
293
+ * {@link configureMethods}.
294
+ */
295
+ const serviceTokenFields: ReadonlyArray<ConfigureField> = [
296
+ { name: "tokenId", label: "Planetscale Service Token ID" },
297
+ { name: "token", label: "Planetscale Service Token", secret: true },
298
+ { name: "organization", label: "Planetscale Organization (URL slug)" },
299
+ ];
300
+
301
+ const configureMethods: ReadonlyArray<ConfigureMethod> = [
302
+ { method: "stored", fields: serviceTokenFields },
303
+ ];
304
+
305
+ const configureWith = (
306
+ _profileName: string,
307
+ input: {
308
+ readonly method: string;
309
+ readonly values: Record<string, string>;
310
+ },
311
+ ): Effect.Effect<PlanetscaleAuthConfig, AuthError, Interaction.Interaction> =>
312
+ input.method === "stored"
313
+ ? validateFieldValues(
314
+ PLANETSCALE_AUTH_PROVIDER_NAME,
315
+ serviceTokenFields,
316
+ input.values,
317
+ ).pipe(
318
+ Effect.map((values) => ({
319
+ method: "stored" as const,
320
+ tokenId: Redacted.value(storedSecret(values.tokenId) ?? Redacted.make("")),
321
+ token: Redacted.value(storedSecret(values.token) ?? Redacted.make("")),
322
+ organization: storedValueText(values.organization) ?? "",
323
+ })),
324
+ Effect.tap(() => interaction.output.success("Planetscale: credentials saved.")),
325
+ )
326
+ : Effect.fail(
327
+ AuthError.make({
328
+ message: `Planetscale: unknown method '${input.method}'. Valid methods: stored. (OAuth is interactive-only.)`,
329
+ }),
330
+ );
331
+
332
+ const resolveCredentials = (
333
+ profileName: string,
334
+ config: PlanetscaleAuthConfig,
335
+ updateConfig?: (config: PlanetscaleAuthConfig) => Effect.Effect<void, AuthError>,
336
+ ) =>
337
+ Effect.gen(function* () {
338
+ const reauth = refreshHint(PLANETSCALE_AUTH_PROVIDER_NAME, profileName);
339
+ return yield* Match.value(config).pipe(
340
+ Match.when({ method: "stored" }, (stored) =>
341
+ Effect.succeed({
342
+ type: "apiToken" as const,
343
+ tokenId: Redacted.make(stored.tokenId),
344
+ token: Redacted.make(stored.token),
345
+ organization: stored.organization,
346
+ source: { type: "stored" as const, details: undefined },
347
+ } satisfies PlanetscaleResolvedCredentials),
348
+ ),
349
+ Match.when({ method: "oauth" }, (cfg) =>
350
+ Effect.gen(function* () {
351
+ const creds: OAuthClient.OAuthCredentials = {
352
+ type: "oauth",
353
+ clientId: cfg.clientId,
354
+ access: Redacted.make(cfg.access),
355
+ refresh: Redacted.make(cfg.refresh),
356
+ expires: cfg.expires,
357
+ scopes: cfg.scopes,
358
+ };
359
+ if (!OAuthClient.usesCurrentClient(creds)) {
360
+ return yield* NeedsReauth.make({
361
+ provider: PLANETSCALE_AUTH_PROVIDER_NAME,
362
+ profile: profileName,
363
+ message: `Planetscale OAuth credentials for profile '${profileName}' were issued to an incompatible OAuth client and have been removed. ${reauth}`,
364
+ });
365
+ }
366
+ // Refresh proactively if the token has expired (or is within
367
+ // 10s of expiring). Persist the refreshed creds so subsequent
368
+ // resolves don't repeat the round-trip.
369
+ const now = yield* Clock.currentTimeMillis;
370
+ const fresh =
371
+ creds.expires > now + 10_000
372
+ ? creds
373
+ : yield* OAuthClient.refresh(creds).pipe(
374
+ // Only the refresh round-trip maps to NeedsReauth — a
375
+ // failed persist afterwards is a local I/O AuthError and
376
+ // passes through untouched.
377
+ Effect.mapError((e) =>
378
+ NeedsReauth.make({
379
+ provider: PLANETSCALE_AUTH_PROVIDER_NAME,
380
+ profile: profileName,
381
+ message: `Planetscale OAuth refresh failed. ${reauth}`,
382
+ cause: e,
383
+ }),
384
+ ),
385
+ );
386
+ if (fresh !== creds) {
387
+ yield* (
388
+ updateConfig?.({
389
+ method: "oauth",
390
+ organization: cfg.organization,
391
+ clientId: fresh.clientId,
392
+ access: Redacted.value(fresh.access),
393
+ refresh: Redacted.value(fresh.refresh),
394
+ expires: fresh.expires,
395
+ scopes: fresh.scopes,
396
+ }) ?? Effect.void
397
+ );
398
+ }
399
+ return {
400
+ type: "oauth" as const,
401
+ accessToken: fresh.access,
402
+ expires: fresh.expires,
403
+ organization: cfg.organization,
404
+ source: { type: "oauth" as const },
405
+ } satisfies PlanetscaleResolvedCredentials;
406
+ }),
407
+ ),
408
+ Match.exhaustive,
409
+ );
410
+ });
411
+
412
+ const logout = (_profileName: string, config: PlanetscaleAuthConfig) =>
413
+ Match.value(config).pipe(
414
+ Match.when({ method: "stored" }, () => Effect.void),
415
+ Match.when({ method: "oauth" }, () => Effect.void),
416
+ Match.exhaustive,
417
+ );
418
+
419
+ const login = (
420
+ profileName: string,
421
+ config: PlanetscaleAuthConfig,
422
+ updateConfig?: (config: PlanetscaleAuthConfig) => Effect.Effect<void, AuthError>,
423
+ ) =>
424
+ Match.value(config).pipe(
425
+ Match.when({ method: "stored" }, (stored) => Effect.succeed(stored)),
426
+ Match.when({ method: "oauth" }, (oauth) =>
427
+ Effect.gen(function* () {
428
+ const credentials: OAuthClient.OAuthCredentials = {
429
+ type: "oauth",
430
+ clientId: oauth.clientId,
431
+ access: Redacted.make(oauth.access),
432
+ refresh: Redacted.make(oauth.refresh),
433
+ expires: oauth.expires,
434
+ scopes: oauth.scopes,
435
+ };
436
+ if (!OAuthClient.usesCurrentClient(credentials)) {
437
+ return yield* configureOAuth(profileName);
438
+ }
439
+ const refreshed = yield* withProfileCredentialsLock(
440
+ profileName,
441
+ interaction.output.info("Planetscale: refreshing OAuth credentials...").pipe(
442
+ Effect.andThen(OAuthClient.refresh(credentials)),
443
+ Effect.flatMap((credentials) => {
444
+ const config = {
445
+ ...oauth,
446
+ clientId: credentials.clientId,
447
+ access: Redacted.value(credentials.access),
448
+ refresh: Redacted.value(credentials.refresh),
449
+ expires: credentials.expires,
450
+ scopes: credentials.scopes,
451
+ };
452
+ return (updateConfig?.(config) ?? Effect.void).pipe(Effect.as(config));
453
+ }),
454
+ Effect.tap(() =>
455
+ interaction.output.success("Planetscale: OAuth credentials refreshed."),
456
+ ),
457
+ ),
458
+ ).pipe(
459
+ Effect.catchTag(
460
+ [
461
+ "OAuthError",
462
+ "OAuthNetworkError",
463
+ "OAuthServerUnavailable",
464
+ "OAuthInvalidResponse",
465
+ ],
466
+ () => configureOAuth(profileName),
467
+ ),
468
+ );
469
+ return refreshed;
470
+ }),
471
+ ),
472
+ Match.exhaustive,
473
+ Effect.mapError((e) => AuthError.make({ message: "login failed", cause: e })),
474
+ );
475
+
476
+ const details = (
477
+ profileName: string,
478
+ config: PlanetscaleAuthConfig,
479
+ updateConfig?: (config: PlanetscaleAuthConfig) => Effect.Effect<void, AuthError>,
480
+ ) =>
481
+ Effect.all([
482
+ resolveCredentials(profileName, config, updateConfig),
483
+ Clock.currentTimeMillis,
484
+ ]).pipe(
485
+ Effect.map(([creds, now]): ProviderDetails => {
486
+ const sourceStr =
487
+ "details" in creds.source && creds.source.details
488
+ ? `${creds.source.type} - ${creds.source.details}`
489
+ : creds.source.type;
490
+ return Match.value(creds).pipe(
491
+ Match.when({ type: "apiToken" }, (c) => ({
492
+ lines: [
493
+ { key: "tokenId", value: displayRedacted(c.tokenId, 3) },
494
+ { key: "token", value: displayRedacted(c.token, 6) },
495
+ { key: "organization", value: c.organization },
496
+ { key: "source", value: sourceStr },
497
+ ],
498
+ })),
499
+ Match.when({ type: "oauth" }, (c) => {
500
+ const remainingMs = c.expires - now;
501
+ const expiresAt = new Date(c.expires).toISOString();
502
+ const expiresStr =
503
+ remainingMs <= 0
504
+ ? `expired (${expiresAt})`
505
+ : `in ${Duration.format(Duration.millis(remainingMs))} (${expiresAt})`;
506
+ return {
507
+ lines: [
508
+ { key: "accessToken", value: displayRedacted(c.accessToken) },
509
+ { key: "expires", value: expiresStr },
510
+ { key: "organization", value: c.organization },
511
+ { key: "source", value: sourceStr },
512
+ ],
513
+ };
514
+ }),
515
+ Match.exhaustive,
516
+ );
517
+ }),
518
+ );
519
+
520
+ const readEnvironment = Effect.gen(function* () {
521
+ const tokenId = yield* getEnvRedactedRequired("PLANETSCALE_API_TOKEN_ID");
522
+ const token = yield* getEnvRedactedRequired("PLANETSCALE_API_TOKEN");
523
+ const organization = yield* getEnvRequired("PLANETSCALE_ORGANIZATION");
524
+ return {
525
+ type: "apiToken" as const,
526
+ tokenId,
527
+ token,
528
+ organization,
529
+ source: {
530
+ type: "env" as const,
531
+ details: "PLANETSCALE_API_TOKEN_ID/PLANETSCALE_API_TOKEN",
532
+ },
533
+ } satisfies PlanetscaleResolvedCredentials;
534
+ });
535
+
536
+ return {
537
+ configSchema: PlanetscaleAuthConfigSchema,
538
+ configure: configureCredentials,
539
+ configureWith,
540
+ configureMethods,
541
+ logout,
542
+ login,
543
+ details,
544
+ read: resolveCredentials,
545
+ readEnvironment,
546
+ environment: [
547
+ {
548
+ name: "PLANETSCALE_API_TOKEN_ID",
549
+ required: true,
550
+ description: "Service token id.",
551
+ },
552
+ {
553
+ name: "PLANETSCALE_API_TOKEN",
554
+ required: true,
555
+ secret: true,
556
+ description: "Service token secret.",
557
+ },
558
+ {
559
+ name: "PLANETSCALE_ORGANIZATION",
560
+ required: true,
561
+ description: "Organization URL slug.",
562
+ },
563
+ {
564
+ name: "PLANETSCALE_API_BASE_URL",
565
+ required: false,
566
+ description: "API base URL override.",
567
+ },
568
+ ],
569
+ };
570
+ }),
571
+ );