@volter/twin-planetscale 0.1.0
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/LICENSE +202 -0
- package/README.md +473 -0
- package/api/src/fetch.ts +50 -0
- package/api/src/generated/surface.gen.json +1 -0
- package/api/src/generated/ui.gen.json +1 -0
- package/api/src/index.ts +19 -0
- package/api/src/manifest.ts +136 -0
- package/api/src/screens/deploy-request.tsx +111 -0
- package/api/src/screens/service-tokens.tsx +141 -0
- package/api/src/screens/session.tsx +117 -0
- package/api/src/semantics/audit.ts +82 -0
- package/api/src/semantics/backups.ts +258 -0
- package/api/src/semantics/branches.ts +201 -0
- package/api/src/semantics/deploy-requests.ts +493 -0
- package/api/src/semantics/index.ts +371 -0
- package/api/src/semantics/shared.ts +141 -0
- package/api/src/semantics/time.ts +77 -0
- package/api/src/token-gate.ts +96 -0
- package/dist/api/src/fetch.d.ts +8 -0
- package/dist/api/src/fetch.js +51 -0
- package/dist/api/src/fetch.ts +50 -0
- package/dist/api/src/generated/surface.gen.json +1 -0
- package/dist/api/src/generated/ui.gen.json +1 -0
- package/dist/api/src/index.ts +19 -0
- package/dist/api/src/manifest.d.ts +2 -0
- package/dist/api/src/manifest.js +113 -0
- package/dist/api/src/manifest.ts +136 -0
- package/dist/api/src/screens/deploy-request.d.ts +7 -0
- package/dist/api/src/screens/deploy-request.js +106 -0
- package/dist/api/src/screens/deploy-request.tsx +111 -0
- package/dist/api/src/screens/service-tokens.d.ts +3 -0
- package/dist/api/src/screens/service-tokens.js +134 -0
- package/dist/api/src/screens/service-tokens.tsx +141 -0
- package/dist/api/src/screens/session.d.ts +11 -0
- package/dist/api/src/screens/session.js +108 -0
- package/dist/api/src/screens/session.tsx +117 -0
- package/dist/api/src/semantics/audit.d.ts +31 -0
- package/dist/api/src/semantics/audit.js +80 -0
- package/dist/api/src/semantics/audit.ts +82 -0
- package/dist/api/src/semantics/backups.d.ts +37 -0
- package/dist/api/src/semantics/backups.js +264 -0
- package/dist/api/src/semantics/backups.ts +258 -0
- package/dist/api/src/semantics/branches.d.ts +53 -0
- package/dist/api/src/semantics/branches.js +197 -0
- package/dist/api/src/semantics/branches.ts +201 -0
- package/dist/api/src/semantics/deploy-requests.d.ts +47 -0
- package/dist/api/src/semantics/deploy-requests.js +491 -0
- package/dist/api/src/semantics/deploy-requests.ts +493 -0
- package/dist/api/src/semantics/index.d.ts +20 -0
- package/dist/api/src/semantics/index.js +381 -0
- package/dist/api/src/semantics/index.ts +371 -0
- package/dist/api/src/semantics/shared.d.ts +36 -0
- package/dist/api/src/semantics/shared.js +132 -0
- package/dist/api/src/semantics/shared.ts +141 -0
- package/dist/api/src/semantics/time.d.ts +2 -0
- package/dist/api/src/semantics/time.js +81 -0
- package/dist/api/src/semantics/time.ts +77 -0
- package/dist/api/src/token-gate.d.ts +9 -0
- package/dist/api/src/token-gate.js +97 -0
- package/dist/api/src/token-gate.ts +96 -0
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +61 -0
- package/dist/src/generated/surface.gen.json +1 -0
- package/dist/src/generated/ui.gen.json +1 -0
- package/dist/src/index.d.ts +26 -0
- package/dist/src/index.js +156 -0
- package/dist/src/manifest.d.ts +2 -0
- package/dist/src/manifest.js +41 -0
- package/dist/src/planetscale-budget.d.ts +78 -0
- package/dist/src/planetscale-budget.js +305 -0
- package/dist/src/planetscale-capabilities.d.ts +10 -0
- package/dist/src/planetscale-capabilities.js +3977 -0
- package/dist/src/planetscale-collation-weights.gen.d.ts +4 -0
- package/dist/src/planetscale-collation-weights.gen.js +12 -0
- package/dist/src/planetscale-collation.d.ts +70 -0
- package/dist/src/planetscale-collation.js +391 -0
- package/dist/src/planetscale-conformance.d.ts +8 -0
- package/dist/src/planetscale-conformance.js +213 -0
- package/dist/src/planetscale-connector.d.ts +150 -0
- package/dist/src/planetscale-connector.js +532 -0
- package/dist/src/planetscale-deploy.d.ts +26 -0
- package/dist/src/planetscale-deploy.js +235 -0
- package/dist/src/planetscale-information-schema.d.ts +32 -0
- package/dist/src/planetscale-information-schema.js +299 -0
- package/dist/src/planetscale-mysql.d.ts +33 -0
- package/dist/src/planetscale-mysql.js +547 -0
- package/dist/src/planetscale-roles.d.ts +11 -0
- package/dist/src/planetscale-roles.js +60 -0
- package/dist/src/planetscale-row.d.ts +12 -0
- package/dist/src/planetscale-row.js +39 -0
- package/dist/src/planetscale-server.d.ts +42 -0
- package/dist/src/planetscale-server.js +137 -0
- package/dist/src/planetscale-sql.d.ts +701 -0
- package/dist/src/planetscale-sql.js +7167 -0
- package/dist/src/planetscale-store.d.ts +126 -0
- package/dist/src/planetscale-store.js +827 -0
- package/dist/src/planetscale-twin.d.ts +48 -0
- package/dist/src/planetscale-twin.js +290 -0
- package/dist/src/planetscale-values.d.ts +139 -0
- package/dist/src/planetscale-values.js +719 -0
- package/dist/src/planetscale-wire.d.ts +110 -0
- package/dist/src/planetscale-wire.js +188 -0
- package/dist/src/semantics/psdb.d.ts +18 -0
- package/dist/src/semantics/psdb.js +30 -0
- package/package.json +58 -0
- package/src/cli.ts +58 -0
- package/src/generated/surface.gen.json +1 -0
- package/src/generated/ui.gen.json +1 -0
- package/src/index.ts +267 -0
- package/src/manifest.ts +60 -0
- package/src/planetscale-budget.ts +347 -0
- package/src/planetscale-capabilities.ts +3862 -0
- package/src/planetscale-collation-weights.gen.ts +13 -0
- package/src/planetscale-collation.ts +378 -0
- package/src/planetscale-conformance.ts +237 -0
- package/src/planetscale-connector.ts +571 -0
- package/src/planetscale-deploy.ts +197 -0
- package/src/planetscale-information-schema.ts +322 -0
- package/src/planetscale-mysql.ts +339 -0
- package/src/planetscale-roles.ts +71 -0
- package/src/planetscale-row.ts +43 -0
- package/src/planetscale-server.ts +162 -0
- package/src/planetscale-sql.ts +5957 -0
- package/src/planetscale-store.ts +869 -0
- package/src/planetscale-twin.ts +338 -0
- package/src/planetscale-values.ts +572 -0
- package/src/planetscale-wire.ts +274 -0
- package/src/semantics/psdb.ts +57 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"generatedBy":"scripts/derive-pack.ts","resources":{"Database":{"retrieve":"/v1/organizations/{organization}/databases/{database}","create":{"path":"/v1/organizations/{organization}/databases","encoding":"json","body":[{"name":"cluster_size","type":"string","required":true},{"name":"kind","type":"string","required":false},{"name":"major_version","type":"string","required":false},{"name":"name","type":"string","required":true},{"name":"region","type":"string","required":false},{"name":"replicas","type":"integer","required":false},{"name":"storage","type":"object","required":false}]},"actions":[],"states":{"state":["pending","ready"]},"embeds":{}},"Backup":{"retrieve":"/v1/organizations/{organization}/databases/{database}/branches/{branch}/backups/{id}","create":{"path":"/v1/organizations/{organization}/databases/{database}/branches/{branch}/backups","encoding":"json","body":[{"name":"emergency","type":"boolean","required":false},{"name":"name","type":"string","required":false},{"name":"retention_unit","type":"string","required":false},{"name":"retention_value","type":"integer","required":false}]},"actions":[],"states":{"state":["pending","running","success"]},"embeds":{}},"DatabaseBranchPassword":{"retrieve":"/v1/organizations/{organization}/databases/{database}/branches/{branch}/passwords/{id}","actions":[],"states":{},"embeds":{}},"ServiceToken":{"create":{"path":"/v1/organizations/{organization}/service-tokens","encoding":"json","body":[{"name":"name","type":"string","required":false},{"name":"ttl","type":"integer","required":false}]},"actions":[],"states":{},"embeds":{}}}}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// The management API lane of the planetscale pack (`api.planetscale.com/v1`), as a plain fetch: the whole pack's fetch
|
|
2
|
+
// (the psdb data plane, the management API and app.planetscale.com's pages), whose owners are the operations this lane
|
|
3
|
+
// serves. Its life walks through all of them: a customer of the management API makes its tokens on the settings page,
|
|
4
|
+
// approves deploy requests on their page, and runs its migrations and queries through psdb on the branches the API makes
|
|
5
|
+
// (journeys/customer-life.outline.md).
|
|
6
|
+
import type { DerivedFetch } from '@volter/world-core';
|
|
7
|
+
import { createPlanetscaleTwinFetch } from '../../src/planetscale-server.ts';
|
|
8
|
+
import { createPlanetscaleApiLaneFetch } from './fetch.ts';
|
|
9
|
+
|
|
10
|
+
export { manifest } from './manifest.ts';
|
|
11
|
+
export { createPlanetscaleApiLaneFetch } from './fetch.ts';
|
|
12
|
+
// how a journey reads a psdb answer: the pack's own view (../../src/index.ts)
|
|
13
|
+
export { answerViews } from '../../src/index.ts';
|
|
14
|
+
|
|
15
|
+
export function createPlanetscaleApiTwinFetch(options: { root?: string; readOnly?: boolean } = {}): DerivedFetch {
|
|
16
|
+
const lane = createPlanetscaleApiLaneFetch(options);
|
|
17
|
+
const pack = createPlanetscaleTwinFetch({ ...(options.root !== undefined ? { root: options.root } : {}), ...(options.readOnly !== undefined ? { readOnly: options.readOnly } : {}) });
|
|
18
|
+
return Object.assign((request: Request) => pack(request), { owners: () => lane.owners() });
|
|
19
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/** A database's `state`. A new database is `pending` and not `ready`; PlanetScale provisions it, and a caller polls:
|
|
2
|
+
* "After creation, poll `get_database` until `ready` is `true` before creating branches or backups"
|
|
3
|
+
* (https://planetscale.com/docs/api/reference/create_database). The twin's database turns ready one minute after it was
|
|
4
|
+
* created, on the World clock (semantics/time.ts): how long provisioning takes is the twin's decision, the reference
|
|
5
|
+
* gives none. Not made by the twin: importing, import_ready, sleep_in_progress, sleeping, awakening. */
|
|
6
|
+
const databaseState = {
|
|
7
|
+
initial: 'pending',
|
|
8
|
+
transitions: [
|
|
9
|
+
{ actor: 'time', from: ['pending'], to: 'ready', source: 'spec:/definitions/Database/properties/ready "If the database is ready to be used"' },
|
|
10
|
+
],
|
|
11
|
+
};
|
|
12
|
+
/** A backup's `state`. A new backup is `pending`; it starts `running` and ends `success`, and a caller polls: "Use
|
|
13
|
+
* `get_backup` to poll the current state of a backup" (https://planetscale.com/docs/api/reference/create_backup). The
|
|
14
|
+
* twin's backup starts 30 seconds after it was asked for and completes two minutes after (semantics/time.ts), its size
|
|
15
|
+
* the branch's stored rows at completion: the timing is the twin's decision, the reference gives none. Not made by the
|
|
16
|
+
* twin: failed, canceled, ignored. */
|
|
17
|
+
const backupState = {
|
|
18
|
+
initial: 'pending',
|
|
19
|
+
transitions: [
|
|
20
|
+
{ actor: 'time', from: ['pending'], to: 'running', source: 'spec:/definitions/Backup/properties/state "The current state of the backup"' },
|
|
21
|
+
{ actor: 'time', from: ['running'], to: 'success', source: 'spec:/definitions/Backup/properties/state "The current state of the backup"' },
|
|
22
|
+
],
|
|
23
|
+
};
|
|
24
|
+
/** A branch's `state`. A new branch is `pending` and not `ready`; PlanetScale's CI example waits for it before using it
|
|
25
|
+
* (https://planetscale.com/docs/vitess/integrations/github-actions: "we create it and pass the `--wait` flag"). The twin's
|
|
26
|
+
* branch turns ready one minute after it was made, on the World clock (semantics/time.ts): the delay is the twin's
|
|
27
|
+
* decision. Not made by the twin: sleep_in_progress, sleeping, awakening. */
|
|
28
|
+
const branchState = {
|
|
29
|
+
initial: 'pending',
|
|
30
|
+
transitions: [
|
|
31
|
+
{ actor: 'time', from: ['pending'], to: 'ready', source: 'spec:/definitions/DatabaseBranch/properties/state "The current state of the branch"' },
|
|
32
|
+
],
|
|
33
|
+
};
|
|
34
|
+
const DR_PAGE = 'https://planetscale.com/docs/vitess/schema-changes/deploy-requests';
|
|
35
|
+
/** A deploy request's `state`: open until it is closed, by `close_deploy_request`, by a revert ("The deploy request will
|
|
36
|
+
* be closed", the deploy-requests page), or when its deployment completes (the twin's reading: the request stays open
|
|
37
|
+
* through its revert window). */
|
|
38
|
+
const deployRequestState = {
|
|
39
|
+
initial: 'open',
|
|
40
|
+
transitions: [
|
|
41
|
+
{ operation: 'close_deploy_request', from: ['open'], to: 'closed', source: 'spec:/paths/~1organizations~1{organization}~1databases~1{database}~1deploy-requests~1{number}/patch "The deploy request will be updated to this state"' },
|
|
42
|
+
{ operation: 'complete_revert', from: ['open'], to: 'closed', source: `${DR_PAGE} "The deploy request will be closed, but the branch will remain"` },
|
|
43
|
+
{ actor: 'time', from: ['open'], to: 'closed', source: `${DR_PAGE} "After the 30 minute period is up, the deployment becomes permanent"` },
|
|
44
|
+
],
|
|
45
|
+
};
|
|
46
|
+
/** A deploy request's `deployment_state` (semantics/deploy-requests.ts): checked, queued, deployed into its revert window,
|
|
47
|
+
* then permanent or reverted. The durations are the twin's. Not made by the twin: submitting, pending_cutover,
|
|
48
|
+
* in_progress_vschema, in_progress_cancel, in_progress_cutover, complete_cancel, complete_error, in_progress_revert,
|
|
49
|
+
* in_progress_revert_vschema, complete_revert_error, cancelled, error. */
|
|
50
|
+
const deploymentState = {
|
|
51
|
+
initial: 'pending',
|
|
52
|
+
transitions: [
|
|
53
|
+
{ actor: 'time', from: ['pending'], to: 'ready', source: `${DR_PAGE} "PlanetScale will check if the request is deployable"` },
|
|
54
|
+
{ actor: 'time', from: ['pending'], to: 'no_changes', source: 'spec:/definitions/DatabaseDeployRequest/properties/deployment_state "The deployment state of the deploy request"' },
|
|
55
|
+
{ operation: 'queue_deploy_request', from: ['ready'], to: 'queued', source: `${DR_PAGE} "The deployment will begin immediately, or join the serial deploy queue if other deployments are already pending"` },
|
|
56
|
+
{ actor: 'time', from: ['queued'], to: 'in_progress', source: `${DR_PAGE} "only one deploy runs at a time, and later requests wait until earlier ones finish"` },
|
|
57
|
+
{ actor: 'time', from: ['in_progress'], to: 'complete_pending_revert', source: `${DR_PAGE} "After you deploy, you have 30 minutes to \"undo\" it"` },
|
|
58
|
+
{ actor: 'time', from: ['complete_pending_revert'], to: 'complete', source: `${DR_PAGE} "After the 30 minute period is up, the deployment becomes permanent"` },
|
|
59
|
+
{ actor: 'time', from: ['in_progress'], to: 'complete', source: `${DR_PAGE} "these schema changes must be auto-applied and cannot be reverted"` },
|
|
60
|
+
{ operation: 'complete_revert', from: ['complete_pending_revert'], to: 'complete_revert', source: `${DR_PAGE} "You can revert a deployment for up to 30 minutes after the deploying"` },
|
|
61
|
+
],
|
|
62
|
+
};
|
|
63
|
+
export const manifest = {
|
|
64
|
+
vendor: 'planetscale',
|
|
65
|
+
service: 'planetscale',
|
|
66
|
+
body: { json: 'always' },
|
|
67
|
+
// PlanetScale's ids are opaque strings; the handlers mint each from the instant, the name and the count
|
|
68
|
+
// (semantics/shared.ts, `mintId`), prefixed per resource, so a World mints the same ids every run. The core mints none.
|
|
69
|
+
ids: { template: '{prefix}{n}' },
|
|
70
|
+
time: 'iso',
|
|
71
|
+
// the management API's error body (the pack's backups have answered it since they were served)
|
|
72
|
+
error: { code: '{code}', message: '{message}' },
|
|
73
|
+
readOnly: { status: 403, code: 'forbidden', message: 'The twin is read-only' },
|
|
74
|
+
malformedBody: { status: 422, code: 'unprocessable_entity', message: 'The request body is not valid JSON' },
|
|
75
|
+
notFound: { status: 404, code: 'not_found', message: 'Not Found' },
|
|
76
|
+
// the spec's Paginated* envelope; the lists are handlers (semantics/shared.ts, `page`)
|
|
77
|
+
list: {
|
|
78
|
+
style: 'envelope',
|
|
79
|
+
envelope: { type: 'list', data: '{data}' },
|
|
80
|
+
limit: { param: 'per_page', default: 25, max: 100 },
|
|
81
|
+
page: { param: 'page', link: false },
|
|
82
|
+
},
|
|
83
|
+
deleted: {},
|
|
84
|
+
// operations of the declared resources the lane does not serve: the gap
|
|
85
|
+
unmodeled: ['update_branch', 'update_backup_policy', 'delete_backup_policy', 'update_backup', 'update_password', 'get_service_token', 'get_oauth_token', 'delete_oauth_token'],
|
|
86
|
+
// the page where a service token is made and granted its accesses (no API operation grants an access)
|
|
87
|
+
screens: [{
|
|
88
|
+
id: 'sign-in', kind: 'flow', host: 'app.planetscale.com', path: '/sign-in',
|
|
89
|
+
demand: 'every app.planetscale.com page a person reaches: each asks who is signed in', status: 'done',
|
|
90
|
+
controls: ['Email', 'Password', 'Sign in'], source: 'https://planetscale.com/docs/api/reference/service-tokens',
|
|
91
|
+
}, {
|
|
92
|
+
id: 'service-tokens', kind: 'flow', host: 'app.planetscale.com', path: '/{organization}/settings/service-tokens',
|
|
93
|
+
demand: 'the first service token of an organization, and the accesses of every token, are made here',
|
|
94
|
+
status: 'done', controls: ['New service token', 'Name', 'Create service token', 'Service token', 'Database', 'Access', 'Save permissions', 'Delete service token'],
|
|
95
|
+
source: 'https://planetscale.com/docs/api/reference/service-tokens',
|
|
96
|
+
}, {
|
|
97
|
+
id: 'deploy-request', kind: 'flow', host: 'app.planetscale.com', path: '/{organization}/{database}/deploy-requests/{number}',
|
|
98
|
+
demand: 'a deploy request is approved by an administrator other than its creator here', status: 'done',
|
|
99
|
+
controls: ['Summary', 'Schema changes', 'Approve changes'],
|
|
100
|
+
source: 'https://planetscale.com/docs/vitess/schema-changes/deploy-requests',
|
|
101
|
+
}],
|
|
102
|
+
resources: {
|
|
103
|
+
Database: { storedAs: '_database', idPrefix: 'db', notFound: 'Not Found', notState: ['kind'], state: { state: databaseState } },
|
|
104
|
+
DatabaseBranch: { storedAs: '_branch', idPrefix: 'br', notFound: 'Not Found', notState: ['kind'], state: { state: branchState } },
|
|
105
|
+
DatabaseDeployRequest: { storedAs: '_deploy_request', idPrefix: 'dr', notFound: 'Not Found', state: { state: deployRequestState, deployment_state: deploymentState } },
|
|
106
|
+
BackupPolicy: { storedAs: '_backup_policy', idPrefix: 'bp', notFound: 'Not Found' },
|
|
107
|
+
Backup: { storedAs: '_backup', idPrefix: 'bk', notFound: 'Not Found', state: { state: backupState } },
|
|
108
|
+
// a password's role is set when it is made: "once a password is created, its role assignment cannot be changed"
|
|
109
|
+
// (planetscale.com/docs/vitess/connecting/password-roles)
|
|
110
|
+
DatabaseBranchPassword: { storedAs: '_password', idPrefix: 'pw', notFound: 'Not Found', notState: ['role'] },
|
|
111
|
+
ServiceToken: { storedAs: '_service_token', idPrefix: 'tk', notFound: 'Not Found' },
|
|
112
|
+
},
|
|
113
|
+
};
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// PlanetScale's management API manifest (the pack's `api` lane; docs/contributing/architecture.md, "Other wires: lanes"):
|
|
2
|
+
// the vendor facts its published Swagger document (../spec/openapi.json.gz) does not carry, and the resources whose state
|
|
3
|
+
// the lane moves. The surface is generated (./generated/surface.gen.json, by `bun scripts/derive-pack.ts planetscale/api`).
|
|
4
|
+
//
|
|
5
|
+
// The lane serves the slice an application and the pack's customer lives reach: databases (create, read, delete), their
|
|
6
|
+
// branches (create from a parent or a backup, read, list, delete; semantics/branches.ts), a branch's passwords (create,
|
|
7
|
+
// list, read, delete), a branch's backups (create, list, read, delete) and service tokens (create, list). Every other
|
|
8
|
+
// operation of the spec is the gap. The psdb data plane (the pack root) reads the passwords
|
|
9
|
+
// this lane issues, so both write the one kernel service `planetscale`; the lane's records are control-plane records the
|
|
10
|
+
// pack never pushes to a real branch, so each is stored under a `_` type, beside psdb's `_session`.
|
|
11
|
+
//
|
|
12
|
+
// NO `auth`: PlanetScale reads a service token as `Authorization: <SERVICE_TOKEN_ID>:<SERVICE_TOKEN>`, with no scheme
|
|
13
|
+
// (planetscale.com/docs/api/reference, "Service tokens"), which the kernel's scheme-prefixed gate cannot state. The lane's
|
|
14
|
+
// token gate (token-gate.ts) sits in front of the dispatch instead, and checks each operation's service token
|
|
15
|
+
// accesses as the spec's own "Authorization" section for it lists them.
|
|
16
|
+
import type { DerivedManifest, StateField } from '@volter/world-core';
|
|
17
|
+
|
|
18
|
+
/** A database's `state`. A new database is `pending` and not `ready`; PlanetScale provisions it, and a caller polls:
|
|
19
|
+
* "After creation, poll `get_database` until `ready` is `true` before creating branches or backups"
|
|
20
|
+
* (https://planetscale.com/docs/api/reference/create_database). The twin's database turns ready one minute after it was
|
|
21
|
+
* created, on the World clock (semantics/time.ts): how long provisioning takes is the twin's decision, the reference
|
|
22
|
+
* gives none. Not made by the twin: importing, import_ready, sleep_in_progress, sleeping, awakening. */
|
|
23
|
+
const databaseState: StateField = {
|
|
24
|
+
initial: 'pending',
|
|
25
|
+
transitions: [
|
|
26
|
+
{ actor: 'time', from: ['pending'], to: 'ready', source: 'spec:/definitions/Database/properties/ready "If the database is ready to be used"' },
|
|
27
|
+
],
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/** A backup's `state`. A new backup is `pending`; it starts `running` and ends `success`, and a caller polls: "Use
|
|
31
|
+
* `get_backup` to poll the current state of a backup" (https://planetscale.com/docs/api/reference/create_backup). The
|
|
32
|
+
* twin's backup starts 30 seconds after it was asked for and completes two minutes after (semantics/time.ts), its size
|
|
33
|
+
* the branch's stored rows at completion: the timing is the twin's decision, the reference gives none. Not made by the
|
|
34
|
+
* twin: failed, canceled, ignored. */
|
|
35
|
+
const backupState: StateField = {
|
|
36
|
+
initial: 'pending',
|
|
37
|
+
transitions: [
|
|
38
|
+
{ actor: 'time', from: ['pending'], to: 'running', source: 'spec:/definitions/Backup/properties/state "The current state of the backup"' },
|
|
39
|
+
{ actor: 'time', from: ['running'], to: 'success', source: 'spec:/definitions/Backup/properties/state "The current state of the backup"' },
|
|
40
|
+
],
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/** A branch's `state`. A new branch is `pending` and not `ready`; PlanetScale's CI example waits for it before using it
|
|
44
|
+
* (https://planetscale.com/docs/vitess/integrations/github-actions: "we create it and pass the `--wait` flag"). The twin's
|
|
45
|
+
* branch turns ready one minute after it was made, on the World clock (semantics/time.ts): the delay is the twin's
|
|
46
|
+
* decision. Not made by the twin: sleep_in_progress, sleeping, awakening. */
|
|
47
|
+
const branchState: StateField = {
|
|
48
|
+
initial: 'pending',
|
|
49
|
+
transitions: [
|
|
50
|
+
{ actor: 'time', from: ['pending'], to: 'ready', source: 'spec:/definitions/DatabaseBranch/properties/state "The current state of the branch"' },
|
|
51
|
+
],
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const DR_PAGE = 'https://planetscale.com/docs/vitess/schema-changes/deploy-requests';
|
|
55
|
+
|
|
56
|
+
/** A deploy request's `state`: open until it is closed, by `close_deploy_request`, by a revert ("The deploy request will
|
|
57
|
+
* be closed", the deploy-requests page), or when its deployment completes (the twin's reading: the request stays open
|
|
58
|
+
* through its revert window). */
|
|
59
|
+
const deployRequestState: StateField = {
|
|
60
|
+
initial: 'open',
|
|
61
|
+
transitions: [
|
|
62
|
+
{ operation: 'close_deploy_request', from: ['open'], to: 'closed', source: 'spec:/paths/~1organizations~1{organization}~1databases~1{database}~1deploy-requests~1{number}/patch "The deploy request will be updated to this state"' },
|
|
63
|
+
{ operation: 'complete_revert', from: ['open'], to: 'closed', source: `${DR_PAGE} "The deploy request will be closed, but the branch will remain"` },
|
|
64
|
+
{ actor: 'time', from: ['open'], to: 'closed', source: `${DR_PAGE} "After the 30 minute period is up, the deployment becomes permanent"` },
|
|
65
|
+
],
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/** A deploy request's `deployment_state` (semantics/deploy-requests.ts): checked, queued, deployed into its revert window,
|
|
69
|
+
* then permanent or reverted. The durations are the twin's. Not made by the twin: submitting, pending_cutover,
|
|
70
|
+
* in_progress_vschema, in_progress_cancel, in_progress_cutover, complete_cancel, complete_error, in_progress_revert,
|
|
71
|
+
* in_progress_revert_vschema, complete_revert_error, cancelled, error. */
|
|
72
|
+
const deploymentState: StateField = {
|
|
73
|
+
initial: 'pending',
|
|
74
|
+
transitions: [
|
|
75
|
+
{ actor: 'time', from: ['pending'], to: 'ready', source: `${DR_PAGE} "PlanetScale will check if the request is deployable"` },
|
|
76
|
+
{ actor: 'time', from: ['pending'], to: 'no_changes', source: 'spec:/definitions/DatabaseDeployRequest/properties/deployment_state "The deployment state of the deploy request"' },
|
|
77
|
+
{ operation: 'queue_deploy_request', from: ['ready'], to: 'queued', source: `${DR_PAGE} "The deployment will begin immediately, or join the serial deploy queue if other deployments are already pending"` },
|
|
78
|
+
{ actor: 'time', from: ['queued'], to: 'in_progress', source: `${DR_PAGE} "only one deploy runs at a time, and later requests wait until earlier ones finish"` },
|
|
79
|
+
{ actor: 'time', from: ['in_progress'], to: 'complete_pending_revert', source: `${DR_PAGE} "After you deploy, you have 30 minutes to \"undo\" it"` },
|
|
80
|
+
{ actor: 'time', from: ['complete_pending_revert'], to: 'complete', source: `${DR_PAGE} "After the 30 minute period is up, the deployment becomes permanent"` },
|
|
81
|
+
{ actor: 'time', from: ['in_progress'], to: 'complete', source: `${DR_PAGE} "these schema changes must be auto-applied and cannot be reverted"` },
|
|
82
|
+
{ operation: 'complete_revert', from: ['complete_pending_revert'], to: 'complete_revert', source: `${DR_PAGE} "You can revert a deployment for up to 30 minutes after the deploying"` },
|
|
83
|
+
],
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
export const manifest: DerivedManifest = {
|
|
87
|
+
vendor: 'planetscale',
|
|
88
|
+
service: 'planetscale',
|
|
89
|
+
body: { json: 'always' },
|
|
90
|
+
// PlanetScale's ids are opaque strings; the handlers mint each from the instant, the name and the count
|
|
91
|
+
// (semantics/shared.ts, `mintId`), prefixed per resource, so a World mints the same ids every run. The core mints none.
|
|
92
|
+
ids: { template: '{prefix}{n}' },
|
|
93
|
+
time: 'iso',
|
|
94
|
+
// the management API's error body (the pack's backups have answered it since they were served)
|
|
95
|
+
error: { code: '{code}', message: '{message}' },
|
|
96
|
+
readOnly: { status: 403, code: 'forbidden', message: 'The twin is read-only' },
|
|
97
|
+
malformedBody: { status: 422, code: 'unprocessable_entity', message: 'The request body is not valid JSON' },
|
|
98
|
+
notFound: { status: 404, code: 'not_found', message: 'Not Found' },
|
|
99
|
+
// the spec's Paginated* envelope; the lists are handlers (semantics/shared.ts, `page`)
|
|
100
|
+
list: {
|
|
101
|
+
style: 'envelope',
|
|
102
|
+
envelope: { type: 'list', data: '{data}' },
|
|
103
|
+
limit: { param: 'per_page', default: 25, max: 100 },
|
|
104
|
+
page: { param: 'page', link: false },
|
|
105
|
+
},
|
|
106
|
+
deleted: {},
|
|
107
|
+
// operations of the declared resources the lane does not serve: the gap
|
|
108
|
+
unmodeled: ['update_branch', 'update_backup_policy', 'delete_backup_policy', 'update_backup', 'update_password', 'get_service_token', 'get_oauth_token', 'delete_oauth_token'],
|
|
109
|
+
// the page where a service token is made and granted its accesses (no API operation grants an access)
|
|
110
|
+
screens: [{
|
|
111
|
+
id: 'sign-in', kind: 'flow', host: 'app.planetscale.com', path: '/sign-in',
|
|
112
|
+
demand: 'every app.planetscale.com page a person reaches: each asks who is signed in', status: 'done',
|
|
113
|
+
controls: ['Email', 'Password', 'Sign in'], source: 'https://planetscale.com/docs/api/reference/service-tokens',
|
|
114
|
+
}, {
|
|
115
|
+
id: 'service-tokens', kind: 'flow', host: 'app.planetscale.com', path: '/{organization}/settings/service-tokens',
|
|
116
|
+
demand: 'the first service token of an organization, and the accesses of every token, are made here',
|
|
117
|
+
status: 'done', controls: ['New service token', 'Name', 'Create service token', 'Service token', 'Database', 'Access', 'Save permissions', 'Delete service token'],
|
|
118
|
+
source: 'https://planetscale.com/docs/api/reference/service-tokens',
|
|
119
|
+
}, {
|
|
120
|
+
id: 'deploy-request', kind: 'flow', host: 'app.planetscale.com', path: '/{organization}/{database}/deploy-requests/{number}',
|
|
121
|
+
demand: 'a deploy request is approved by an administrator other than its creator here', status: 'done',
|
|
122
|
+
controls: ['Summary', 'Schema changes', 'Approve changes'],
|
|
123
|
+
source: 'https://planetscale.com/docs/vitess/schema-changes/deploy-requests',
|
|
124
|
+
}],
|
|
125
|
+
resources: {
|
|
126
|
+
Database: { storedAs: '_database', idPrefix: 'db', notFound: 'Not Found', notState: ['kind'], state: { state: databaseState } },
|
|
127
|
+
DatabaseBranch: { storedAs: '_branch', idPrefix: 'br', notFound: 'Not Found', notState: ['kind'], state: { state: branchState } },
|
|
128
|
+
DatabaseDeployRequest: { storedAs: '_deploy_request', idPrefix: 'dr', notFound: 'Not Found', state: { state: deployRequestState, deployment_state: deploymentState } },
|
|
129
|
+
BackupPolicy: { storedAs: '_backup_policy', idPrefix: 'bp', notFound: 'Not Found' },
|
|
130
|
+
Backup: { storedAs: '_backup', idPrefix: 'bk', notFound: 'Not Found', state: { state: backupState } },
|
|
131
|
+
// a password's role is set when it is made: "once a password is created, its role assignment cannot be changed"
|
|
132
|
+
// (planetscale.com/docs/vitess/connecting/password-roles)
|
|
133
|
+
DatabaseBranchPassword: { storedAs: '_password', idPrefix: 'pw', notFound: 'Not Found', notState: ['role'] },
|
|
134
|
+
ServiceToken: { storedAs: '_service_token', idPrefix: 'tk', notFound: 'Not Found' },
|
|
135
|
+
},
|
|
136
|
+
};
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { SemanticsContext } from '@volter/world-core';
|
|
2
|
+
type Row = Record<string, unknown>;
|
|
3
|
+
/** The spec's actor object for a person on a page. */
|
|
4
|
+
export declare const personActor: (email: string) => Row;
|
|
5
|
+
/** The deploy request page, or undefined for any other request. `contextFor` gives the lane's semantics context. */
|
|
6
|
+
export declare function deployRequestPage(request: Request, contextFor: (request: Request) => Promise<SemanticsContext>): Promise<Response | undefined>;
|
|
7
|
+
export {};
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
// PLANETSCALE'S DEPLOY REQUEST PAGE — a flow (docs/contributing/architecture.md, "Screens"): the page at
|
|
3
|
+
// app.planetscale.com/{organization}/{database}/deploy-requests/{number}, where a teammate reads a deploy request's schema
|
|
4
|
+
// changes and approves it. https://planetscale.com/docs/vitess/schema-changes/deploy-requests, "Review a deploy request":
|
|
5
|
+
// "Under "Summary", you'll see if the request is deployable. To review the schema changes, click the "Schema changes"
|
|
6
|
+
// tab"; "If you have required deploy requests to be approved before deployment, other users in your Organization will
|
|
7
|
+
// see the option to "Approve changes" or "Leave a comment" on the "Schema changes" tab." Built from @volter/world-ui's
|
|
8
|
+
// settings layout (Portal) under PlanetScale's skin; nothing of PlanetScale's page is copied. Its controls are the ones
|
|
9
|
+
// the lane's life needs: the Summary, the Schema changes, and "Approve changes".
|
|
10
|
+
//
|
|
11
|
+
// The person on the page is who signed in at /sign-in (screens/session.tsx); only a member of the organization reaches
|
|
12
|
+
// it (another answers PlanetScale's 404). Where the documentation stops and the twin decides: approving takes an
|
|
13
|
+
// administrator (the setting "Require administrator approval for deploy requests" names an administrator's approval),
|
|
14
|
+
// the page posts the approval to `…/deploy-requests/{number}/review` and comes back to the page, and the approval is the
|
|
15
|
+
// same review the API's review_deploy_request records (semantics/deploy-requests.ts), with the person as reviewer.
|
|
16
|
+
import { flowPage, Portal, PORTAL_CSS } from '@volter/world-ui';
|
|
17
|
+
import { deploymentOf, recordReview, reviewsOf } from "../semantics/deploy-requests.js";
|
|
18
|
+
import { createdById, personActorOf } from "../semantics/audit.js";
|
|
19
|
+
import { signedIn, toSignIn } from "./session.js";
|
|
20
|
+
const PAGE = /^\/([^/]+)\/([^/]+)\/deploy-requests\/(\d+)(?:\/(review))?$/;
|
|
21
|
+
const SKIN = `
|
|
22
|
+
body { background: #ffffff; color: #111111; }
|
|
23
|
+
.portal-side { background: #fafafa; border-right: 1px solid #e5e5e5; }
|
|
24
|
+
.portal-section h2 { border-color: #e5e5e5; color: #525252; }
|
|
25
|
+
.portal-notice { background: #f0f7ff; border: 1px solid #b6d4fe; }
|
|
26
|
+
.portal-button { background: #ffffff; border: 1px solid #d4d4d4; border-radius: 6px; padding: 6px 12px; }
|
|
27
|
+
.portal-primary { background: #1d4ed8; border-color: #1d4ed8; color: #ffffff; }
|
|
28
|
+
`;
|
|
29
|
+
/** The spec's actor object for a person on a page. */
|
|
30
|
+
export const personActor = (email) => ({ id: createdById(email), display_name: email, avatar_url: 'https://app.planetscale.com/gravatar-fallback.png' });
|
|
31
|
+
function page(ctx, org, database, dr, canApprove, notice, status = 200) {
|
|
32
|
+
const deployment = deploymentOf(dr);
|
|
33
|
+
const ops = deployment.deploy_operations;
|
|
34
|
+
const reviews = reviewsOf(ctx, dr);
|
|
35
|
+
const action = `/${org}/${database}/deploy-requests/${String(dr.number)}/review`;
|
|
36
|
+
const approvable = canApprove && dr.state === 'open' && dr.approved !== true;
|
|
37
|
+
return flowPage({
|
|
38
|
+
title: `Deploy request #${String(dr.number)} · ${database} · PlanetScale`,
|
|
39
|
+
css: [PORTAL_CSS, SKIN],
|
|
40
|
+
status,
|
|
41
|
+
body: (_jsx(Portal, { merchant: `${org} / ${database}`, back: { href: `/${org}/${database}`, label: 'Back to database' }, ...(notice ? { notice } : {}), sections: [
|
|
42
|
+
{
|
|
43
|
+
heading: 'Summary',
|
|
44
|
+
items: [{
|
|
45
|
+
title: `#${String(dr.number)}: ${String(dr.branch)} → ${String(dr.into_branch)}`,
|
|
46
|
+
detail: deployment.deployable === true ? 'Deployable' : 'Not deployable',
|
|
47
|
+
note: `${String(dr.state)} · ${String(dr.deployment_state)}${dr.approved === true ? ' · Approved' : ''}${dr.notes ? ` · ${String(dr.notes)}` : ''}`,
|
|
48
|
+
...(approvable ? { actions: [{ label: 'Approve changes', action, fields: { state: 'approved' }, tone: 'primary', method: 'post' }] } : {}),
|
|
49
|
+
}],
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
heading: 'Schema changes',
|
|
53
|
+
empty: 'No schema changes.',
|
|
54
|
+
items: ops.map((o) => ({ title: String(o.table_name), detail: String(o.ddl_statement), ...(o.can_drop_data === true ? { badge: 'Drops data' } : {}) })),
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
heading: 'Reviews',
|
|
58
|
+
empty: 'No reviews yet.',
|
|
59
|
+
items: reviews.map((r) => ({ title: String(r.actor.display_name), detail: String(r.state), note: String(r.created_at) })),
|
|
60
|
+
},
|
|
61
|
+
] })),
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
/** The page again with the refusal an approval drew (a creator approving their own request, or a token's maker its
|
|
65
|
+
* request), in the page's notice. */
|
|
66
|
+
async function approvalRefused(out, again) {
|
|
67
|
+
return again((await out.json()).message ?? 'The approval was refused.', out.status);
|
|
68
|
+
}
|
|
69
|
+
async function formOf(request) {
|
|
70
|
+
return Object.fromEntries(new URLSearchParams(await request.text()));
|
|
71
|
+
}
|
|
72
|
+
/** The deploy request page, or undefined for any other request. `contextFor` gives the lane's semantics context. */
|
|
73
|
+
export async function deployRequestPage(request, contextFor) {
|
|
74
|
+
const url = new URL(request.url);
|
|
75
|
+
const m = PAGE.exec(url.pathname.replace(/\/+$/, ''));
|
|
76
|
+
if (!m)
|
|
77
|
+
return undefined;
|
|
78
|
+
const [, org, database, number, review] = m.map((x) => (x === undefined ? x : decodeURIComponent(x)));
|
|
79
|
+
const ctx = await contextFor(new Request(request.url));
|
|
80
|
+
const person = signedIn(ctx, request);
|
|
81
|
+
if (!person)
|
|
82
|
+
return toSignIn(request);
|
|
83
|
+
const notFound = new Response('Not Found', { status: 404, headers: { 'content-type': 'text/plain' } });
|
|
84
|
+
if (!person.organizations.includes(org))
|
|
85
|
+
return notFound;
|
|
86
|
+
const db = ctx.rowsRaw('Database').find((d) => d.name === database && !d._deleted_at);
|
|
87
|
+
const find = (c) => c.rowsRaw('DatabaseDeployRequest').find((d) => d._database === database && String(d.number) === number);
|
|
88
|
+
const dr = db ? find(ctx) : undefined;
|
|
89
|
+
if (!db || !dr)
|
|
90
|
+
return notFound;
|
|
91
|
+
const admin = person.role === 'admin';
|
|
92
|
+
if (!review && request.method === 'GET')
|
|
93
|
+
return page(ctx, org, database, dr, admin);
|
|
94
|
+
if (!review || request.method !== 'POST')
|
|
95
|
+
return undefined;
|
|
96
|
+
const form = await formOf(request);
|
|
97
|
+
if (form.state !== 'approved')
|
|
98
|
+
return page(ctx, org, database, dr, admin, 'Choose Approve changes.', 422);
|
|
99
|
+
if (!admin)
|
|
100
|
+
return page(ctx, org, database, dr, admin, 'Only an organization administrator can approve a deploy request.', 403);
|
|
101
|
+
const out = await recordReview(ctx, db, dr, `person:${person.email}`, personActor(person.email), 'approved', '', personActorOf(person.email));
|
|
102
|
+
const after = await contextFor(new Request(request.url));
|
|
103
|
+
if (out instanceof Response)
|
|
104
|
+
return approvalRefused(out, (text, status) => page(after, org, database, find(after), admin, text, status));
|
|
105
|
+
return page(after, org, database, find(after), admin, `You approved deploy request #${number}.`);
|
|
106
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// PLANETSCALE'S DEPLOY REQUEST PAGE — a flow (docs/contributing/architecture.md, "Screens"): the page at
|
|
2
|
+
// app.planetscale.com/{organization}/{database}/deploy-requests/{number}, where a teammate reads a deploy request's schema
|
|
3
|
+
// changes and approves it. https://planetscale.com/docs/vitess/schema-changes/deploy-requests, "Review a deploy request":
|
|
4
|
+
// "Under "Summary", you'll see if the request is deployable. To review the schema changes, click the "Schema changes"
|
|
5
|
+
// tab"; "If you have required deploy requests to be approved before deployment, other users in your Organization will
|
|
6
|
+
// see the option to "Approve changes" or "Leave a comment" on the "Schema changes" tab." Built from @volter/world-ui's
|
|
7
|
+
// settings layout (Portal) under PlanetScale's skin; nothing of PlanetScale's page is copied. Its controls are the ones
|
|
8
|
+
// the lane's life needs: the Summary, the Schema changes, and "Approve changes".
|
|
9
|
+
//
|
|
10
|
+
// The person on the page is who signed in at /sign-in (screens/session.tsx); only a member of the organization reaches
|
|
11
|
+
// it (another answers PlanetScale's 404). Where the documentation stops and the twin decides: approving takes an
|
|
12
|
+
// administrator (the setting "Require administrator approval for deploy requests" names an administrator's approval),
|
|
13
|
+
// the page posts the approval to `…/deploy-requests/{number}/review` and comes back to the page, and the approval is the
|
|
14
|
+
// same review the API's review_deploy_request records (semantics/deploy-requests.ts), with the person as reviewer.
|
|
15
|
+
import { flowPage, Portal, PORTAL_CSS } from '@volter/world-ui';
|
|
16
|
+
import type { SemanticsContext } from '@volter/world-core';
|
|
17
|
+
import { deploymentOf, recordReview, reviewsOf } from '../semantics/deploy-requests.ts';
|
|
18
|
+
import { createdById, personActorOf } from '../semantics/audit.ts';
|
|
19
|
+
import { signedIn, toSignIn } from './session.tsx';
|
|
20
|
+
|
|
21
|
+
type Row = Record<string, unknown>;
|
|
22
|
+
const PAGE = /^\/([^/]+)\/([^/]+)\/deploy-requests\/(\d+)(?:\/(review))?$/;
|
|
23
|
+
const SKIN = `
|
|
24
|
+
body { background: #ffffff; color: #111111; }
|
|
25
|
+
.portal-side { background: #fafafa; border-right: 1px solid #e5e5e5; }
|
|
26
|
+
.portal-section h2 { border-color: #e5e5e5; color: #525252; }
|
|
27
|
+
.portal-notice { background: #f0f7ff; border: 1px solid #b6d4fe; }
|
|
28
|
+
.portal-button { background: #ffffff; border: 1px solid #d4d4d4; border-radius: 6px; padding: 6px 12px; }
|
|
29
|
+
.portal-primary { background: #1d4ed8; border-color: #1d4ed8; color: #ffffff; }
|
|
30
|
+
`;
|
|
31
|
+
|
|
32
|
+
/** The spec's actor object for a person on a page. */
|
|
33
|
+
export const personActor = (email: string): Row => ({ id: createdById(email), display_name: email, avatar_url: 'https://app.planetscale.com/gravatar-fallback.png' });
|
|
34
|
+
|
|
35
|
+
function page(ctx: SemanticsContext, org: string, database: string, dr: Row, canApprove: boolean, notice?: string, status = 200): Response {
|
|
36
|
+
const deployment = deploymentOf(dr);
|
|
37
|
+
const ops = deployment.deploy_operations as Row[];
|
|
38
|
+
const reviews = reviewsOf(ctx, dr);
|
|
39
|
+
const action = `/${org}/${database}/deploy-requests/${String(dr.number)}/review`;
|
|
40
|
+
const approvable = canApprove && dr.state === 'open' && dr.approved !== true;
|
|
41
|
+
return flowPage({
|
|
42
|
+
title: `Deploy request #${String(dr.number)} · ${database} · PlanetScale`,
|
|
43
|
+
css: [PORTAL_CSS, SKIN],
|
|
44
|
+
status,
|
|
45
|
+
body: (
|
|
46
|
+
<Portal
|
|
47
|
+
merchant={`${org} / ${database}`}
|
|
48
|
+
back={{ href: `/${org}/${database}`, label: 'Back to database' }}
|
|
49
|
+
{...(notice ? { notice } : {})}
|
|
50
|
+
sections={[
|
|
51
|
+
{
|
|
52
|
+
heading: 'Summary',
|
|
53
|
+
items: [{
|
|
54
|
+
title: `#${String(dr.number)}: ${String(dr.branch)} → ${String(dr.into_branch)}`,
|
|
55
|
+
detail: deployment.deployable === true ? 'Deployable' : 'Not deployable',
|
|
56
|
+
note: `${String(dr.state)} · ${String(dr.deployment_state)}${dr.approved === true ? ' · Approved' : ''}${dr.notes ? ` · ${String(dr.notes)}` : ''}`,
|
|
57
|
+
...(approvable ? { actions: [{ label: 'Approve changes', action, fields: { state: 'approved' }, tone: 'primary' as const, method: 'post' as const }] } : {}),
|
|
58
|
+
}],
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
heading: 'Schema changes',
|
|
62
|
+
empty: 'No schema changes.',
|
|
63
|
+
items: ops.map((o) => ({ title: String(o.table_name), detail: String(o.ddl_statement), ...(o.can_drop_data === true ? { badge: 'Drops data' } : {}) })),
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
heading: 'Reviews',
|
|
67
|
+
empty: 'No reviews yet.',
|
|
68
|
+
items: reviews.map((r) => ({ title: String((r.actor as Row).display_name), detail: String(r.state), note: String(r.created_at) })),
|
|
69
|
+
},
|
|
70
|
+
]}
|
|
71
|
+
/>
|
|
72
|
+
),
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The page again with the refusal an approval drew (a creator approving their own request, or a token's maker its
|
|
77
|
+
* request), in the page's notice. */
|
|
78
|
+
async function approvalRefused(out: Response, again: (text: string, status: number) => Response): Promise<Response> {
|
|
79
|
+
return again(((await out.json()) as { message?: string }).message ?? 'The approval was refused.', out.status);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function formOf(request: Request): Promise<Record<string, string>> {
|
|
83
|
+
return Object.fromEntries(new URLSearchParams(await request.text()));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** The deploy request page, or undefined for any other request. `contextFor` gives the lane's semantics context. */
|
|
87
|
+
export async function deployRequestPage(request: Request, contextFor: (request: Request) => Promise<SemanticsContext>): Promise<Response | undefined> {
|
|
88
|
+
const url = new URL(request.url);
|
|
89
|
+
const m = PAGE.exec(url.pathname.replace(/\/+$/, ''));
|
|
90
|
+
if (!m) return undefined;
|
|
91
|
+
const [, org, database, number, review] = m.map((x) => (x === undefined ? x : decodeURIComponent(x))) as [string, string, string, string, string | undefined];
|
|
92
|
+
const ctx = await contextFor(new Request(request.url));
|
|
93
|
+
const person = signedIn(ctx, request);
|
|
94
|
+
if (!person) return toSignIn(request);
|
|
95
|
+
const notFound = new Response('Not Found', { status: 404, headers: { 'content-type': 'text/plain' } });
|
|
96
|
+
if (!person.organizations.includes(org)) return notFound;
|
|
97
|
+
const db = ctx.rowsRaw('Database').find((d) => d.name === database && !d._deleted_at);
|
|
98
|
+
const find = (c: SemanticsContext): Row | undefined => c.rowsRaw('DatabaseDeployRequest').find((d) => d._database === database && String(d.number) === number);
|
|
99
|
+
const dr = db ? find(ctx) : undefined;
|
|
100
|
+
if (!db || !dr) return notFound;
|
|
101
|
+
const admin = person.role === 'admin';
|
|
102
|
+
if (!review && request.method === 'GET') return page(ctx, org, database, dr, admin);
|
|
103
|
+
if (!review || request.method !== 'POST') return undefined;
|
|
104
|
+
const form = await formOf(request);
|
|
105
|
+
if (form.state !== 'approved') return page(ctx, org, database, dr, admin, 'Choose Approve changes.', 422);
|
|
106
|
+
if (!admin) return page(ctx, org, database, dr, admin, 'Only an organization administrator can approve a deploy request.', 403);
|
|
107
|
+
const out = await recordReview(ctx, db, dr, `person:${person.email}`, personActor(person.email), 'approved', '', personActorOf(person.email));
|
|
108
|
+
const after = await contextFor(new Request(request.url));
|
|
109
|
+
if (out instanceof Response) return approvalRefused(out, (text, status) => page(after, org, database, find(after)!, admin, text, status));
|
|
110
|
+
return page(after, org, database, find(after)!, admin, `You approved deploy request #${number}.`);
|
|
111
|
+
}
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import type { SemanticsContext } from '@volter/world-core';
|
|
2
|
+
/** The service tokens page, or undefined for any other request. `contextFor` gives the lane's semantics context. */
|
|
3
|
+
export declare function serviceTokensPage(request: Request, contextFor: (request: Request) => Promise<SemanticsContext>): Promise<Response | undefined>;
|