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