@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,491 @@
|
|
|
1
|
+
import { closeSession, executeSql, scopeImage } from "../../../src/planetscale-store.js";
|
|
2
|
+
import { quoteSqlLiteral } from "../../../src/planetscale-row.js";
|
|
3
|
+
import { tableKey } from "../../../src/planetscale-sql.js";
|
|
4
|
+
import { heldAccesses } from "../token-gate.js";
|
|
5
|
+
import { branchScope, branchesOf, removeBranch, storeCtx } from "./branches.js";
|
|
6
|
+
import { actorOf, mintId, page } from "./shared.js";
|
|
7
|
+
import { audit, callActor, SYSTEM } from "./audit.js";
|
|
8
|
+
export const CHECK_AFTER = 30_000;
|
|
9
|
+
export const RUN_FOR = 120_000;
|
|
10
|
+
export const REVERT_WINDOW = 30 * 60_000;
|
|
11
|
+
const later = (iso, ms) => new Date(Date.parse(iso) + ms).toISOString();
|
|
12
|
+
const unprocessable = (ctx, message) => ctx.refuse({ status: 422, code: 'unprocessable_entity', message });
|
|
13
|
+
const body = (ctx) => (ctx.body && typeof ctx.body === 'object' && !Array.isArray(ctx.body) ? ctx.body : {});
|
|
14
|
+
/** A column's definition as SHOW CREATE TABLE prints it (`varchar(191) NOT NULL`), read off the branch's own table. */
|
|
15
|
+
async function showCreate(ctx, scope, table) {
|
|
16
|
+
const run = await executeSql({ ...storeCtx(ctx), ...(scope ? { scope } : {}) }, `SHOW CREATE TABLE \`${table.replace(/`/g, '``')}\``, null);
|
|
17
|
+
await closeSession({ ...storeCtx(ctx), ...(scope ? { scope } : {}) }, run.session.id);
|
|
18
|
+
return String(run.outcome.rows?.[0]?.[1] ?? '');
|
|
19
|
+
}
|
|
20
|
+
const columnLine = (create, column) => {
|
|
21
|
+
const line = create.split('\n').find((l) => l.trim().startsWith(`\`${column}\` `));
|
|
22
|
+
return (line ?? '').trim().replace(/^`[^`]+`\s+/, '').replace(/,$/, '');
|
|
23
|
+
};
|
|
24
|
+
const q = (name) => `\`${name.replace(/`/g, '``')}\``;
|
|
25
|
+
/** The branch's schema against the schema it was branched from: the deploy request's operations and lint errors. */
|
|
26
|
+
async function diffOf(ctx, database, branch) {
|
|
27
|
+
const scope = branchScope(database, String(branch.name));
|
|
28
|
+
const now = new Map(scopeImage(ctx.root, scope).tables.map((t) => [tableKey(t.name), t]));
|
|
29
|
+
const before = new Map((branch._parent_schema ?? []).map((t) => [tableKey(t.name), t]));
|
|
30
|
+
const operations = [];
|
|
31
|
+
const lint = [];
|
|
32
|
+
for (const [key, t] of now) {
|
|
33
|
+
const prior = before.get(key);
|
|
34
|
+
if (!prior) {
|
|
35
|
+
const ddl = await showCreate(ctx, scope, t.name);
|
|
36
|
+
operations.push({ table: t.name, kind: 'create', added: [], dropped: [], ddl, apply: [ddl], can_drop_data: false });
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
const cols = new Map(t.columns.map((c) => [c.name.toLowerCase(), c]));
|
|
40
|
+
const priorCols = new Map(prior.columns.map((c) => [c.name.toLowerCase(), c]));
|
|
41
|
+
const dropped = prior.columns.filter((c) => !cols.has(c.name.toLowerCase())).map((c) => c.name);
|
|
42
|
+
const addedCols = t.columns.filter((c) => !priorCols.has(c.name.toLowerCase()));
|
|
43
|
+
const changed = t.columns.some((c) => priorCols.has(c.name.toLowerCase()) && JSON.stringify(priorCols.get(c.name.toLowerCase())) !== JSON.stringify(c))
|
|
44
|
+
|| JSON.stringify([t.primaryKey, t.uniques, t.indexes ?? []]) !== JSON.stringify([prior.primaryKey, prior.uniques, prior.indexes ?? []]);
|
|
45
|
+
if (changed) {
|
|
46
|
+
lint.push(unsupportedChange(t.name));
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
if (!dropped.length && !addedCols.length)
|
|
50
|
+
continue;
|
|
51
|
+
const create = addedCols.length ? await showCreate(ctx, scope, t.name) : '';
|
|
52
|
+
const added = addedCols.map((c) => {
|
|
53
|
+
const i = t.columns.indexOf(c);
|
|
54
|
+
return { name: c.name, definition: columnLine(create, c.name), after: i > 0 ? t.columns[i - 1].name : null };
|
|
55
|
+
});
|
|
56
|
+
const drops = dropped.map((c) => `DROP COLUMN ${q(c)}`);
|
|
57
|
+
const adds = added.map((a) => `ADD COLUMN ${q(a.name)} ${a.definition}${a.after ? ` AFTER ${q(a.after)}` : ' FIRST'}`);
|
|
58
|
+
const apply = [...(drops.length ? [`ALTER TABLE ${q(t.name)} ${drops.join(', ')}`] : []), ...(adds.length ? [`ALTER TABLE ${q(t.name)} ${adds.join(', ')}`] : [])];
|
|
59
|
+
operations.push({ table: t.name, kind: 'alter', added, dropped, ddl: `ALTER TABLE ${q(t.name)} ${[...drops, ...adds].join(', ')}`, apply, can_drop_data: dropped.length > 0 });
|
|
60
|
+
}
|
|
61
|
+
for (const [key, t] of before)
|
|
62
|
+
if (!now.has(key))
|
|
63
|
+
operations.push({ table: t.name, kind: 'drop', added: [], dropped: [], ddl: `DROP TABLE ${q(t.name)}`, apply: [`DROP TABLE ${q(t.name)}`], can_drop_data: true });
|
|
64
|
+
return { operations, lint };
|
|
65
|
+
}
|
|
66
|
+
/** Whether every operation can be deployed with ALGORITHM=INSTANT: "Adding or dropping a column (with some exceptions)"
|
|
67
|
+
* is; "Adding or dropping an index", a foreign key and a column's type are not
|
|
68
|
+
* (https://planetscale.com/docs/vitess/schema-changes/deploy-requests, "Supported operations"). Where the page stops: it
|
|
69
|
+
* does not list creating or dropping a table, which the twin reads as not instant. */
|
|
70
|
+
const instantEligible = (ops) => ops.length > 0 && ops.every((o) => o.kind === 'alter');
|
|
71
|
+
/** The lint error for a change the twin does not compute (a column's type or definition, a key, an index). */
|
|
72
|
+
function unsupportedChange(table) {
|
|
73
|
+
return { lint_error: 'UNSUPPORTED_BY_TWIN', table_name: table, error_description: `The twin does not compute this change to ${table}: only created and dropped tables, and added and dropped columns` };
|
|
74
|
+
}
|
|
75
|
+
/** The spec's Deployment for a deploy request as it stands. */
|
|
76
|
+
export function deploymentOf(dr) {
|
|
77
|
+
const ops = (dr._operations ?? []);
|
|
78
|
+
const state = String(dr.deployment_state);
|
|
79
|
+
const opState = ['complete', 'complete_pending_revert', 'complete_revert'].includes(state) ? 'complete'
|
|
80
|
+
: state === 'in_progress' ? 'in_progress' : state === 'queued' ? 'queued' : 'pending';
|
|
81
|
+
const at = String(dr.updated_at);
|
|
82
|
+
return {
|
|
83
|
+
id: String(dr._deployment_id), auto_cutover: dr._auto_cutover !== false, auto_delete_branch: dr._auto_delete_branch === true, created_at: String(dr.created_at),
|
|
84
|
+
cutover_at: dr._finished_at ?? null, cutover_expiring: false, deploy_check_errors: dr._lint?.length ? 'The deploy request is not deployable' : null,
|
|
85
|
+
finished_at: dr._finished_at ?? null, force_cutover_requested_at: null, queued_at: dr._queued_at ?? null,
|
|
86
|
+
ready_to_cutover_at: dr._finished_at ?? null, started_at: dr._started_at ?? null, state,
|
|
87
|
+
submitted_at: dr._queued_at ?? null, updated_at: at, into_branch: String(dr.into_branch),
|
|
88
|
+
deploy_request_number: Number(dr.number), deployable: dr._deployable === true, preceding_deployments: [],
|
|
89
|
+
deploy_operations: ops.map((o, i) => ({
|
|
90
|
+
id: `${String(dr._deployment_id)}-${i + 1}`, state: opState, keyspace_name: String(dr._database), table_name: o.table,
|
|
91
|
+
operation_name: o.kind === 'create' ? 'CREATE' : o.kind === 'drop' ? 'DROP' : 'ALTER', eta_seconds: 0,
|
|
92
|
+
progress_percentage: opState === 'complete' ? 100 : 0, deploy_error_docs_url: null, ddl_statement: o.ddl, syntax_highlighted_ddl: o.ddl,
|
|
93
|
+
created_at: String(dr.created_at), updated_at: at, throttled_at: null, can_drop_data: o.can_drop_data, table_locked: false,
|
|
94
|
+
table_recently_used: false, table_recently_used_at: null, removed_foreign_key_names: [], deploy_errors: null,
|
|
95
|
+
})),
|
|
96
|
+
deploy_operation_summaries: [], lint_errors: (dr._lint ?? []), sequential_diff_dependencies: [], lookup_vindex_operations: [],
|
|
97
|
+
throttler_configurations: null,
|
|
98
|
+
deployment_revert_request: state === 'complete_revert' ? { id: `${String(dr._deployment_id)}-revert`, state: 'complete', created_at: dr.closed_at } : null,
|
|
99
|
+
actor: dr.actor, cutover_actor: dr._finished_at ? dr.actor : null, cancelled_actor: null, schema_last_updated_at: at,
|
|
100
|
+
table_locked: false, locked_table_name: null, instant_ddl: dr._instant === true, instant_ddl_eligible: instantEligible(ops), queue_paused: false, queue_pause_reason: null,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
const requestsOf = (ctx, database) => ctx.rowsRaw('DatabaseDeployRequest').filter((d) => d._database === database);
|
|
104
|
+
/** The spec's DatabaseDeployRequest, with its deployment, from the stored row (a write's answer carries no bookkeeping). */
|
|
105
|
+
const view = (ctx, dr) => {
|
|
106
|
+
const raw = ctx.rowsRaw('DatabaseDeployRequest').find((d) => d.id === dr.id) ?? dr;
|
|
107
|
+
return { ...ctx.get('DatabaseDeployRequest', String(dr.id)), deployment: deploymentOf(raw) };
|
|
108
|
+
};
|
|
109
|
+
function createdDatabase(ctx) {
|
|
110
|
+
const name = String(ctx.call.params.database ?? '');
|
|
111
|
+
const db = ctx.rowsRaw('Database').find((d) => d.name === name && !d._deleted_at);
|
|
112
|
+
return db ? { db } : { refused: ctx.notFound('Database', name, 'database') };
|
|
113
|
+
}
|
|
114
|
+
function requestOf(ctx) {
|
|
115
|
+
const { refused, db } = createdDatabase(ctx);
|
|
116
|
+
if (refused)
|
|
117
|
+
return { refused };
|
|
118
|
+
const dr = requestsOf(ctx, String(db.name)).find((d) => String(d.number) === String(ctx.call.params.number));
|
|
119
|
+
return dr ? { db, dr } : { refused: ctx.notFound('DatabaseDeployRequest', String(ctx.call.params.number), 'number') };
|
|
120
|
+
}
|
|
121
|
+
/** A deploy request's audit event. */
|
|
122
|
+
async function drEvent(ctx, db, dr, action, actor) {
|
|
123
|
+
await audit(ctx, String(db._organization ?? ''), 'database_deploy_request', action, { type: 'DatabaseDeployRequest', id: String(dr.id), name: `#${String(dr.number)}` }, { actor, auditable: { type: 'Database', id: String(db.id), name: String(db.name) } });
|
|
124
|
+
}
|
|
125
|
+
/** Who is acting: the calling token's id, or the person a page names (screens pass it). */
|
|
126
|
+
export const actingAs = (ctx) => /^([^:\s]+):/.exec(ctx.call.request.headers.get('authorization') ?? '')?.[1] ?? 'world';
|
|
127
|
+
async function createDeployRequest(ctx) {
|
|
128
|
+
const { refused, db } = createdDatabase(ctx);
|
|
129
|
+
if (refused)
|
|
130
|
+
return refused;
|
|
131
|
+
const database = String(db.name);
|
|
132
|
+
const b = body(ctx);
|
|
133
|
+
const branchName = typeof b.branch === 'string' ? b.branch : '';
|
|
134
|
+
const intoName = typeof b.into_branch === 'string' ? b.into_branch : '';
|
|
135
|
+
if (!branchName || !intoName)
|
|
136
|
+
return unprocessable(ctx, 'branch and into_branch are required');
|
|
137
|
+
const branch = branchesOf(ctx, database).find((x) => x.name === branchName);
|
|
138
|
+
if (!branch)
|
|
139
|
+
return ctx.notFound('DatabaseBranch', branchName, 'branch');
|
|
140
|
+
const into = branchesOf(ctx, database).find((x) => x.name === intoName);
|
|
141
|
+
if (!into)
|
|
142
|
+
return ctx.notFound('DatabaseBranch', intoName, 'into_branch');
|
|
143
|
+
if (branch.id === into.id)
|
|
144
|
+
return unprocessable(ctx, 'A branch cannot be deployed into itself');
|
|
145
|
+
// a gated deployment (auto_cutover false, "Apply changes" by hand) is not modelled: complete_gated_deploy_request is the gap
|
|
146
|
+
if (b.auto_cutover === false)
|
|
147
|
+
return unprocessable(ctx, 'Gated deployments (auto_cutover false) are not modelled by the twin');
|
|
148
|
+
if (into.safe_migrations !== true)
|
|
149
|
+
return unprocessable(ctx, `Safe migrations must be enabled on ${intoName} before you can create a deploy request into it`);
|
|
150
|
+
if (requestsOf(ctx, database).some((d) => d.branch === branchName && d.state === 'open'))
|
|
151
|
+
return unprocessable(ctx, `An open deploy request already exists for ${branchName}`);
|
|
152
|
+
const { operations, lint } = await diffOf(ctx, database, branch);
|
|
153
|
+
const number = requestsOf(ctx, database).length + 1;
|
|
154
|
+
const id = mintId(ctx, 'DatabaseDeployRequest', 'dr', `${database}/${number}`);
|
|
155
|
+
const org = String(ctx.call.params.organization);
|
|
156
|
+
const at = ctx.occurredAt;
|
|
157
|
+
const notes = typeof b.notes === 'string' ? b.notes : '';
|
|
158
|
+
const record = await ctx.write('DatabaseDeployRequest', id, {
|
|
159
|
+
id, number, actor: actorOf(ctx), closed_by: null, branch: branchName, branch_id: String(branch.id), branch_deleted: false,
|
|
160
|
+
branch_deleted_by: null, branch_deleted_at: null, into_branch: intoName, into_branch_sharded: false, into_branch_shard_count: 1,
|
|
161
|
+
into_branch_keyspace_count: 1, approved: false, state: 'open', deployment_state: 'pending', num_comments: 0,
|
|
162
|
+
html_url: `https://app.planetscale.com/${org}/${database}/deploy-requests/${number}`, notes, html_body: notes ? `<p>${notes.replace(/[<&>]/g, (c) => ({ '<': '<', '&': '&', '>': '>' })[c])}</p>` : '',
|
|
163
|
+
created_at: at, updated_at: at, closed_at: null, deployed_at: null,
|
|
164
|
+
_database: database, _deployment_id: mintId(ctx, 'DatabaseDeployRequest', 'dp', `${database}/${number}/deployment`), _creator: actingAs(ctx),
|
|
165
|
+
_operations: operations, _lint: lint, _deployable: lint.length === 0 && operations.length > 0,
|
|
166
|
+
// "When enabled, will delete the branch once the DR successfully completes" (the spec's auto_delete_branch)
|
|
167
|
+
_auto_cutover: b.auto_cutover !== false, _auto_delete_branch: b.auto_delete_branch === true,
|
|
168
|
+
}, 'deploy_request.create');
|
|
169
|
+
await drEvent(ctx, db, record, 'created', callActor(ctx));
|
|
170
|
+
return ctx.reply(view(ctx, record), 201);
|
|
171
|
+
}
|
|
172
|
+
async function getDeployRequest(ctx) {
|
|
173
|
+
const { refused, dr } = requestOf(ctx);
|
|
174
|
+
return refused ?? ctx.reply(view(ctx, dr));
|
|
175
|
+
}
|
|
176
|
+
async function getDeployment(ctx) {
|
|
177
|
+
const { refused, dr } = requestOf(ctx);
|
|
178
|
+
return refused ?? ctx.reply(deploymentOf(dr));
|
|
179
|
+
}
|
|
180
|
+
async function listDeployOperations(ctx) {
|
|
181
|
+
const { refused, dr } = requestOf(ctx);
|
|
182
|
+
return refused ?? page(ctx, deploymentOf(dr).deploy_operations);
|
|
183
|
+
}
|
|
184
|
+
/** list_deploy_requests, newest first (the twin's order), filtered by `state`, `branch` and `into_branch`. */
|
|
185
|
+
async function listDeployRequests(ctx) {
|
|
186
|
+
const { refused, db } = createdDatabase(ctx);
|
|
187
|
+
if (refused)
|
|
188
|
+
return refused;
|
|
189
|
+
const p = new URL(ctx.call.request.url).searchParams;
|
|
190
|
+
const rows = requestsOf(ctx, String(db.name)).filter((d) => (!p.get('state') || d.state === p.get('state'))
|
|
191
|
+
&& (!p.get('branch') || d.branch === p.get('branch')) && (!p.get('into_branch') || d.into_branch === p.get('into_branch')));
|
|
192
|
+
return page(ctx, [...rows].sort((a, b) => Number(b.number) - Number(a.number)).map((d) => view(ctx, d)));
|
|
193
|
+
}
|
|
194
|
+
/** The reviews of a deploy request (bookkeeping `_deploy_review` records), oldest first. */
|
|
195
|
+
export const reviewsOf = (ctx, dr) => ctx.tree().filter((r) => r.type === '_deploy_review').map((r) => ({ ...ctx.own(r), id: String(r.id) })).filter((r) => r.deploy_request === dr.id);
|
|
196
|
+
/**
|
|
197
|
+
* Record a review. `reviewer` is who approves: a token's id, or `person:<email>` from the deploy request's page;
|
|
198
|
+
* `actor` is the spec's actor object. With approval required, a creator cannot approve its own request, nor a person the
|
|
199
|
+
* token that created it (`_created_by` on the token, set on the service tokens page).
|
|
200
|
+
*/
|
|
201
|
+
export async function recordReview(ctx, db, dr, reviewer, actor, state, text, auditActor) {
|
|
202
|
+
if (dr.state !== 'open')
|
|
203
|
+
return unprocessable(ctx, 'The deploy request is closed');
|
|
204
|
+
if (state === 'approved' && db.require_approval_for_deploy === true) {
|
|
205
|
+
if (reviewer === dr._creator)
|
|
206
|
+
return unprocessable(ctx, 'You cannot approve your own deploy request');
|
|
207
|
+
const creatorToken = ctx.rowsRaw('ServiceToken').find((t) => t.id === dr._creator);
|
|
208
|
+
if (creatorToken && reviewer === `person:${String(creatorToken._created_by ?? '')}`)
|
|
209
|
+
return unprocessable(ctx, 'You cannot approve a deploy request created by a service token you created');
|
|
210
|
+
}
|
|
211
|
+
const at = ctx.occurredAt;
|
|
212
|
+
const id = `${String(dr.id)}-review-${reviewsOf(ctx, dr).length + 1}`;
|
|
213
|
+
const review = { id, body: text, html_body: text ? `<p>${text}</p>` : '', state, created_at: at, updated_at: at, actor, deploy_request: dr.id };
|
|
214
|
+
await ctx.record('_deploy_review', review, id);
|
|
215
|
+
if (state === 'approved' && dr.approved !== true)
|
|
216
|
+
await ctx.write('DatabaseDeployRequest', String(dr.id), { ...ctx.own(dr), approved: true, updated_at: at }, 'deploy_request.approve');
|
|
217
|
+
if (state === 'approved')
|
|
218
|
+
await audit(ctx, String(db._organization ?? ''), 'deploy_request_review', 'approved', { type: 'DeployRequestReview', id, name: `#${String(dr.number)}` }, { actor: auditActor, auditable: { type: 'Database', id: String(db.id), name: String(db.name) } });
|
|
219
|
+
const { deploy_request: _dr, ...answer } = review;
|
|
220
|
+
return answer;
|
|
221
|
+
}
|
|
222
|
+
async function reviewDeployRequest(ctx) {
|
|
223
|
+
const { refused, db, dr } = requestOf(ctx);
|
|
224
|
+
if (refused)
|
|
225
|
+
return refused;
|
|
226
|
+
const b = body(ctx);
|
|
227
|
+
const state = b.state === undefined ? 'commented' : String(b.state);
|
|
228
|
+
if (state !== 'approved' && state !== 'commented')
|
|
229
|
+
return unprocessable(ctx, 'state must be approved or commented');
|
|
230
|
+
const held = heldAccesses(ctx, String(db.name));
|
|
231
|
+
if (held && state === 'approved' && !held.has('approve_deploy_request'))
|
|
232
|
+
return ctx.refuse({ status: 403, code: 'forbidden', message: 'Forbidden: approving needs approve_deploy_request' });
|
|
233
|
+
const out = await recordReview(ctx, db, dr, actingAs(ctx), actorOf(ctx), state, typeof b.body === 'string' ? b.body : '', callActor(ctx));
|
|
234
|
+
return out instanceof Response ? out : ctx.reply(out, 201);
|
|
235
|
+
}
|
|
236
|
+
async function listReviews(ctx) {
|
|
237
|
+
const { refused, dr } = requestOf(ctx);
|
|
238
|
+
if (refused)
|
|
239
|
+
return refused;
|
|
240
|
+
return page(ctx, reviewsOf(ctx, dr).map(({ deploy_request: _d, ...r }) => r));
|
|
241
|
+
}
|
|
242
|
+
/** queue_deploy_request: a deployable request, approved where the database requires it, joins the serial queue. */
|
|
243
|
+
async function queueDeployRequest(ctx) {
|
|
244
|
+
const { refused, db, dr } = requestOf(ctx);
|
|
245
|
+
if (refused)
|
|
246
|
+
return refused;
|
|
247
|
+
if (dr.state !== 'open')
|
|
248
|
+
return unprocessable(ctx, 'The deploy request is closed');
|
|
249
|
+
if (dr.deployment_state !== 'ready')
|
|
250
|
+
return unprocessable(ctx, dr.deployment_state === 'pending' ? 'The deploy request is still being checked' : `The deploy request cannot be deployed from ${String(dr.deployment_state)}`);
|
|
251
|
+
if (db.require_approval_for_deploy === true && dr.approved !== true)
|
|
252
|
+
return unprocessable(ctx, 'The deploy request must be approved before it can be deployed');
|
|
253
|
+
const b = body(ctx);
|
|
254
|
+
// "Instant deployments will apply schema changes faster, however, these schema changes must be auto-applied and
|
|
255
|
+
// cannot be reverted"; "all schema changes in the deploy request must be instantly deployable" (the deploy-requests page)
|
|
256
|
+
const instant = b.instant_ddl === true;
|
|
257
|
+
if (instant && !instantEligible((dr._operations ?? [])))
|
|
258
|
+
return unprocessable(ctx, 'The deploy request is not instantly deployable');
|
|
259
|
+
const refusal = ctx.legal('DatabaseDeployRequest', 'deployment_state', 'queue_deploy_request', 'ready', 'queued', String(dr.id));
|
|
260
|
+
if (refusal)
|
|
261
|
+
return ctx.refuse(refusal);
|
|
262
|
+
const at = ctx.occurredAt;
|
|
263
|
+
const record = await ctx.write('DatabaseDeployRequest', String(dr.id), { ...ctx.own(dr), deployment_state: 'queued', updated_at: at, _queued_at: at, ...(instant ? { _instant: true } : {}) }, 'deploy_request.queue');
|
|
264
|
+
return ctx.reply(view(ctx, record));
|
|
265
|
+
}
|
|
266
|
+
async function closeDeployRequest(ctx) {
|
|
267
|
+
const { refused, dr } = requestOf(ctx);
|
|
268
|
+
if (refused)
|
|
269
|
+
return refused;
|
|
270
|
+
if (body(ctx).state !== 'closed')
|
|
271
|
+
return unprocessable(ctx, 'state must be closed');
|
|
272
|
+
if (dr.state !== 'open')
|
|
273
|
+
return unprocessable(ctx, 'The deploy request is already closed');
|
|
274
|
+
if (['queued', 'in_progress', 'complete_pending_revert'].includes(String(dr.deployment_state)))
|
|
275
|
+
return unprocessable(ctx, 'A deploy request being deployed cannot be closed');
|
|
276
|
+
const refusal = ctx.legal('DatabaseDeployRequest', 'state', 'close_deploy_request', 'open', 'closed', String(dr.id));
|
|
277
|
+
if (refusal)
|
|
278
|
+
return ctx.refuse(refusal);
|
|
279
|
+
const at = ctx.occurredAt;
|
|
280
|
+
const record = await ctx.write('DatabaseDeployRequest', String(dr.id), { ...ctx.own(dr), state: 'closed', closed_at: at, closed_by: actorOf(ctx), updated_at: at }, 'deploy_request.close');
|
|
281
|
+
await drEvent(ctx, ctx.rowsRaw('Database').find((d) => d.name === dr._database), record, 'closed', callActor(ctx));
|
|
282
|
+
return ctx.reply(view(ctx, record));
|
|
283
|
+
}
|
|
284
|
+
/** Run statements against the base branch through the pack root's SQL, in one session closed after. */
|
|
285
|
+
async function runOnBase(ctx, database, into, at, statements) {
|
|
286
|
+
const scope = branchScope(database, into);
|
|
287
|
+
const sctx = { ...storeCtx(ctx, at), ...(scope ? { scope } : {}) };
|
|
288
|
+
let session = null;
|
|
289
|
+
for (const sql of statements)
|
|
290
|
+
session = (await executeSql(sctx, sql, session)).session.id;
|
|
291
|
+
if (session)
|
|
292
|
+
await closeSession(sctx, session);
|
|
293
|
+
}
|
|
294
|
+
/** Deploy a request's diff to its base at `at`, saving what a revert needs (each dropped column's values by primary key,
|
|
295
|
+
* each dropped table's definition and rows). */
|
|
296
|
+
export async function applyDeploy(ctx, dr, at) {
|
|
297
|
+
const database = String(dr._database);
|
|
298
|
+
const into = String(dr.into_branch);
|
|
299
|
+
const scope = branchScope(database, into);
|
|
300
|
+
const image = scopeImage(ctx.root, scope);
|
|
301
|
+
const saved = [];
|
|
302
|
+
const statements = [];
|
|
303
|
+
for (const op of (dr._operations ?? [])) {
|
|
304
|
+
const table = image.tables.find((t) => tableKey(t.name) === tableKey(op.table));
|
|
305
|
+
const rows = image.rows.find((r) => tableKey(r.table) === tableKey(op.table))?.rows ?? [];
|
|
306
|
+
if (op.kind === 'drop' && table)
|
|
307
|
+
saved.push(await savedDroppedTable(ctx, scope, op.table, table.name, rows));
|
|
308
|
+
if (op.kind === 'alter' && table && op.dropped.length) {
|
|
309
|
+
const create = await showCreate(ctx, scope, table.name);
|
|
310
|
+
saved.push({
|
|
311
|
+
table: op.table, kind: 'columns', primaryKey: table.primaryKey,
|
|
312
|
+
columns: op.dropped.map((c) => {
|
|
313
|
+
const i = table.columns.findIndex((x) => x.name.toLowerCase() === c.toLowerCase());
|
|
314
|
+
const def = table.columns[i];
|
|
315
|
+
return { name: def.name, definition: columnLine(create, def.name), after: i > 0 ? table.columns[i - 1].name : null, nullable: def.nullable !== false, hasDefault: def.defaultValue !== undefined || def.defaultCurrentTimestamp !== undefined };
|
|
316
|
+
}),
|
|
317
|
+
rows: rows.map((r) => ({ key: Object.fromEntries(table.primaryKey.map((k) => [k, r.cells[k] ?? null])), values: Object.fromEntries(op.dropped.map((c) => [c, r.cells[table.columns.find((x) => x.name.toLowerCase() === c.toLowerCase()).name] ?? null])) })),
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
statements.push(...op.apply);
|
|
321
|
+
}
|
|
322
|
+
await runOnBase(ctx, database, into, at, statements);
|
|
323
|
+
return { ...ctx.own(dr), _saved: saved };
|
|
324
|
+
}
|
|
325
|
+
/** What a revert needs of a table the deploy drops: its definition and its rows. */
|
|
326
|
+
async function savedDroppedTable(ctx, scope, table, name, rows) {
|
|
327
|
+
return { table, kind: 'drop', create: await showCreate(ctx, scope, name), rows: rows.map((r) => r.cells) };
|
|
328
|
+
}
|
|
329
|
+
/** A dropped table put back as it was: "the status of the two tables is flipped, bringing the former table back"
|
|
330
|
+
* (https://planetscale.com/docs/vitess/schema-changes/safe-migrations, "Schema revert"). */
|
|
331
|
+
function revertDroppedTable(table, saved) {
|
|
332
|
+
const s = saved.find((x) => x.kind === 'drop' && x.table === table);
|
|
333
|
+
if (!s)
|
|
334
|
+
return [];
|
|
335
|
+
return [String(s.create), ...s.rows.map((cells) => {
|
|
336
|
+
const cols = Object.keys(cells);
|
|
337
|
+
return `INSERT INTO ${q(table)} (${cols.map(q).join(', ')}) VALUES (${cols.map((c) => quoteSqlLiteral(cells[c] ?? null)).join(', ')})`;
|
|
338
|
+
})];
|
|
339
|
+
}
|
|
340
|
+
/** The statements that undo a deployment, or the reason the page says the revert fails. */
|
|
341
|
+
function revertPlan(ctx, dr) {
|
|
342
|
+
const database = String(dr._database);
|
|
343
|
+
const image = scopeImage(ctx.root, branchScope(database, String(dr.into_branch)));
|
|
344
|
+
const statements = [];
|
|
345
|
+
const saved = (dr._saved ?? []);
|
|
346
|
+
for (const op of [...(dr._operations ?? [])].reverse()) {
|
|
347
|
+
if (op.kind === 'create') {
|
|
348
|
+
statements.push(`DROP TABLE ${q(op.table)}`);
|
|
349
|
+
continue;
|
|
350
|
+
}
|
|
351
|
+
if (op.kind === 'drop') {
|
|
352
|
+
statements.push(...revertDroppedTable(op.table, saved));
|
|
353
|
+
continue;
|
|
354
|
+
}
|
|
355
|
+
// an altered table: drop what the deploy added (its data is not retained), re-add what it dropped with the values
|
|
356
|
+
// the rows had; rows inserted since take the column's default, or NULL
|
|
357
|
+
const s = saved.find((x) => x.kind === 'columns' && x.table === op.table);
|
|
358
|
+
const current = image.rows.find((r) => tableKey(r.table) === tableKey(op.table))?.rows ?? [];
|
|
359
|
+
const pk = (s?.primaryKey ?? []);
|
|
360
|
+
const keyOf = (cells) => JSON.stringify(pk.map((k) => cells[k] ?? null));
|
|
361
|
+
const known = new Set((s?.rows ?? []).map((r) => keyOf(r.key)));
|
|
362
|
+
const inserted = current.some((r) => !known.has(keyOf(r.cells)));
|
|
363
|
+
for (const c of (s?.columns ?? [])) {
|
|
364
|
+
if (!c.nullable && !c.hasDefault && inserted)
|
|
365
|
+
return { refused: `The revert cannot proceed: ${op.table}.${c.name} was NOT NULL without a DEFAULT and rows have been inserted since the deployment` };
|
|
366
|
+
}
|
|
367
|
+
const drops = op.added.map((a) => `DROP COLUMN ${q(a.name)}`);
|
|
368
|
+
const adds = (s?.columns ?? []).map((c) => `ADD COLUMN ${q(c.name)} ${c.definition}${c.after ? ` AFTER ${q(c.after)}` : ' FIRST'}`);
|
|
369
|
+
if (drops.length)
|
|
370
|
+
statements.push(`ALTER TABLE ${q(op.table)} ${drops.join(', ')}`);
|
|
371
|
+
if (adds.length)
|
|
372
|
+
statements.push(`ALTER TABLE ${q(op.table)} ${adds.join(', ')}`);
|
|
373
|
+
for (const r of (s?.rows ?? [])) {
|
|
374
|
+
if (!current.some((x) => keyOf(x.cells) === keyOf(r.key)))
|
|
375
|
+
continue;
|
|
376
|
+
const sets = Object.entries(r.values).map(([c, v]) => `${q(c)} = ${quoteSqlLiteral(v)}`).join(', ');
|
|
377
|
+
if (sets)
|
|
378
|
+
statements.push(`UPDATE ${q(op.table)} SET ${sets} WHERE ${pk.map((k) => `${q(k)} = ${quoteSqlLiteral(r.key[k] ?? null)}`).join(' AND ')}`);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
return { statements };
|
|
382
|
+
}
|
|
383
|
+
async function completeRevert(ctx) {
|
|
384
|
+
const { refused, dr } = requestOf(ctx);
|
|
385
|
+
if (refused)
|
|
386
|
+
return refused;
|
|
387
|
+
if (dr.deployment_state !== 'complete_pending_revert')
|
|
388
|
+
return unprocessable(ctx, 'Only a deployment within its 30-minute revert window can be reverted');
|
|
389
|
+
const refusal = ctx.legal('DatabaseDeployRequest', 'deployment_state', 'complete_revert', 'complete_pending_revert', 'complete_revert', String(dr.id));
|
|
390
|
+
if (refusal)
|
|
391
|
+
return ctx.refuse(refusal);
|
|
392
|
+
const closing = ctx.legal('DatabaseDeployRequest', 'state', 'complete_revert', 'open', 'closed', String(dr.id));
|
|
393
|
+
if (closing)
|
|
394
|
+
return ctx.refuse(closing);
|
|
395
|
+
const plan = revertPlan(ctx, dr);
|
|
396
|
+
if ('refused' in plan)
|
|
397
|
+
return unprocessable(ctx, plan.refused);
|
|
398
|
+
const at = ctx.occurredAt;
|
|
399
|
+
await runOnBase(ctx, String(dr._database), String(dr.into_branch), at, plan.statements);
|
|
400
|
+
const record = await ctx.write('DatabaseDeployRequest', String(dr.id), {
|
|
401
|
+
...ctx.own(dr), deployment_state: 'complete_revert', state: 'closed', closed_at: at, closed_by: actorOf(ctx), updated_at: at, _saved: [],
|
|
402
|
+
}, 'deploy_request.revert');
|
|
403
|
+
await drEvent(ctx, ctx.rowsRaw('Database').find((d) => d.name === dr._database), record, 'closed', callActor(ctx));
|
|
404
|
+
return ctx.reply(view(ctx, record));
|
|
405
|
+
}
|
|
406
|
+
/** What the World clock does to deploy requests (semantics/time.ts calls it): a check finishes, the serial queue starts
|
|
407
|
+
* the next deploy when none runs, a deploy finishes into its revert window, and the window closes. */
|
|
408
|
+
/** A deployment made permanent: the request's closing event, and its branch deleted when it asked for that. */
|
|
409
|
+
async function completed(ctx, id) {
|
|
410
|
+
const dr = ctx.rowsRaw('DatabaseDeployRequest').find((d) => d.id === id);
|
|
411
|
+
const db = ctx.rowsRaw('Database').find((d) => d.name === dr._database);
|
|
412
|
+
if (db)
|
|
413
|
+
await drEvent(ctx, db, dr, 'closed', SYSTEM);
|
|
414
|
+
if (dr._auto_delete_branch !== true)
|
|
415
|
+
return;
|
|
416
|
+
const branch = branchesOf(ctx, String(dr._database)).find((b) => b.name === dr.branch);
|
|
417
|
+
if (branch)
|
|
418
|
+
await removeBranch(ctx, String(dr._database), branch, SYSTEM);
|
|
419
|
+
}
|
|
420
|
+
export async function catchUpDeployRequests(ctx) {
|
|
421
|
+
const now = Date.parse(ctx.occurredAt);
|
|
422
|
+
for (;;) {
|
|
423
|
+
const events = [];
|
|
424
|
+
const all = ctx.rowsRaw('DatabaseDeployRequest');
|
|
425
|
+
for (const dr of all) {
|
|
426
|
+
if (dr.deployment_state === 'pending')
|
|
427
|
+
events.push({ at: later(String(dr.created_at), CHECK_AFTER), dr, kind: 'check' });
|
|
428
|
+
if (dr.deployment_state === 'in_progress')
|
|
429
|
+
events.push({ at: later(String(dr._started_at), RUN_FOR), dr, kind: 'finish' });
|
|
430
|
+
if (dr.deployment_state === 'complete_pending_revert')
|
|
431
|
+
events.push({ at: later(String(dr._finished_at), REVERT_WINDOW), dr, kind: 'window' });
|
|
432
|
+
if (dr.deployment_state === 'queued') {
|
|
433
|
+
const running = all.filter((x) => x._database === dr._database && x.into_branch === dr.into_branch && x.deployment_state === 'in_progress');
|
|
434
|
+
const ahead = all.some((x) => x._database === dr._database && x.into_branch === dr.into_branch && x.deployment_state === 'queued' && String(x._queued_at) < String(dr._queued_at));
|
|
435
|
+
if (!running.length && !ahead) {
|
|
436
|
+
const lastFinish = all.filter((x) => x._database === dr._database && x.into_branch === dr.into_branch && typeof x._finished_at === 'string').map((x) => String(x._finished_at)).sort().at(-1);
|
|
437
|
+
events.push({ at: [String(dr._queued_at), lastFinish ?? ''].sort().at(-1), dr, kind: 'start' });
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
const next = events.filter((e) => Date.parse(e.at) <= now).sort((a, b) => a.at.localeCompare(b.at))[0];
|
|
442
|
+
if (!next)
|
|
443
|
+
return;
|
|
444
|
+
const at = await ctx.at(next.at);
|
|
445
|
+
const dr = next.dr;
|
|
446
|
+
const id = String(dr.id);
|
|
447
|
+
if (next.kind === 'check') {
|
|
448
|
+
const to = (dr._operations ?? []).length === 0 && !(dr._lint ?? []).length ? 'no_changes' : 'ready';
|
|
449
|
+
if (at.legal('DatabaseDeployRequest', 'deployment_state', 'check', 'pending', to, id, 'time'))
|
|
450
|
+
return;
|
|
451
|
+
await at.write('DatabaseDeployRequest', id, { ...at.own(dr), deployment_state: to, updated_at: next.at }, 'deploy_request.check');
|
|
452
|
+
}
|
|
453
|
+
else if (next.kind === 'start') {
|
|
454
|
+
if (at.legal('DatabaseDeployRequest', 'deployment_state', 'start', 'queued', 'in_progress', id, 'time'))
|
|
455
|
+
return;
|
|
456
|
+
await at.write('DatabaseDeployRequest', id, { ...at.own(dr), deployment_state: 'in_progress', updated_at: next.at, _started_at: next.at }, 'deploy_request.start');
|
|
457
|
+
}
|
|
458
|
+
else if (next.kind === 'finish') {
|
|
459
|
+
const instant = dr._instant === true;
|
|
460
|
+
const to = instant ? 'complete' : 'complete_pending_revert';
|
|
461
|
+
if (at.legal('DatabaseDeployRequest', 'deployment_state', 'finish', 'in_progress', to, id, 'time'))
|
|
462
|
+
return;
|
|
463
|
+
if (instant && at.legal('DatabaseDeployRequest', 'state', 'finish', 'open', 'closed', id, 'time'))
|
|
464
|
+
return;
|
|
465
|
+
const applied = await applyDeploy(at, dr, next.at);
|
|
466
|
+
await at.write('DatabaseDeployRequest', id, { ...applied, deployment_state: to, deployed_at: next.at, updated_at: next.at, _finished_at: next.at, ...(instant ? { state: 'closed', closed_at: next.at, _saved: [] } : {}) }, 'deploy_request.deploy');
|
|
467
|
+
if (instant)
|
|
468
|
+
await completed(at, id);
|
|
469
|
+
}
|
|
470
|
+
else {
|
|
471
|
+
if (at.legal('DatabaseDeployRequest', 'deployment_state', 'close_window', 'complete_pending_revert', 'complete', id, 'time'))
|
|
472
|
+
return;
|
|
473
|
+
if (at.legal('DatabaseDeployRequest', 'state', 'close_window', 'open', 'closed', id, 'time'))
|
|
474
|
+
return;
|
|
475
|
+
await at.write('DatabaseDeployRequest', id, { ...at.own(dr), deployment_state: 'complete', state: 'closed', closed_at: next.at, updated_at: next.at, _saved: [] }, 'deploy_request.complete');
|
|
476
|
+
await completed(at, id);
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
export const deployRequestSemantics = {
|
|
481
|
+
create_deploy_request: createDeployRequest,
|
|
482
|
+
get_deploy_request: getDeployRequest,
|
|
483
|
+
list_deploy_requests: listDeployRequests,
|
|
484
|
+
get_deployment: getDeployment,
|
|
485
|
+
list_deploy_operations: listDeployOperations,
|
|
486
|
+
review_deploy_request: reviewDeployRequest,
|
|
487
|
+
list_deploy_request_reviews: listReviews,
|
|
488
|
+
queue_deploy_request: queueDeployRequest,
|
|
489
|
+
close_deploy_request: closeDeployRequest,
|
|
490
|
+
complete_revert: completeRevert,
|
|
491
|
+
};
|