@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.
- package/lib/AuthProvider.d.ts +1 -1
- package/lib/AuthProvider.d.ts.map +1 -1
- package/lib/AuthProvider.js +58 -47
- package/lib/AuthProvider.js.map +1 -1
- package/lib/Branch.d.ts +6 -1
- package/lib/Branch.d.ts.map +1 -1
- package/lib/Branch.js +23 -2
- package/lib/Branch.js.map +1 -1
- package/lib/Contributions.d.ts +1 -1
- package/lib/Contributions.d.ts.map +1 -1
- package/lib/Contributions.js.map +1 -1
- package/lib/Credentials.d.ts +1 -1
- package/lib/Credentials.d.ts.map +1 -1
- package/lib/Credentials.js +6 -12
- package/lib/Credentials.js.map +1 -1
- package/lib/Database.d.ts +7 -0
- package/lib/Database.d.ts.map +1 -1
- package/lib/Database.js.map +1 -1
- package/lib/MySQL/MySQLBranch.d.ts +26 -2
- package/lib/MySQL/MySQLBranch.d.ts.map +1 -1
- package/lib/MySQL/MySQLBranch.js +15 -1
- package/lib/MySQL/MySQLBranch.js.map +1 -1
- package/lib/MySQL/MySQLClusterSize.d.ts +1 -1
- package/lib/MySQL/MySQLClusterSize.d.ts.map +1 -1
- package/lib/MySQL/MySQLClusterSize.js +1 -1
- package/lib/MySQL/MySQLClusterSize.js.map +1 -1
- package/lib/MySQL/MySQLDatabase.d.ts +28 -2
- package/lib/MySQL/MySQLDatabase.d.ts.map +1 -1
- package/lib/MySQL/MySQLDatabase.js +72 -51
- package/lib/MySQL/MySQLDatabase.js.map +1 -1
- package/lib/MySQL/MySQLMigrations.d.ts +7 -11
- package/lib/MySQL/MySQLMigrations.d.ts.map +1 -1
- package/lib/MySQL/MySQLMigrations.js +24 -58
- package/lib/MySQL/MySQLMigrations.js.map +1 -1
- package/lib/MySQL/MySQLOrigin.d.ts.map +1 -1
- package/lib/MySQL/MySQLOrigin.js.map +1 -1
- package/lib/MySQL/MySQLPassword.d.ts.map +1 -1
- package/lib/MySQL/MySQLPassword.js +2 -2
- package/lib/MySQL/MySQLPassword.js.map +1 -1
- package/lib/MySQL/index.d.ts.map +1 -1
- package/lib/MySQL/index.js.map +1 -1
- package/lib/OAuthClient.d.ts.map +1 -1
- package/lib/OAuthClient.js.map +1 -1
- package/lib/Postgres/PostgresBranch.d.ts +26 -2
- package/lib/Postgres/PostgresBranch.d.ts.map +1 -1
- package/lib/Postgres/PostgresBranch.js +15 -1
- package/lib/Postgres/PostgresBranch.js.map +1 -1
- package/lib/Postgres/PostgresClusterSize.d.ts.map +1 -1
- package/lib/Postgres/PostgresClusterSize.js.map +1 -1
- package/lib/Postgres/PostgresDatabase.d.ts +28 -2
- package/lib/Postgres/PostgresDatabase.d.ts.map +1 -1
- package/lib/Postgres/PostgresDatabase.js +77 -56
- package/lib/Postgres/PostgresDatabase.js.map +1 -1
- package/lib/Postgres/PostgresDefaultRole.d.ts.map +1 -1
- package/lib/Postgres/PostgresDefaultRole.js +2 -2
- package/lib/Postgres/PostgresDefaultRole.js.map +1 -1
- package/lib/Postgres/PostgresMigrations.d.ts +3 -11
- package/lib/Postgres/PostgresMigrations.d.ts.map +1 -1
- package/lib/Postgres/PostgresMigrations.js +15 -66
- package/lib/Postgres/PostgresMigrations.js.map +1 -1
- package/lib/Postgres/PostgresOrigin.d.ts.map +1 -1
- package/lib/Postgres/PostgresOrigin.js.map +1 -1
- package/lib/Postgres/PostgresRole.d.ts.map +1 -1
- package/lib/Postgres/PostgresRole.js +3 -3
- package/lib/Postgres/PostgresRole.js.map +1 -1
- package/lib/Postgres/index.d.ts.map +1 -1
- package/lib/Postgres/index.js.map +1 -1
- package/lib/Providers.d.ts +1 -1
- package/lib/Providers.d.ts.map +1 -1
- package/lib/Providers.js +3 -1
- package/lib/Providers.js.map +1 -1
- package/lib/ServiceToken.d.ts +142 -0
- package/lib/ServiceToken.d.ts.map +1 -0
- package/lib/ServiceToken.js +268 -0
- package/lib/ServiceToken.js.map +1 -0
- package/lib/Util.d.ts +3 -3
- package/lib/Util.d.ts.map +1 -1
- package/lib/Util.js +1 -1
- package/lib/Util.js.map +1 -1
- package/lib/index.d.ts +1 -0
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +1 -0
- package/lib/index.js.map +1 -1
- package/package.json +9 -9
- package/src/AuthProvider.ts +91 -78
- package/src/Branch.ts +36 -3
- package/src/Credentials.ts +8 -32
- package/src/Database.ts +7 -0
- package/src/MySQL/MySQLBranch.ts +28 -2
- package/src/MySQL/MySQLClusterSize.ts +1 -1
- package/src/MySQL/MySQLDatabase.ts +103 -63
- package/src/MySQL/MySQLMigrations.ts +31 -80
- package/src/MySQL/MySQLPassword.ts +2 -2
- package/src/Postgres/PostgresBranch.ts +28 -2
- package/src/Postgres/PostgresDatabase.ts +104 -63
- package/src/Postgres/PostgresDefaultRole.ts +2 -2
- package/src/Postgres/PostgresMigrations.ts +20 -91
- package/src/Postgres/PostgresRole.ts +4 -4
- package/src/Providers.ts +3 -0
- package/src/ServiceToken.ts +431 -0
- package/src/Util.ts +1 -1
- 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 {
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
81
|
-
|
|
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: (
|
|
56
|
+
use: (executor: SqlExecutor) => Effect.Effect<A, E, R>,
|
|
86
57
|
) =>
|
|
87
58
|
withTemporaryPostgresRole(target, (role) =>
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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.
|
|
255
|
-
NotFound: () => Effect.
|
|
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.
|
|
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
|
|
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.
|
|
51
|
+
.pipe(Effect.catchTag("NotFound", () => Effect.undefined));
|
|
52
52
|
if (!live) return;
|
|
53
53
|
if (live.deletion_protected) {
|
|
54
54
|
return yield* new PlanetscaleDeletionProtected({
|