@alchemy.run/planetscale 2.0.0-beta.84 → 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 (102) hide show
  1. package/lib/AuthProvider.d.ts +1 -1
  2. package/lib/AuthProvider.d.ts.map +1 -1
  3. package/lib/AuthProvider.js +58 -47
  4. package/lib/AuthProvider.js.map +1 -1
  5. package/lib/Branch.d.ts +6 -1
  6. package/lib/Branch.d.ts.map +1 -1
  7. package/lib/Branch.js +23 -2
  8. package/lib/Branch.js.map +1 -1
  9. package/lib/Contributions.d.ts +1 -1
  10. package/lib/Contributions.d.ts.map +1 -1
  11. package/lib/Contributions.js.map +1 -1
  12. package/lib/Credentials.d.ts +1 -1
  13. package/lib/Credentials.d.ts.map +1 -1
  14. package/lib/Credentials.js +6 -12
  15. package/lib/Credentials.js.map +1 -1
  16. package/lib/Database.d.ts +7 -0
  17. package/lib/Database.d.ts.map +1 -1
  18. package/lib/Database.js.map +1 -1
  19. package/lib/MySQL/MySQLBranch.d.ts +26 -2
  20. package/lib/MySQL/MySQLBranch.d.ts.map +1 -1
  21. package/lib/MySQL/MySQLBranch.js +15 -1
  22. package/lib/MySQL/MySQLBranch.js.map +1 -1
  23. package/lib/MySQL/MySQLClusterSize.d.ts +1 -1
  24. package/lib/MySQL/MySQLClusterSize.d.ts.map +1 -1
  25. package/lib/MySQL/MySQLClusterSize.js +1 -1
  26. package/lib/MySQL/MySQLClusterSize.js.map +1 -1
  27. package/lib/MySQL/MySQLDatabase.d.ts +28 -2
  28. package/lib/MySQL/MySQLDatabase.d.ts.map +1 -1
  29. package/lib/MySQL/MySQLDatabase.js +72 -51
  30. package/lib/MySQL/MySQLDatabase.js.map +1 -1
  31. package/lib/MySQL/MySQLMigrations.d.ts +7 -11
  32. package/lib/MySQL/MySQLMigrations.d.ts.map +1 -1
  33. package/lib/MySQL/MySQLMigrations.js +24 -58
  34. package/lib/MySQL/MySQLMigrations.js.map +1 -1
  35. package/lib/MySQL/MySQLOrigin.d.ts.map +1 -1
  36. package/lib/MySQL/MySQLOrigin.js.map +1 -1
  37. package/lib/MySQL/MySQLPassword.d.ts.map +1 -1
  38. package/lib/MySQL/MySQLPassword.js +2 -2
  39. package/lib/MySQL/MySQLPassword.js.map +1 -1
  40. package/lib/MySQL/index.d.ts.map +1 -1
  41. package/lib/MySQL/index.js.map +1 -1
  42. package/lib/OAuthClient.d.ts.map +1 -1
  43. package/lib/OAuthClient.js.map +1 -1
  44. package/lib/Postgres/PostgresBranch.d.ts +26 -2
  45. package/lib/Postgres/PostgresBranch.d.ts.map +1 -1
  46. package/lib/Postgres/PostgresBranch.js +15 -1
  47. package/lib/Postgres/PostgresBranch.js.map +1 -1
  48. package/lib/Postgres/PostgresClusterSize.d.ts.map +1 -1
  49. package/lib/Postgres/PostgresClusterSize.js.map +1 -1
  50. package/lib/Postgres/PostgresDatabase.d.ts +28 -2
  51. package/lib/Postgres/PostgresDatabase.d.ts.map +1 -1
  52. package/lib/Postgres/PostgresDatabase.js +77 -56
  53. package/lib/Postgres/PostgresDatabase.js.map +1 -1
  54. package/lib/Postgres/PostgresDefaultRole.d.ts.map +1 -1
  55. package/lib/Postgres/PostgresDefaultRole.js +2 -2
  56. package/lib/Postgres/PostgresDefaultRole.js.map +1 -1
  57. package/lib/Postgres/PostgresMigrations.d.ts +3 -11
  58. package/lib/Postgres/PostgresMigrations.d.ts.map +1 -1
  59. package/lib/Postgres/PostgresMigrations.js +15 -66
  60. package/lib/Postgres/PostgresMigrations.js.map +1 -1
  61. package/lib/Postgres/PostgresOrigin.d.ts.map +1 -1
  62. package/lib/Postgres/PostgresOrigin.js.map +1 -1
  63. package/lib/Postgres/PostgresRole.d.ts.map +1 -1
  64. package/lib/Postgres/PostgresRole.js +3 -3
  65. package/lib/Postgres/PostgresRole.js.map +1 -1
  66. package/lib/Postgres/index.d.ts.map +1 -1
  67. package/lib/Postgres/index.js.map +1 -1
  68. package/lib/Providers.d.ts +1 -1
  69. package/lib/Providers.d.ts.map +1 -1
  70. package/lib/Providers.js +3 -1
  71. package/lib/Providers.js.map +1 -1
  72. package/lib/ServiceToken.d.ts +142 -0
  73. package/lib/ServiceToken.d.ts.map +1 -0
  74. package/lib/ServiceToken.js +268 -0
  75. package/lib/ServiceToken.js.map +1 -0
  76. package/lib/Util.d.ts +3 -3
  77. package/lib/Util.d.ts.map +1 -1
  78. package/lib/Util.js +1 -1
  79. package/lib/Util.js.map +1 -1
  80. package/lib/index.d.ts +1 -0
  81. package/lib/index.d.ts.map +1 -1
  82. package/lib/index.js +1 -0
  83. package/lib/index.js.map +1 -1
  84. package/package.json +9 -9
  85. package/src/AuthProvider.ts +91 -78
  86. package/src/Branch.ts +36 -3
  87. package/src/Credentials.ts +8 -32
  88. package/src/Database.ts +7 -0
  89. package/src/MySQL/MySQLBranch.ts +28 -2
  90. package/src/MySQL/MySQLClusterSize.ts +1 -1
  91. package/src/MySQL/MySQLDatabase.ts +103 -63
  92. package/src/MySQL/MySQLMigrations.ts +31 -80
  93. package/src/MySQL/MySQLPassword.ts +2 -2
  94. package/src/Postgres/PostgresBranch.ts +28 -2
  95. package/src/Postgres/PostgresDatabase.ts +104 -63
  96. package/src/Postgres/PostgresDefaultRole.ts +2 -2
  97. package/src/Postgres/PostgresMigrations.ts +20 -91
  98. package/src/Postgres/PostgresRole.ts +4 -4
  99. package/src/Providers.ts +3 -0
  100. package/src/ServiceToken.ts +431 -0
  101. package/src/Util.ts +1 -1
  102. package/src/index.ts +1 -0
@@ -1,36 +1,19 @@
1
1
  import {
2
- makePgMigrationExecutor,
3
2
  runMigrations,
3
+ withPgExecutor,
4
4
  type NormalizedMigrationsInput,
5
+ type SqlExecutor,
5
6
  type StampedMigrationsState,
6
7
  } from "@alchemy.run/sql/Migrations";
7
- import { readSqlFile } from "@alchemy.run/sql/SqlFile";
8
+ import { stripSslQueryParams } from "@alchemy.run/sql/PostgresDriver";
9
+ import { applySqlImports } from "@alchemy.run/sql/SqlFile";
8
10
  import * as ps from "@distilled.cloud/planetscale";
9
- import * as Data from "effect/Data";
10
11
  import * as Effect from "effect/Effect";
11
12
  import * as Redacted from "effect/Redacted";
12
13
  import * as Schedule from "effect/Schedule";
13
- import type { Client } from "pg";
14
-
15
- // `pg` is an optional peer dependency — loaded lazily so importing the
16
- // Planetscale provider never requires the driver unless migrations run.
17
- const importPg = () =>
18
- import("pg").catch((cause) => {
19
- throw new Error(
20
- "Failed to load the 'pg' driver. Install the optional peer dependency 'pg' to run Planetscale Postgres migrations.",
21
- { cause },
22
- );
23
- });
24
14
 
25
15
  const MIGRATION_ROLE_TTL_SECONDS = 600;
26
16
 
27
- export class PostgresMigrationError extends Data.TaggedError(
28
- "Planetscale::PostgresMigrationError",
29
- )<{
30
- message: string;
31
- cause?: unknown;
32
- }> {}
33
-
34
17
  export interface PostgresMigrationTarget {
35
18
  organization: string;
36
19
  database: string;
@@ -49,67 +32,36 @@ export const runPostgresMigrations = (
49
32
  runMigrations({
50
33
  input,
51
34
  stamped,
52
- withExecutor: (apply) =>
53
- withPostgresClient(target, (client) => apply(makePgMigrationExecutor(client))),
35
+ withExecutor: (apply) => withPostgresExecutor(target, apply),
54
36
  });
55
37
 
38
+ /** Apply `importFiles`, each once and again only when it changes. */
56
39
  export const runPostgresImports = (
57
40
  target: PostgresMigrationTarget,
58
41
  importFiles: ReadonlyArray<string>,
59
42
  rootDir: string,
60
43
  previous: Record<string, string>,
61
44
  ) =>
62
- Effect.gen(function* () {
63
- const hashes: Record<string, string> = { ...previous };
64
- for (const filePath of importFiles) {
65
- const file = yield* readSqlFile(rootDir, filePath);
66
- if (previous[filePath] === file.hash) {
67
- hashes[filePath] = file.hash;
68
- continue;
69
- }
70
- yield* runPostgresSql(target, file.sql);
71
- hashes[filePath] = file.hash;
72
- }
73
- const tracked = new Set(importFiles);
74
- for (const key of Object.keys(hashes)) {
75
- if (!tracked.has(key)) delete hashes[key];
76
- }
77
- return hashes;
45
+ applySqlImports({
46
+ importFiles,
47
+ rootDir,
48
+ previous,
49
+ apply: (file) =>
50
+ withPostgresExecutor(target, (executor) => executor.query(file.sql)).pipe(Effect.asVoid),
78
51
  });
79
52
 
80
- const runPostgresSql = (target: PostgresMigrationTarget, sql: string) =>
81
- withPostgresClient(target, (client) => pgExec(client, sql));
82
-
83
- const withPostgresClient = <A, E, R>(
53
+ /** A verified-TLS `pg` session as a short-lived migration role. */
54
+ const withPostgresExecutor = <A, E, R>(
84
55
  target: PostgresMigrationTarget,
85
- use: (client: Client) => Effect.Effect<A, E, R>,
56
+ use: (executor: SqlExecutor) => Effect.Effect<A, E, R>,
86
57
  ) =>
87
58
  withTemporaryPostgresRole(target, (role) =>
88
- Effect.acquireUseRelease(
89
- Effect.gen(function* () {
90
- const { Client } = yield* Effect.tryPromise({
91
- try: importPg,
92
- catch: toMigrationError,
93
- });
94
- const client = yield* Effect.sync(
95
- () =>
96
- new Client({
97
- connectionString: role.connectionUrl.pipe(Redacted.value, stripPgSslQueryParams),
98
- ssl: { rejectUnauthorized: true },
99
- }),
100
- );
101
- yield* Effect.tryPromise({
102
- try: () => client.connect(),
103
- catch: toMigrationError,
104
- });
105
- return client;
106
- }),
59
+ withPgExecutor(
60
+ {
61
+ connectionString: role.connectionUrl.pipe(Redacted.value, stripSslQueryParams),
62
+ ssl: { rejectUnauthorized: true },
63
+ },
107
64
  use,
108
- (client) =>
109
- Effect.tryPromise({
110
- try: () => client.end(),
111
- catch: toMigrationError,
112
- }).pipe(Effect.ignore),
113
65
  ),
114
66
  );
115
67
 
@@ -168,26 +120,3 @@ const withTemporaryPostgresRole = <A, E, R>(
168
120
  ),
169
121
  ),
170
122
  );
171
-
172
- const pgExec = (client: Client, sql: string, values?: ReadonlyArray<unknown>) =>
173
- Effect.tryPromise({
174
- try: () => client.query(sql, values as Array<unknown>).then(() => undefined),
175
- catch: toMigrationError,
176
- });
177
-
178
- const toMigrationError = (cause: unknown) =>
179
- new PostgresMigrationError({
180
- message: cause instanceof Error ? cause.message : String(cause),
181
- cause,
182
- });
183
-
184
- const stripPgSslQueryParams = (uri: string): string => {
185
- try {
186
- const url = new URL(uri);
187
- url.searchParams.delete("sslmode");
188
- url.searchParams.delete("channel_binding");
189
- return url.toString();
190
- } catch {
191
- return uri;
192
- }
193
- };
@@ -251,8 +251,8 @@ export const PostgresRoleProvider = () =>
251
251
  }),
252
252
  ),
253
253
  Effect.catchTags({
254
- Forbidden: () => Effect.succeed(undefined),
255
- NotFound: () => Effect.succeed(undefined),
254
+ Forbidden: () => Effect.undefined,
255
+ NotFound: () => Effect.undefined,
256
256
  }),
257
257
  );
258
258
  }),
@@ -277,7 +277,7 @@ export const PostgresRoleProvider = () =>
277
277
  organization,
278
278
  database: databaseName,
279
279
  })
280
- .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)))
280
+ .pipe(Effect.catchTag("NotFound", () => Effect.undefined))
281
281
  : undefined;
282
282
 
283
283
  // 2. Ensure — create if missing. Single branch covers both
@@ -292,7 +292,7 @@ export const PostgresRoleProvider = () =>
292
292
  // once on create; on an observe-hit we re-use the cached
293
293
  // plaintext from output (which is why adoption requires
294
294
  // pre-existing state — see `read`).
295
- let live: planetscale.GetRoleOutput | planetscale.CreateRoleOutput;
295
+ let live: planetscale.GetRoleOutput;
296
296
  let plaintext: Redacted.Redacted<string>;
297
297
  if (observed) {
298
298
  live = observed;
package/src/Providers.ts CHANGED
@@ -15,6 +15,7 @@ import {
15
15
  PostgresDefaultRoleProvider,
16
16
  } from "./Postgres/PostgresDefaultRole.ts";
17
17
  import { PostgresRole, PostgresRoleProvider } from "./Postgres/PostgresRole.ts";
18
+ import { ServiceToken, ServiceTokenProvider } from "./ServiceToken.ts";
18
19
 
19
20
  /**
20
21
  * Service tag bundling all PlanetScale providers + auth + credentials. Use
@@ -45,6 +46,7 @@ export const providers = () =>
45
46
  MySQLPassword,
46
47
  PostgresRole,
47
48
  PostgresDefaultRole,
49
+ ServiceToken,
48
50
  ]),
49
51
  ).pipe(
50
52
  Layer.provide(
@@ -56,6 +58,7 @@ export const providers = () =>
56
58
  MySQLPasswordProvider(),
57
59
  PostgresRoleProvider(),
58
60
  PostgresDefaultRoleProvider(),
61
+ ServiceTokenProvider(),
59
62
  ),
60
63
  ),
61
64
  Layer.provideMerge(Credentials.fromAuthProvider()),
@@ -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/Util.ts CHANGED
@@ -48,7 +48,7 @@ export const deleteUnprotectedDatabase = Effect.fn(function* (
48
48
  ) {
49
49
  const live = yield* ps
50
50
  .getDatabase({ organization, database })
51
- .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
51
+ .pipe(Effect.catchTag("NotFound", () => Effect.undefined));
52
52
  if (!live) return;
53
53
  if (live.deletion_protected) {
54
54
  return yield* new PlanetscaleDeletionProtected({
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";