@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.
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 +395 -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 +59 -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 +20 -0
  36. package/lib/MySQL/MySQLMigrations.d.ts.map +1 -0
  37. package/lib/MySQL/MySQLMigrations.js +72 -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 +16 -0
  72. package/lib/Postgres/PostgresMigrations.d.ts.map +1 -0
  73. package/lib/Postgres/PostgresMigrations.js +65 -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 +584 -0
  101. package/src/Branch.ts +657 -0
  102. package/src/Contributions.ts +9 -0
  103. package/src/Credentials.ts +92 -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 +133 -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 +122 -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.85",
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.85",
62
+ "@distilled.cloud/core": "1.0.0-rc.18",
63
+ "@distilled.cloud/planetscale": "1.0.0-rc.18"
64
+ },
65
+ "devDependencies": {
66
+ "@types/bun": "1.4.3",
67
+ "@types/pg": "^8.20.0",
68
+ "alchemy": "2.0.0-beta.85",
69
+ "effect": "^4.1.0",
70
+ "mysql2": "^3.24.2",
71
+ "pg": "^8.22.0",
72
+ "typescript": "^7.0.2"
73
+ },
74
+ "peerDependencies": {
75
+ "alchemy": "2.0.0-beta.85",
76
+ "effect": "^4.1.0",
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,584 @@
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 list = yield* listOrganizations;
71
+ const response = yield* list({});
72
+ const orgs = response.data;
73
+ if (orgs.length === 0) {
74
+ return yield* AuthError.make({
75
+ message: "Planetscale: no organizations found for this credential.",
76
+ });
77
+ }
78
+ if (orgs.length === 1) {
79
+ const org = orgs[0];
80
+ if (org === undefined) {
81
+ return yield* AuthError.make({
82
+ message: "Planetscale: organization response was unexpectedly empty.",
83
+ });
84
+ }
85
+ yield* Interaction.info(`Planetscale: using organization: ${org.name} (${org.id})`);
86
+ return org.name;
87
+ }
88
+ return yield* Interaction.select({
89
+ message: "Select a Planetscale organization",
90
+ options: orgs.map((o) => ({
91
+ value: o.name,
92
+ label: o.name,
93
+ description: o.id,
94
+ })),
95
+ }).pipe(mapPromptCancellation);
96
+ }).pipe((e) => withOAuthCredentials(accessToken, e));
97
+
98
+ const options: Array<{
99
+ value: PlanetscaleAuthConfig["method"];
100
+ label: string;
101
+ description?: string;
102
+ }> = [
103
+ {
104
+ value: "oauth",
105
+ label: "OAuth",
106
+ description: "recommended — browser-based login with automatic token refresh",
107
+ },
108
+ {
109
+ value: "stored",
110
+ label: "Service Token",
111
+ description: "enter a service token and store it inline in the provider file",
112
+ },
113
+ ];
114
+
115
+ /**
116
+ * Typed values stored in a PlanetScale provider profile document. Both
117
+ * service-token and OAuth values are inline. PlanetScale has no PKCE flow,
118
+ * so the OAuth application's
119
+ * `client_secret` ships in the CLI — see {@link OAuthClient}.
120
+ */
121
+ export const PlanetscaleAuthConfigSchema = Schema.Union([
122
+ Schema.Struct({
123
+ method: Schema.Literal("stored"),
124
+ tokenId: Schema.String,
125
+ token: Schema.String,
126
+ organization: Schema.String,
127
+ }),
128
+ Schema.Struct({
129
+ method: Schema.Literal("oauth"),
130
+ organization: Schema.String,
131
+ clientId: Schema.optional(Schema.String),
132
+ access: Schema.String,
133
+ refresh: Schema.String,
134
+ expires: Schema.Number,
135
+ scopes: Schema.String.pipe(Schema.Array, Schema.mutable),
136
+ }),
137
+ ]);
138
+ export type PlanetscaleAuthConfig = typeof PlanetscaleAuthConfigSchema.Type;
139
+
140
+ /**
141
+ * Resolved in-memory PlanetScale credentials returned by
142
+ * {@link AuthProviderImpl.read}. Either a service token (`tokenId`/`token`)
143
+ * or an OAuth access token.
144
+ */
145
+ export type PlanetscaleResolvedCredentials =
146
+ | {
147
+ type: "apiToken";
148
+ tokenId: Redacted.Redacted<string>;
149
+ token: Redacted.Redacted<string>;
150
+ organization: string;
151
+ source: {
152
+ type: PlanetscaleAuthConfig["method"] | "env";
153
+ details?: string;
154
+ };
155
+ }
156
+ | {
157
+ type: "oauth";
158
+ accessToken: Redacted.Redacted<string>;
159
+ expires: number;
160
+ organization: string;
161
+ source: {
162
+ type: PlanetscaleAuthConfig["method"] | "env";
163
+ details?: string;
164
+ };
165
+ };
166
+
167
+ /**
168
+ * Layer that registers the PlanetScale {@link AuthProvider} into the
169
+ * {@link AuthProviders} registry when built. Include this in the
170
+ * PlanetScale `providers()` layer so the alchemy CLI can discover it.
171
+ *
172
+ * Supported methods:
173
+ * - `stored`: prompts for a service token and stores it inline.
174
+ * - `oauth`: browser-based login storing access/refresh values inline.
175
+ */
176
+ export const PlanetscaleAuth = AuthProviderLayer<
177
+ PlanetscaleAuthConfig,
178
+ PlanetscaleResolvedCredentials
179
+ >()(
180
+ PLANETSCALE_AUTH_PROVIDER_NAME,
181
+ Effect.gen(function* () {
182
+ const oauthLogin = (_profileName: string) =>
183
+ Effect.gen(function* () {
184
+ const authorization = yield* OAuthClient.authorize();
185
+
186
+ const credentials = yield* browserOAuth({
187
+ provider: "Planetscale",
188
+ url: authorization.url,
189
+ callback: OAuthClient.callback(authorization),
190
+ exchange: (input) => OAuthClient.exchangeCallbackInput(input, authorization),
191
+ });
192
+ yield* Interaction.success("Planetscale: OAuth credentials saved.");
193
+ return credentials;
194
+ });
195
+
196
+ const configureOAuth = Effect.fn(function* (profileName: string) {
197
+ const oauthCreds = yield* oauthLogin(profileName);
198
+
199
+ // Use the just-issued access token to list the user's orgs and let
200
+ // them pick (mirrors Cloudflare's selectAccount). Requires the
201
+ // `user:read_organizations` scope. If the call fails for any
202
+ // reason — missing scope, network, off-spec response — fall back
203
+ // to a manual prompt so login still completes.
204
+ const organization = yield* oauthCreds.access.pipe(
205
+ Redacted.value,
206
+ selectOrganization,
207
+ Effect.catch((e) =>
208
+ Effect.gen(function* () {
209
+ yield* Interaction.warning(
210
+ `Planetscale: could not auto-list organizations (${String(e)}). Falling back to manual entry.`,
211
+ );
212
+ return yield* Interaction.text({
213
+ message: "Planetscale Organization (URL slug)",
214
+ validate: (v) => (v.length === 0 ? "Required" : undefined),
215
+ }).pipe(mapPromptCancellation);
216
+ }),
217
+ ),
218
+ );
219
+
220
+ return {
221
+ method: "oauth" as const,
222
+ organization,
223
+ clientId: oauthCreds.clientId,
224
+ access: Redacted.value(oauthCreds.access),
225
+ refresh: Redacted.value(oauthCreds.refresh),
226
+ expires: oauthCreds.expires,
227
+ scopes: oauthCreds.scopes,
228
+ };
229
+ });
230
+
231
+ const loginStored = Effect.fn(function* (_profileName: string) {
232
+ const tokenId = yield* Interaction.text({
233
+ message: "Planetscale Service Token ID",
234
+ validate: (v) => (v.length === 0 ? "Required" : undefined),
235
+ }).pipe(mapPromptCancellation);
236
+
237
+ const token = yield* Interaction.password({
238
+ message: "Planetscale Service Token",
239
+ validate: (v) => (v.length === 0 ? "Required" : undefined),
240
+ }).pipe(mapPromptCancellation);
241
+
242
+ const organization = yield* Interaction.text({
243
+ message: "Planetscale Organization (URL slug)",
244
+ validate: (v) => (v.length === 0 ? "Required" : undefined),
245
+ }).pipe(mapPromptCancellation);
246
+
247
+ yield* Interaction.success("Planetscale: credentials saved.");
248
+ return { method: "stored" as const, tokenId, token, organization };
249
+ });
250
+
251
+ const configureInteractive = (profileName: string) =>
252
+ Interaction.select({
253
+ message: "Planetscale authentication method",
254
+ options,
255
+ }).pipe(
256
+ Effect.flatMap((method) =>
257
+ Match.value(method).pipe(
258
+ Match.when("oauth", () => configureOAuth(profileName)),
259
+ Match.when("stored", () => loginStored(profileName)),
260
+ Match.exhaustive,
261
+ ),
262
+ ),
263
+ );
264
+
265
+ const configureCredentials = (profileName: string) =>
266
+ configureInteractive(profileName).pipe(
267
+ Effect.mapError((e) =>
268
+ AuthError.make({
269
+ message: "failed to configure credentials",
270
+ cause: e,
271
+ }),
272
+ ),
273
+ );
274
+
275
+ /**
276
+ * Flag-driven (`--method stored --set ...`) fields, mirroring the
277
+ * interactive service-token prompts. OAuth requires a browser and stays
278
+ * interactive-only, so it is deliberately absent from
279
+ * {@link configureMethods}.
280
+ */
281
+ const serviceTokenFields: ReadonlyArray<ConfigureField> = [
282
+ { name: "tokenId", label: "Planetscale Service Token ID" },
283
+ { name: "token", label: "Planetscale Service Token", secret: true },
284
+ { name: "organization", label: "Planetscale Organization (URL slug)" },
285
+ ];
286
+
287
+ const configureMethods: ReadonlyArray<ConfigureMethod> = [
288
+ { method: "stored", fields: serviceTokenFields },
289
+ ];
290
+
291
+ const configureWith = (
292
+ _profileName: string,
293
+ input: {
294
+ readonly method: string;
295
+ readonly values: Record<string, string>;
296
+ },
297
+ ): Effect.Effect<PlanetscaleAuthConfig, AuthError, Interaction.Interaction> =>
298
+ input.method === "stored"
299
+ ? validateFieldValues(
300
+ PLANETSCALE_AUTH_PROVIDER_NAME,
301
+ serviceTokenFields,
302
+ input.values,
303
+ ).pipe(
304
+ Effect.map((values) => ({
305
+ method: "stored" as const,
306
+ tokenId: Redacted.value(storedSecret(values.tokenId) ?? Redacted.make("")),
307
+ token: Redacted.value(storedSecret(values.token) ?? Redacted.make("")),
308
+ organization: storedValueText(values.organization) ?? "",
309
+ })),
310
+ Effect.tap(() => Interaction.success("Planetscale: credentials saved.")),
311
+ )
312
+ : Effect.fail(
313
+ AuthError.make({
314
+ message: `Planetscale: unknown method '${input.method}'. Valid methods: stored. (OAuth is interactive-only.)`,
315
+ }),
316
+ );
317
+
318
+ const resolveCredentials = (
319
+ profileName: string,
320
+ config: PlanetscaleAuthConfig,
321
+ updateConfig?: (config: PlanetscaleAuthConfig) => Effect.Effect<void, AuthError>,
322
+ ) =>
323
+ Effect.gen(function* () {
324
+ const reauth = refreshHint(PLANETSCALE_AUTH_PROVIDER_NAME, profileName);
325
+ return yield* Match.value(config).pipe(
326
+ Match.when({ method: "stored" }, (stored) =>
327
+ Effect.succeed({
328
+ type: "apiToken" as const,
329
+ tokenId: Redacted.make(stored.tokenId),
330
+ token: Redacted.make(stored.token),
331
+ organization: stored.organization,
332
+ source: { type: "stored" as const, details: undefined },
333
+ } satisfies PlanetscaleResolvedCredentials),
334
+ ),
335
+ Match.when({ method: "oauth" }, (cfg) =>
336
+ Effect.gen(function* () {
337
+ const creds: OAuthClient.OAuthCredentials = {
338
+ type: "oauth",
339
+ clientId: cfg.clientId,
340
+ access: Redacted.make(cfg.access),
341
+ refresh: Redacted.make(cfg.refresh),
342
+ expires: cfg.expires,
343
+ scopes: cfg.scopes,
344
+ };
345
+ if (!OAuthClient.usesCurrentClient(creds)) {
346
+ return yield* NeedsReauth.make({
347
+ provider: PLANETSCALE_AUTH_PROVIDER_NAME,
348
+ profile: profileName,
349
+ message: `Planetscale OAuth credentials for profile '${profileName}' were issued to an incompatible OAuth client and have been removed. ${reauth}`,
350
+ });
351
+ }
352
+ // Refresh proactively if the token has expired (or is within
353
+ // 10s of expiring). Persist the refreshed creds so subsequent
354
+ // resolves don't repeat the round-trip.
355
+ const now = yield* Clock.currentTimeMillis;
356
+ const fresh =
357
+ creds.expires > now + 10_000
358
+ ? creds
359
+ : yield* OAuthClient.refresh(creds).pipe(
360
+ // Only the refresh round-trip maps to NeedsReauth — a
361
+ // failed persist afterwards is a local I/O AuthError and
362
+ // passes through untouched.
363
+ Effect.mapError((e) =>
364
+ NeedsReauth.make({
365
+ provider: PLANETSCALE_AUTH_PROVIDER_NAME,
366
+ profile: profileName,
367
+ message: `Planetscale OAuth refresh failed. ${reauth}`,
368
+ cause: e,
369
+ }),
370
+ ),
371
+ );
372
+ if (fresh !== creds) {
373
+ yield* (
374
+ updateConfig?.({
375
+ method: "oauth",
376
+ organization: cfg.organization,
377
+ clientId: fresh.clientId,
378
+ access: Redacted.value(fresh.access),
379
+ refresh: Redacted.value(fresh.refresh),
380
+ expires: fresh.expires,
381
+ scopes: fresh.scopes,
382
+ }) ?? Effect.void
383
+ );
384
+ }
385
+ return {
386
+ type: "oauth" as const,
387
+ accessToken: fresh.access,
388
+ expires: fresh.expires,
389
+ organization: cfg.organization,
390
+ source: { type: "oauth" as const },
391
+ } satisfies PlanetscaleResolvedCredentials;
392
+ }),
393
+ ),
394
+ Match.exhaustive,
395
+ );
396
+ });
397
+
398
+ const logout = (_profileName: string, config: PlanetscaleAuthConfig) =>
399
+ Match.value(config).pipe(
400
+ Match.when({ method: "stored" }, () => Effect.void),
401
+ Match.when({ method: "oauth" }, () => Effect.void),
402
+ Match.exhaustive,
403
+ );
404
+
405
+ const login = (
406
+ profileName: string,
407
+ config: PlanetscaleAuthConfig,
408
+ updateConfig?: (config: PlanetscaleAuthConfig) => Effect.Effect<void, AuthError>,
409
+ reload?: Effect.Effect<PlanetscaleAuthConfig, AuthError>,
410
+ ) =>
411
+ Match.value(config).pipe(
412
+ Match.when({ method: "stored" }, (stored) => Effect.succeed(stored)),
413
+ Match.when({ method: "oauth" }, (oauth) =>
414
+ Effect.gen(function* () {
415
+ const credentials: OAuthClient.OAuthCredentials = {
416
+ type: "oauth",
417
+ clientId: oauth.clientId,
418
+ access: Redacted.make(oauth.access),
419
+ refresh: Redacted.make(oauth.refresh),
420
+ expires: oauth.expires,
421
+ scopes: oauth.scopes,
422
+ };
423
+ if (!OAuthClient.usesCurrentClient(credentials)) {
424
+ return yield* configureOAuth(profileName);
425
+ }
426
+ // Refresh tokens are single-use: refresh with the token stored
427
+ // now, read under the lock, not the one `config` was read with.
428
+ const outcome = yield* withProfileCredentialsLock(
429
+ profileName,
430
+ Effect.gen(function* () {
431
+ const current = yield* reload ?? Effect.succeed(oauth);
432
+ if (current.method !== "oauth") return { type: "browser" as const };
433
+ const currentCredentials: OAuthClient.OAuthCredentials = {
434
+ type: "oauth",
435
+ clientId: current.clientId,
436
+ access: Redacted.make(current.access),
437
+ refresh: Redacted.make(current.refresh),
438
+ expires: current.expires,
439
+ scopes: current.scopes,
440
+ };
441
+ if (!OAuthClient.usesCurrentClient(currentCredentials)) {
442
+ return { type: "browser" as const };
443
+ }
444
+ return yield* Interaction.info("Planetscale: refreshing OAuth credentials...").pipe(
445
+ Effect.andThen(OAuthClient.refresh(currentCredentials)),
446
+ Effect.flatMap((credentials) => {
447
+ const config = {
448
+ ...current,
449
+ clientId: credentials.clientId,
450
+ access: Redacted.value(credentials.access),
451
+ refresh: Redacted.value(credentials.refresh),
452
+ expires: credentials.expires,
453
+ scopes: credentials.scopes,
454
+ };
455
+ // Saved here, under the lock, when the caller can take it;
456
+ // otherwise handed back for the caller to save.
457
+ const unsaved: Effect.Effect<typeof config | undefined, AuthError> =
458
+ updateConfig === undefined
459
+ ? Effect.succeed(config)
460
+ : Effect.as(updateConfig(config), undefined);
461
+ return unsaved.pipe(
462
+ Effect.map((config) => ({ type: "refreshed" as const, config })),
463
+ );
464
+ }),
465
+ Effect.tap(() =>
466
+ Interaction.success("Planetscale: OAuth credentials refreshed."),
467
+ ),
468
+ );
469
+ }),
470
+ ).pipe(
471
+ Effect.catchTag(
472
+ [
473
+ "OAuthError",
474
+ "OAuthNetworkError",
475
+ "OAuthServerUnavailable",
476
+ "OAuthInvalidResponse",
477
+ ],
478
+ () => Effect.succeed({ type: "browser" as const }),
479
+ ),
480
+ );
481
+ if (outcome.type === "browser") {
482
+ return yield* configureOAuth(profileName);
483
+ }
484
+ return outcome.config;
485
+ }),
486
+ ),
487
+ Match.exhaustive,
488
+ Effect.mapError((e) => AuthError.make({ message: "login failed", cause: e })),
489
+ );
490
+
491
+ const details = (
492
+ profileName: string,
493
+ config: PlanetscaleAuthConfig,
494
+ updateConfig?: (config: PlanetscaleAuthConfig) => Effect.Effect<void, AuthError>,
495
+ ) =>
496
+ Effect.all([
497
+ resolveCredentials(profileName, config, updateConfig),
498
+ Clock.currentTimeMillis,
499
+ ]).pipe(
500
+ Effect.map(([creds, now]): ProviderDetails => {
501
+ const source: PlanetscaleResolvedCredentials["source"] = creds.source;
502
+ const sourceStr = source.details ? `${source.type} - ${source.details}` : source.type;
503
+ return Match.value(creds).pipe(
504
+ Match.when({ type: "apiToken" }, (c) => ({
505
+ lines: [
506
+ { key: "tokenId", value: displayRedacted(c.tokenId, 3) },
507
+ { key: "token", value: displayRedacted(c.token, 6) },
508
+ { key: "organization", value: c.organization },
509
+ { key: "source", value: sourceStr },
510
+ ],
511
+ })),
512
+ Match.when({ type: "oauth" }, (c) => {
513
+ const remainingMs = c.expires - now;
514
+ const expiresAt = new Date(c.expires).toISOString();
515
+ const expiresStr =
516
+ remainingMs <= 0
517
+ ? `expired (${expiresAt})`
518
+ : `in ${Duration.format(Duration.millis(remainingMs))} (${expiresAt})`;
519
+ return {
520
+ lines: [
521
+ { key: "accessToken", value: displayRedacted(c.accessToken) },
522
+ { key: "expires", value: expiresStr },
523
+ { key: "organization", value: c.organization },
524
+ { key: "source", value: sourceStr },
525
+ ],
526
+ };
527
+ }),
528
+ Match.exhaustive,
529
+ );
530
+ }),
531
+ );
532
+
533
+ const readEnvironment = Effect.gen(function* () {
534
+ const tokenId = yield* getEnvRedactedRequired("PLANETSCALE_API_TOKEN_ID");
535
+ const token = yield* getEnvRedactedRequired("PLANETSCALE_API_TOKEN");
536
+ const organization = yield* getEnvRequired("PLANETSCALE_ORGANIZATION");
537
+ return {
538
+ type: "apiToken" as const,
539
+ tokenId,
540
+ token,
541
+ organization,
542
+ source: {
543
+ type: "env" as const,
544
+ details: "PLANETSCALE_API_TOKEN_ID/PLANETSCALE_API_TOKEN",
545
+ },
546
+ } satisfies PlanetscaleResolvedCredentials;
547
+ });
548
+
549
+ return {
550
+ configSchema: PlanetscaleAuthConfigSchema,
551
+ configure: configureCredentials,
552
+ configureWith,
553
+ configureMethods,
554
+ logout,
555
+ login,
556
+ details,
557
+ read: resolveCredentials,
558
+ readEnvironment,
559
+ environment: [
560
+ {
561
+ name: "PLANETSCALE_API_TOKEN_ID",
562
+ required: true,
563
+ description: "Service token id.",
564
+ },
565
+ {
566
+ name: "PLANETSCALE_API_TOKEN",
567
+ required: true,
568
+ secret: true,
569
+ description: "Service token secret.",
570
+ },
571
+ {
572
+ name: "PLANETSCALE_ORGANIZATION",
573
+ required: true,
574
+ description: "Organization URL slug.",
575
+ },
576
+ {
577
+ name: "PLANETSCALE_API_BASE_URL",
578
+ required: false,
579
+ description: "API base URL override.",
580
+ },
581
+ ],
582
+ };
583
+ }),
584
+ );