@alchemy.run/planetscale 2.0.0-beta.85 → 2.0.0-beta.86

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 (78) hide show
  1. package/lib/AuthProvider.d.ts.map +1 -1
  2. package/lib/AuthProvider.js.map +1 -1
  3. package/lib/Branch.d.ts +6 -1
  4. package/lib/Branch.d.ts.map +1 -1
  5. package/lib/Branch.js +21 -0
  6. package/lib/Branch.js.map +1 -1
  7. package/lib/Contributions.d.ts.map +1 -1
  8. package/lib/Contributions.js.map +1 -1
  9. package/lib/Credentials.d.ts.map +1 -1
  10. package/lib/Credentials.js.map +1 -1
  11. package/lib/Database.d.ts +7 -0
  12. package/lib/Database.d.ts.map +1 -1
  13. package/lib/Database.js.map +1 -1
  14. package/lib/MySQL/MySQLBranch.d.ts +26 -2
  15. package/lib/MySQL/MySQLBranch.d.ts.map +1 -1
  16. package/lib/MySQL/MySQLBranch.js +15 -1
  17. package/lib/MySQL/MySQLBranch.js.map +1 -1
  18. package/lib/MySQL/MySQLClusterSize.d.ts +1 -1
  19. package/lib/MySQL/MySQLClusterSize.d.ts.map +1 -1
  20. package/lib/MySQL/MySQLClusterSize.js.map +1 -1
  21. package/lib/MySQL/MySQLDatabase.d.ts +28 -2
  22. package/lib/MySQL/MySQLDatabase.d.ts.map +1 -1
  23. package/lib/MySQL/MySQLDatabase.js +71 -50
  24. package/lib/MySQL/MySQLDatabase.js.map +1 -1
  25. package/lib/MySQL/MySQLMigrations.d.ts.map +1 -1
  26. package/lib/MySQL/MySQLMigrations.js.map +1 -1
  27. package/lib/MySQL/MySQLOrigin.d.ts.map +1 -1
  28. package/lib/MySQL/MySQLOrigin.js.map +1 -1
  29. package/lib/MySQL/MySQLPassword.d.ts.map +1 -1
  30. package/lib/MySQL/MySQLPassword.js.map +1 -1
  31. package/lib/MySQL/index.d.ts.map +1 -1
  32. package/lib/MySQL/index.js.map +1 -1
  33. package/lib/OAuthClient.d.ts.map +1 -1
  34. package/lib/OAuthClient.js.map +1 -1
  35. package/lib/Postgres/PostgresBranch.d.ts +26 -2
  36. package/lib/Postgres/PostgresBranch.d.ts.map +1 -1
  37. package/lib/Postgres/PostgresBranch.js +15 -1
  38. package/lib/Postgres/PostgresBranch.js.map +1 -1
  39. package/lib/Postgres/PostgresClusterSize.d.ts.map +1 -1
  40. package/lib/Postgres/PostgresClusterSize.js.map +1 -1
  41. package/lib/Postgres/PostgresDatabase.d.ts +28 -2
  42. package/lib/Postgres/PostgresDatabase.d.ts.map +1 -1
  43. package/lib/Postgres/PostgresDatabase.js +75 -54
  44. package/lib/Postgres/PostgresDatabase.js.map +1 -1
  45. package/lib/Postgres/PostgresDefaultRole.d.ts.map +1 -1
  46. package/lib/Postgres/PostgresDefaultRole.js.map +1 -1
  47. package/lib/Postgres/PostgresMigrations.d.ts.map +1 -1
  48. package/lib/Postgres/PostgresMigrations.js.map +1 -1
  49. package/lib/Postgres/PostgresOrigin.d.ts.map +1 -1
  50. package/lib/Postgres/PostgresOrigin.js.map +1 -1
  51. package/lib/Postgres/PostgresRole.d.ts.map +1 -1
  52. package/lib/Postgres/PostgresRole.js.map +1 -1
  53. package/lib/Postgres/index.d.ts.map +1 -1
  54. package/lib/Postgres/index.js.map +1 -1
  55. package/lib/Providers.d.ts.map +1 -1
  56. package/lib/Providers.js +3 -1
  57. package/lib/Providers.js.map +1 -1
  58. package/lib/ServiceToken.d.ts +142 -0
  59. package/lib/ServiceToken.d.ts.map +1 -0
  60. package/lib/ServiceToken.js +268 -0
  61. package/lib/ServiceToken.js.map +1 -0
  62. package/lib/Util.d.ts +3 -3
  63. package/lib/Util.d.ts.map +1 -1
  64. package/lib/Util.js.map +1 -1
  65. package/lib/index.d.ts +1 -0
  66. package/lib/index.d.ts.map +1 -1
  67. package/lib/index.js +1 -0
  68. package/lib/index.js.map +1 -1
  69. package/package.json +9 -9
  70. package/src/Branch.ts +34 -1
  71. package/src/Database.ts +7 -0
  72. package/src/MySQL/MySQLBranch.ts +28 -2
  73. package/src/MySQL/MySQLDatabase.ts +102 -62
  74. package/src/Postgres/PostgresBranch.ts +28 -2
  75. package/src/Postgres/PostgresDatabase.ts +102 -61
  76. package/src/Providers.ts +3 -0
  77. package/src/ServiceToken.ts +431 -0
  78. package/src/index.ts +1 -0
@@ -0,0 +1,431 @@
1
+ import * as planetscale from "@distilled.cloud/planetscale";
2
+ import { Credentials } from "@distilled.cloud/planetscale/Credentials";
3
+ import { isResolved } from "alchemy/Diff";
4
+ import { createPhysicalName } from "alchemy/PhysicalName";
5
+ import * as Provider from "alchemy/Provider";
6
+ import { Resource } from "alchemy/Resource";
7
+ import * as Array from "effect/Array";
8
+ import * as Data from "effect/Data";
9
+ import * as Effect from "effect/Effect";
10
+ import * as Exit from "effect/Exit";
11
+ import * as Redacted from "effect/Redacted";
12
+ import * as Stream from "effect/Stream";
13
+ import type { MySQLDatabase } from "./MySQL/MySQLDatabase.ts";
14
+ import type { PostgresDatabase } from "./Postgres/PostgresDatabase.ts";
15
+ import type { Providers } from "./Providers.ts";
16
+
17
+ /** Database permissions supported by PlanetScale service tokens. */
18
+ export type ServiceTokenDatabaseAccess =
19
+ | "read_database"
20
+ | "write_database"
21
+ | "delete_database"
22
+ | "create_branch"
23
+ | "read_branch"
24
+ | "delete_branch"
25
+ | "delete_production_branch"
26
+ | "promote_branches"
27
+ | "demote_branches"
28
+ | "connect_branch"
29
+ | "connect_production_branch"
30
+ | "connect_production_read_only_branch"
31
+ | "create_branch_password"
32
+ | "create_production_branch_password"
33
+ | "create_production_read_only_branch_password"
34
+ | "delete_branch_password"
35
+ | "delete_production_branch_password"
36
+ | "delete_production_read_only_branch_password"
37
+ | "create_deploy_request"
38
+ | "read_deploy_request"
39
+ | "approve_deploy_request"
40
+ | "review_deploy_request"
41
+ | "create_comment"
42
+ | "read_comment"
43
+ | "read_backups"
44
+ | "write_backups"
45
+ | "delete_backups"
46
+ | "delete_production_branch_backups"
47
+ | "restore_backup"
48
+ | "restore_production_branch_backup"
49
+ | "write_branch_vschema"
50
+ | "write_production_branch_vschema"
51
+ | "write_branch_topology"
52
+ | "write_production_branch_topology"
53
+ | "read_workflow"
54
+ | "write_workflow"
55
+ | "delete_workflow";
56
+
57
+ /** Properties for a service token scoped to one PlanetScale database. */
58
+ export interface ServiceTokenProps {
59
+ /** Name prefix. An instance suffix allows old and replacement tokens to coexist. */
60
+ name?: string;
61
+ /**
62
+ * Organization slug. Defaults to the database resource's organization, then
63
+ * to the configured PlanetScale organization.
64
+ */
65
+ organization?: string;
66
+ /**
67
+ * The database the token is scoped to: its name, a {@link MySQLDatabase} or
68
+ * {@link PostgresDatabase} resource, or a `.ref(...)` to one deployed
69
+ * elsewhere. Changing it replaces the token.
70
+ */
71
+ database: string | MySQLDatabase | PostgresDatabase;
72
+ /**
73
+ * Exact database permissions. Permissions not declared here are revoked.
74
+ * PlanetScale also accepts the Vitess-only accesses (`*_vschema`,
75
+ * `*_topology`, `*_workflow`) on a Postgres database.
76
+ */
77
+ accesses: readonly ServiceTokenDatabaseAccess[];
78
+ }
79
+
80
+ /** Output attributes of a deployed PlanetScale service token. */
81
+ export interface ServiceTokenAttributes {
82
+ /** Service token ID. */
83
+ id: string;
84
+ /** Physical token name, including the instance suffix. */
85
+ name: string;
86
+ /** Organization that owns the token. */
87
+ organization: string;
88
+ /** Database to which the token is scoped. */
89
+ database: string;
90
+ /** Granted database permissions. */
91
+ accesses: readonly ServiceTokenDatabaseAccess[];
92
+ /** Creation-only secret, retained in Alchemy state for subsequent deploys. */
93
+ token: Redacted.Redacted<string>;
94
+ }
95
+
96
+ export type ServiceToken = Resource<
97
+ "Planetscale.ServiceToken",
98
+ ServiceTokenProps,
99
+ ServiceTokenAttributes,
100
+ never,
101
+ Providers
102
+ >;
103
+
104
+ /**
105
+ * A PlanetScale API service token with exactly the declared permissions on one database.
106
+ *
107
+ * The deployment identity must be allowed to create service tokens and manage
108
+ * their accesses. These API credentials are separate from database passwords.
109
+ *
110
+ * PlanetScale returns the secret only at creation. Alchemy preserves it in
111
+ * state; use a state backend that protects secrets. Existing tokens cannot be
112
+ * adopted because their secret cannot be recovered.
113
+ *
114
+ * Service tokens carry no labels or tags, so a token's physical name, which
115
+ * holds this resource's instance ID, is its only ownership marker. If a
116
+ * create is interrupted before Alchemy saves the token, the next destroy
117
+ * finds it by that name and revokes it, and the next deploy revokes it and
118
+ * mints a replacement, since its secret was never saved. `list` returns
119
+ * nothing: a name alone can't prove which stack made a token, and listing
120
+ * every token would offer an organization's other tokens to account-wide
121
+ * cleanup.
122
+ *
123
+ * Changing the name prefix, organization, or database creates a replacement.
124
+ * Resources that consume the token's outputs are updated before Alchemy deletes
125
+ * the previous token. Changes to `accesses` are reconciled in place.
126
+ *
127
+ * ### Creating a Database Token
128
+ * **Example:** Token for a database declared in the stack
129
+ * ```typescript
130
+ * const database = yield* Planetscale.MySQLDatabase("Database", {
131
+ * clusterSize: "PS_10",
132
+ * });
133
+ *
134
+ * const token = yield* Planetscale.ServiceToken("Sync", {
135
+ * database,
136
+ * accesses: ["read_database", "write_database"],
137
+ * });
138
+ * ```
139
+ *
140
+ * **Example:** Token for a Postgres database
141
+ * ```typescript
142
+ * const database = yield* Planetscale.PostgresDatabase("Database", {
143
+ * clusterSize: "PS_10",
144
+ * });
145
+ *
146
+ * const token = yield* Planetscale.ServiceToken("Backups", {
147
+ * database,
148
+ * accesses: ["read_backups", "write_backups"],
149
+ * });
150
+ * ```
151
+ *
152
+ * **Example:** Token for an existing database by name
153
+ * ```typescript
154
+ * const token = yield* Planetscale.ServiceToken("Sync", {
155
+ * database: "app-db",
156
+ * accesses: ["read_database"],
157
+ * });
158
+ * ```
159
+ *
160
+ * ### Referencing a Database from Another Stack
161
+ * **Example:** Token for a database another stack owns
162
+ * ```typescript
163
+ * const database = yield* Planetscale.MySQLDatabase.ref("Database", {
164
+ * stack: "shared-data",
165
+ * });
166
+ *
167
+ * const token = yield* Planetscale.ServiceToken("Sync", {
168
+ * database,
169
+ * accesses: ["read_deploy_request", "create_deploy_request"],
170
+ * });
171
+ * ```
172
+ *
173
+ * ### Rotating a Token
174
+ * **Example:** Rename the token to mint a replacement
175
+ * ```typescript
176
+ * // Changing `name` creates a new token; consumers are updated
177
+ * // before the previous token is revoked.
178
+ * const token = yield* Planetscale.ServiceToken("Sync", {
179
+ * name: "sync-2026-11",
180
+ * database,
181
+ * accesses: ["read_database"],
182
+ * });
183
+ * ```
184
+ *
185
+ * @resource
186
+ */
187
+ export const ServiceToken = Resource<ServiceToken>("Planetscale.ServiceToken");
188
+
189
+ /** A service token cannot satisfy the declared ownership or access contract. */
190
+ export class ServiceTokenError extends Data.TaggedError("ServiceTokenError")<{
191
+ readonly message: string;
192
+ }> {}
193
+
194
+ export const ServiceTokenProvider = () =>
195
+ Provider.succeed(ServiceToken, {
196
+ list: () => Effect.succeed([]),
197
+ read: Effect.fn(function* ({ id, instanceId, olds, output }) {
198
+ if (output) {
199
+ const live = yield* getToken(output.organization, output.id);
200
+ return live ? { ...output, name: live.name ?? output.name } : undefined;
201
+ }
202
+ // An interrupted create leaves a token whose attributes were never
203
+ // saved. Its physical name ends with this instance's suffix, whatever
204
+ // prefix it was made with, so it is this resource's own: report it,
205
+ // with an empty placeholder for the secret that was lost, so destroy
206
+ // can revoke it.
207
+ if (!olds) return undefined;
208
+ const organization = yield* organizationOf(olds);
209
+ const [live] = yield* instanceTokens(organization, id, instanceId);
210
+ return live
211
+ ? {
212
+ id: live.id,
213
+ name: live.name,
214
+ organization,
215
+ database: databaseName(olds.database),
216
+ accesses: ordered(olds.accesses),
217
+ token: Redacted.make(""),
218
+ }
219
+ : undefined;
220
+ }),
221
+ diff: Effect.fn(function* ({ id, instanceId, olds, news, output }) {
222
+ if (!output) return undefined;
223
+ const prefix = "name" in news ? news.name : undefined;
224
+ const org = "organization" in news ? news.organization : undefined;
225
+ // A computed identity can resolve to a different database after apply.
226
+ // Replace first so a failed dependent update keeps the old token usable.
227
+ if (
228
+ !("database" in news) ||
229
+ !isResolved(news.database) ||
230
+ !isResolved(prefix) ||
231
+ !isResolved(org)
232
+ ) {
233
+ return { action: "replace" } as const;
234
+ }
235
+ const organization =
236
+ org ?? databaseOrganization(news.database) ?? (yield* yield* Credentials).organization;
237
+ const database = databaseName(news.database);
238
+ const name = yield* tokenName(id, instanceId, prefix);
239
+ if (
240
+ output.organization !== organization ||
241
+ output.database !== database ||
242
+ output.name !== name ||
243
+ (olds && olds.name !== prefix)
244
+ ) {
245
+ return { action: "replace" } as const;
246
+ }
247
+ const live = yield* getToken(output.organization, output.id);
248
+ if (!live || live.name !== name) return { action: "replace" } as const;
249
+ if (!("accesses" in news) || !isResolved(news.accesses)) {
250
+ return { action: "update" } as const;
251
+ }
252
+ return exactAccess(
253
+ (yield* planetscale.listServiceTokenAccesses({ organization, id: output.id })).data,
254
+ database,
255
+ news.accesses,
256
+ )
257
+ ? undefined
258
+ : ({ action: "update" } as const);
259
+ }),
260
+ reconcile: Effect.fn(function* ({ id, instanceId, news, output }) {
261
+ const organization = yield* organizationOf(news);
262
+ const database = databaseName(news.database);
263
+ const name = yield* tokenName(id, instanceId, news.name);
264
+ let observed = output ? yield* getToken(output.organization, output.id) : undefined;
265
+
266
+ // A live token whose secret was never saved can't reach its consumers,
267
+ // who never received it either: revoke it and mint a replacement.
268
+ if (observed && output && Redacted.value(output.token) === "") {
269
+ yield* deleteToken(output.organization, output.id);
270
+ observed = undefined;
271
+ } else if (!observed) {
272
+ for (const orphan of yield* instanceTokens(organization, id, instanceId)) {
273
+ yield* deleteToken(organization, orphan.id);
274
+ }
275
+ }
276
+
277
+ return yield* Effect.acquireUseRelease(
278
+ observed
279
+ ? Effect.succeed(observed)
280
+ : planetscale
281
+ .createServiceToken({ organization, name })
282
+ // A retried create can lose the only response containing the secret.
283
+ .pipe(planetscale.Retry.none),
284
+ Effect.fn(function* (live) {
285
+ const token = observed ? output?.token : live.token;
286
+ if (!token) {
287
+ return yield* new ServiceTokenError({
288
+ message: `The creation-only secret for service token ${live.id} is unavailable.`,
289
+ });
290
+ }
291
+ yield* ensureAccess(organization, live.id, database, news.accesses);
292
+ return {
293
+ id: live.id,
294
+ name: live.name ?? name,
295
+ organization,
296
+ database,
297
+ accesses: ordered(news.accesses),
298
+ token,
299
+ };
300
+ }),
301
+ (live, exit) =>
302
+ !observed && Exit.isFailure(exit) ? deleteToken(organization, live.id) : Effect.void,
303
+ );
304
+ }),
305
+ delete: Effect.fn(function* ({ output }) {
306
+ yield* deleteToken(output.organization, output.id);
307
+ }),
308
+ });
309
+
310
+ // Resource references in props reach the provider as the database's
311
+ // attributes, so a database prop is either a name or `{ name, organization }`.
312
+ type DatabaseInput = string | { readonly name: string; readonly organization?: string };
313
+
314
+ const databaseName = (database: ServiceTokenProps["database"]) => {
315
+ const input = database as unknown as DatabaseInput;
316
+ return typeof input === "string" ? input : input.name;
317
+ };
318
+
319
+ const databaseOrganization = (database: ServiceTokenProps["database"]) => {
320
+ const input = database as unknown as DatabaseInput;
321
+ return typeof input === "string" ? undefined : input.organization;
322
+ };
323
+
324
+ const organizationOf = Effect.fnUntraced(function* (props: ServiceTokenProps) {
325
+ return (
326
+ props.organization ??
327
+ databaseOrganization(props.database) ??
328
+ (yield* yield* Credentials).organization
329
+ );
330
+ });
331
+
332
+ const tokenName = (id: string, instanceId: string, prefix: string | undefined) =>
333
+ createPhysicalName({ id, instanceId, prefix: prefix === undefined ? undefined : `${prefix}-` });
334
+
335
+ const ordered = <A extends string>(accesses: readonly A[]) => Array.dedupe(accesses).toSorted();
336
+
337
+ const getToken = (organization: string, id: string) =>
338
+ planetscale
339
+ .getServiceToken({ organization, id })
340
+ .pipe(Effect.catchTag("NotFound", () => Effect.undefined));
341
+
342
+ /**
343
+ * The organization's tokens made by this resource instance: their names end
344
+ * with its 80-bit instance suffix, whatever prefix each was made with (a
345
+ * truncated name keeps the suffix last, after a hash, with no delimiter).
346
+ */
347
+ const instanceTokens = Effect.fnUntraced(function* (
348
+ organization: string,
349
+ id: string,
350
+ instanceId: string,
351
+ ) {
352
+ const suffix = yield* createPhysicalName({ id, instanceId, prefix: "" });
353
+ return yield* planetscale.listServiceTokens.items({ organization }).pipe(
354
+ Stream.runCollect,
355
+ Effect.map((tokens) =>
356
+ Array.fromIterable(tokens).flatMap(({ id, name }) =>
357
+ name?.endsWith(suffix) ? [{ id, name }] : [],
358
+ ),
359
+ ),
360
+ );
361
+ });
362
+
363
+ const deleteToken = (organization: string, id: string) =>
364
+ planetscale
365
+ .deleteServiceToken({ organization, id })
366
+ .pipe(Effect.catchTag("NotFound", () => Effect.void));
367
+
368
+ const exactAccess = (
369
+ current: planetscale.ListServiceTokenAccessesOutput["data"],
370
+ database: string,
371
+ desired: readonly string[],
372
+ ) =>
373
+ current.length === ordered(desired).length &&
374
+ desired.every((access) => current.some((entry) => entry.access === access)) &&
375
+ current.every(
376
+ (entry) =>
377
+ entry.resource.type === "Database" &&
378
+ entry.resource.name === database &&
379
+ desired.includes(entry.access),
380
+ );
381
+
382
+ const ensureAccess = Effect.fn(function* (
383
+ organization: string,
384
+ id: string,
385
+ database: string,
386
+ accesses: readonly ServiceTokenDatabaseAccess[],
387
+ ) {
388
+ const current = (yield* planetscale.listServiceTokenAccesses({ organization, id })).data;
389
+ for (const entry of current) {
390
+ if (
391
+ entry.resource.type !== "Database" ||
392
+ entry.resource.name !== database ||
393
+ !accesses.some((access) => access === entry.access)
394
+ ) {
395
+ yield* planetscale.removeServiceTokenAccess({
396
+ organization,
397
+ id,
398
+ database: entry.resource.type === "Database" ? entry.resource.name : "",
399
+ access: [entry.access],
400
+ });
401
+ }
402
+ }
403
+ const missing = ordered(accesses).filter(
404
+ (access) =>
405
+ !current.some(
406
+ (entry) =>
407
+ entry.resource.type === "Database" &&
408
+ entry.resource.name === database &&
409
+ entry.access === access,
410
+ ),
411
+ );
412
+ if (missing.length > 0) {
413
+ yield* planetscale.addServiceTokenAccess({
414
+ organization,
415
+ id,
416
+ database: database,
417
+ access: missing,
418
+ });
419
+ }
420
+ if (
421
+ !exactAccess(
422
+ (yield* planetscale.listServiceTokenAccesses({ organization, id })).data,
423
+ database,
424
+ accesses,
425
+ )
426
+ ) {
427
+ return yield* new ServiceTokenError({
428
+ message: `Service token ${id} did not converge to its declared database permissions.`,
429
+ });
430
+ }
431
+ });
package/src/index.ts CHANGED
@@ -11,4 +11,5 @@ export * from "./Database.ts";
11
11
  export * from "./MySQL/index.ts";
12
12
  export * from "./Postgres/index.ts";
13
13
  export * from "./Providers.ts";
14
+ export * from "./ServiceToken.ts";
14
15
  export * from "./Util.ts";