@mason-data/cli 0.9.0 → 0.10.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.
Files changed (41) hide show
  1. package/README.md +6 -4
  2. package/dist/src/api/error.js +4 -0
  3. package/dist/src/api/postgres/proxy.js +6 -160
  4. package/dist/src/api/postgres/server.js +22 -4
  5. package/dist/src/cli/command.js +169 -5
  6. package/dist/src/cli/commands.js +290 -157
  7. package/dist/src/cli/deploy.js +134 -28
  8. package/dist/src/cli/doctor.js +14 -11
  9. package/dist/src/cli/guides.js +20 -11
  10. package/dist/src/cli/hosted.js +1 -1
  11. package/dist/src/cli/init.js +110 -25
  12. package/dist/src/cli/login.js +20 -17
  13. package/dist/src/cli/machine.js +125 -40
  14. package/dist/src/cli/main.js +51 -8
  15. package/dist/src/cli/mcp.js +15 -12
  16. package/dist/src/cli/project.js +1 -1
  17. package/dist/src/cli/registry.js +80 -35
  18. package/dist/src/cli/replica-hosted.js +761 -102
  19. package/dist/src/cli/replica.js +43 -5
  20. package/dist/src/cli/replication-mode.js +5 -0
  21. package/dist/src/cli/request-filter.js +16 -6
  22. package/dist/src/cli/run-server.js +25 -2
  23. package/dist/src/cli/secret-input.js +193 -0
  24. package/dist/src/cli/serve.js +2 -2
  25. package/dist/src/cli/share.js +20 -15
  26. package/dist/src/cli/source-role.js +14 -15
  27. package/dist/src/cli/sources.js +20 -3
  28. package/dist/src/cli/style.js +14 -0
  29. package/dist/src/cli/sync.js +17 -8
  30. package/dist/src/cli/token-hosted.js +30 -24
  31. package/dist/src/cli/tunnel.js +1 -1
  32. package/dist/src/cli/ui-hosted.js +163 -0
  33. package/dist/src/cli/ui-ssh.js +3 -3
  34. package/dist/src/cli/ui.js +67 -26
  35. package/dist/src/cli/usage.js +19 -15
  36. package/dist/src/cli/user-hosted.js +78 -13
  37. package/dist/src/cli/user.js +168 -50
  38. package/dist/src/duck/users.js +105 -9
  39. package/dist/src/lib/cidr.js +177 -0
  40. package/dist/src/lib/table-sync.js +69 -19
  41. package/package.json +6 -6
@@ -1,17 +1,31 @@
1
1
  /**
2
- * `mason replica create | list | status` — A HOSTED MASON SERVICE'S REPLICAS, by its documented API alone
2
+ * `mason replica create | list | status | delete` — A HOSTED MASON SERVICE'S REPLICAS, by its documented API alone
3
3
  * (docs/HOSTED-API.md "Replicas"). Each is a client of it, as `mason deploy` and `mason user --replica` are:
4
4
  *
5
5
  * create <org>/<name> POST /v1/replicas/{org} { name, region, sizeGb, sources: [{ name, variable, url }] }
6
6
  * 201 { replica, checks }, then GET …/{name} until it is ready, failed or past --timeout
7
7
  * list [<org>] GET /v1/replicas/{org} { replicas, more } — each organisation of the login's when none is named
8
- * status <org>/<name> GET /v1/replicas/{org}/{name} { replica }
8
+ * status <org>/<name> GET /v1/replicas/{org}/{name} { replica } (with its deletion, while it is deleted)
9
+ * delete <org>/<name> DELETE /v1/replicas/{org}/{name} { confirm: "<org>/<name>" } 202 (or 200, done already)
10
+ * { replica }, then GET …/{name} until its deletion is finished, needs a person (a slot not
11
+ * verified gone: it stops at once, exit 1) or is past --timeout
9
12
  *
10
- * THE DATABASE'S URL (create) is read here alone, by the variable the project's sources file names (`url: ${NAME}`,
11
- * src/cli/sources.ts `readSource`, the one reader: `.env.local`, `.env`, then the shell), never from a flag: it never
12
- * reaches a command line, a log, the output or --json. It is sent once, in the creation's body, for Mason's checks of
13
- * the source on the replica's side, and the variable's NAME beside it, which the replica reads it by. A `url:` written
14
- * in the file is refused: the replica has no variable to read it from, and a literal would be kept in the project's files.
13
+ * A DELETION'S SQL IS MASON'S OWN (docs/HOSTED-API.md, `DELETE /v1/replicas/{org}/{name}` and `DeletionSource`): for a source whose replication slot was not
14
+ * verified gone, the statement a person runs by hand is rendered here, by `dropStatements` (src/cli/replication-mode.ts,
15
+ * the one authority), from the slot's and the publication's names the service gives, each held to Postgres's name
16
+ * grammar (`PG_NAME`); a name it does not know is `listSlotsStatement`'s query. The service's own `byHand` is advisory:
17
+ * put to the exact test with the rest of the answer, never compared, never printed (#687's review, B1). EVERY statement a
18
+ * person pastes is written by `pasteLines` alone, on a line of its own, never broken by the CLI and never inside a
19
+ * failure's words (N1).
20
+ *
21
+ * THE DATABASE'S URL (create) is read here alone, from the person or the pipe (src/cli/secret-input.ts, issue 720): on a
22
+ * terminal a hidden prompt (the question on stderr, nothing echoed), else stdin read whole (2 KiB at most, one trailing
23
+ * newline trimmed). Never from a flag, an argument, .env.local, .env or the shell: a command line holding a URL is refused
24
+ * before anything else is read (`urlInArgs`, exit 2, the line to run instead), and the sources file gives only the
25
+ * variable's NAME (`url: ${NAME}`, src/cli/sources.ts `readSourceFile`, never its value), which the replica reads the
26
+ * URL by. It never reaches a log, the output or --json. It is sent once, in the creation's body, for Mason's checks of
27
+ * the source on the replica's side, the variable's name beside it. A `url:` written in the file is refused: the replica
28
+ * has no variable to read it from, and a literal would be kept in the project's files.
15
29
  * Its password is held as a secret of this command beside the token, in the one list every boundary reads
16
30
  * (`urlPassword`): masked in free text, refused in any other value and by the output's gate, read whole whatever its
17
31
  * alphabet; one under `MIN_PASSWORD` characters is not sent, since none of them could tell it from ordinary words.
@@ -30,25 +44,40 @@
30
44
  * state, outcome, status, change rows (the CLI's words), --json closed sets (`STATES`, …)
31
45
  * createdAt, nextAttemptAt rows, --json `isServiceTime`
32
46
  * allowlist a row, --json each `CIDR`, at most `MAX_ALLOWLIST`
47
+ * page a row, --json on the service's own origin (`pageUrl`)
33
48
  * a check's name, detail, fix; missing; stderr's lines, --json one line each, bounded, masked (`serviceText`)
34
49
  * a pending change's failure
35
50
  * a check's source stderr's lines, --json the source sent
51
+ * a deletion's stage, outcome, reason rows, lines (the CLI's words), --json closed sets (`STAGES`, …)
52
+ * a deletion's times, a step's doneAt rows, lines, --json `isServiceTime`
53
+ * a step's id --json `STEP_ID`, each once
54
+ * a step's what, a source's why, rows, stderr's lines, --json one line each, bounded, masked (`serviceText`)
55
+ * the handover's failure
56
+ * a source's slot, publication rows, the SQL's line, --json `PG_NAME`; Mason's own publication `OWN_PUBLICATION`
57
+ * a source's byHand nowhere: Mason's own rendering is a string or nothing, the exact test; never read
36
58
  *
37
- * What to run next, the code and the exit are the CLI's own: 0 once the replica is ready (create) or said (list,
38
- * status); 1 refused (its words as they came), not made, failed or an answer outside the contract; 2 a wrong line;
39
- * 124 the wait timed out (nothing stopped: `mason replica status` says how it goes).
59
+ * What to run next, the code and the exit are the CLI's own: 0 once the replica is ready (create), said (list,
60
+ * status) or deleted with every slot dropped, none or confirmed by a person (delete); 1 refused (its words as they
61
+ * came), not made, failed, a deletion that needs a person (its SQL printed) or an answer outside the contract; 2 a wrong line; 124 the wait timed out
62
+ * (nothing stopped: `mason replica status` says how it goes).
40
63
  */
64
+ import { once } from "node:events";
65
+ import { createInterface } from "node:readline/promises";
41
66
  import { setTimeout as sleep } from "node:timers/promises";
42
67
  import { endText, errorJson, exitOf, failed, failureText, jsonText, parseCommand, reportText, serviceStyle, strict } from "./command.js";
43
- import { call, canonicalSecret, carriesNoSecret, isFailure, isServiceTime, serviceFailure, serviceOf, serviceText, unexpected } from "./hosted.js";
44
- import { loginOrgs, orgNamed, REPLICA_PART, replicaNamed, replicaText, tokenFor } from "./replica.js";
68
+ import { parseArgs } from "node:util";
69
+ import { call, canonicalSecret, carriesNoSecret, isFailure, isServiceTime, pageUrl, serviceFailure, serviceOf, serviceText, unexpected } from "./hosted.js";
70
+ import { loginOrgs, orgOf, REPLICA_ENV, REPLICA_PART, replicaOf, replicaText, tokenFor } from "./replica.js";
45
71
  import { holdOutput, readingsCarry, readingsOf } from "./output-gate.js";
46
72
  import { theProject } from "./project.js";
47
- import { readSource, shownPath } from "./sources.js";
73
+ import { readSourceFile, shownPath } from "./sources.js";
74
+ import { readHidden, readPiped } from "./secret-input.js";
48
75
  import { parseSource } from "./preflight.js";
49
- import { parseDuration } from "../lib/table-sync.js";
76
+ import { MAX_POLL_SECONDS, MIN_POLL_SECONDS, parseDuration } from "../lib/table-sync.js";
50
77
  import { sayAgain } from "./commands.js";
78
+ import { version } from "./version.js";
51
79
  import { didYouMean } from "../lib/did-you-mean.js";
80
+ import { dropStatements, listSlotsStatement } from "./replication-mode.js";
52
81
  import { err as styleErr, out as styleOut, PLAIN } from "./style.js";
53
82
  /** How long the creation's POST may take: the service answers once Mason's checks of the source are done. */
54
83
  export const CREATE_MS = 60_000;
@@ -72,59 +101,98 @@ const OUTCOMES = ["passed", "failed", "unknown"];
72
101
  const CHECK_STATUSES = ["ok", "warn", "failed", "unknown"];
73
102
  export const REPLICA = {
74
103
  name: "replica",
75
- summary: "a hosted Mason service's replicas: create one for this project's database, list them, say how one is",
76
- usage: `usage: mason replica create <org>/<name> --region <region> --size <gb> [--timeout <interval>] [--json]
77
- mason replica list [<org>] [--json]
78
- mason replica status <org>/<name> [--json]
104
+ summary: "a hosted Mason service's replicas: create one for this project's database, list them, say how one is, delete one",
105
+ usage: `usage: mason replica create <org>/<name> --region <region> --size <gb> [--timeout <duration>] [--json] [< url]
106
+ mason replica list [<org> | --org <org>] [--json]
107
+ mason replica status <org>/<name> | --replica <org>/<name> [--json]
108
+ mason replica delete <org>/<name> | --replica <org>/<name> [--yes] [--timeout <duration>] [--json]
79
109
 
80
110
  A replica is Mason run for you by a hosted Mason service, on a machine of its own, syncing this project's
81
111
  database. mason deploy then sends it the project's files, and mason token create a CI token for it.
82
112
 
113
+ mason replica create acme/shop --region <region> --size 50
114
+ my-secret-store read app-db-url | mason replica create --replica acme/shop --region <region> --size 50 --json
115
+ mason replica list
116
+ mason replica status acme/shop --json
117
+ mason replica delete acme/shop
118
+
119
+ --replica <org>/<name> create, status, delete: the replica, in place of the argument (else MASON_REPLICA)
120
+ --org <org> list: the organisation, in place of the argument
121
+ --region <region> create: the region it runs in, one the service offers
122
+ --size <gb> create: the synced data it holds, in GB, one size the service offers (50)
123
+ --timeout <duration> create: how long to wait for its region to make it; delete: for its deletion to finish
124
+ or to need a person (default ${DEFAULT_TIMEOUT}; 1s to 24h; a bare number is seconds)
125
+ --yes delete: delete it without asking for its name to be typed back (CI, a coding agent)
126
+ --json as data: its shape is at the end
127
+
128
+ The replica can also be named as every other command names one, --replica <org>/<name> (else MASON_REPLICA, said
129
+ on stderr when it chose): mason replica status --replica acme/shop. The organisation, as --org: mason replica
130
+ list --org acme.
131
+
83
132
  create makes one in the organisation <org>, named <name>, in --region (one the service offers), sized by
84
- --size: the synced data it holds, in GB (one size the service offers, such as 50). The database is the one this
85
- project's sources file names, by its variable (url: \${DATABASE_URL}): its value is read here, from .env.local,
86
- .env or the shell, and sent once to the service for Mason's checks of the source; it is never taken on the
87
- command line, never printed and never in --json, and the replica reads it by that variable. A url: written in
88
- the file is refused: write url: \${DATABASE_URL} and put the URL in .env. A URL whose password is under 8
133
+ --size: the synced data it holds, in GB (one size the service offers, such as 50). The database's URL is
134
+ asked for: on a terminal, create asks for it on stderr and reads it without showing it (Enter on an empty
135
+ line, or Ctrl-C, cancels before anything is sent); otherwise it reads it from stdin, whole (at most 2 KiB,
136
+ one trailing newline trimmed): mason replica create acme/shop --region <region> --size 50 < url.txt, or
137
+ piped from a secret store's command. It is never taken from a flag, an argument, .env or the shell: a
138
+ command line holding a URL is refused (exit 2). It is sent once to the service for Mason's checks of the
139
+ source, never printed and never in --json. The sources file names the variable the replica reads it by
140
+ (url: \${DATABASE_URL}); a url: written in the file is refused. A URL whose password is under 8
89
141
  characters, or is in the words this command prints itself, is refused too, before anything is sent: nothing
90
- could keep it out of what is printed. create waits until the service has run
91
- Mason's checks of the database (up to a minute) and says each check, one line each, as the service words them;
92
- then until its region has made the replica (--timeout bounds that wait; the replica is made all the same).
142
+ could keep it out of what is printed.
143
+ create waits until the service has run Mason's checks of the database (up to a minute) and says each check,
144
+ one line each, as the service words them; then until its region has made the replica (--timeout bounds that wait; the replica is made all the same),
145
+ and prints its page in the service's web app, where its allowlist (the addresses that may query it) is set.
93
146
  An admin or an owner of the organisation creates one. An account's FIRST replica is made in the service's web
94
147
  app, where it asks a check that a person is creating it, which a command cannot answer: the service refuses it
95
148
  here, and the replicas after it can be made here.
96
149
 
97
150
  list says the replicas of <org>, or of every organisation of this login when none is named (a CI token names
98
151
  one); status says one replica: its state (checking, creating, ready, failed, deleting, deleted), its region and
99
- size, its host, its allowlist (the addresses that may query it), and what of its region's work is not done.
152
+ size, its host, its allowlist (the addresses that may query it), what of its region's work is not done, and,
153
+ while it is deleted, its deletion as delete says it.
154
+
155
+ delete deletes one, for good: the replica and its synced data go, and Mason's replication slot on its
156
+ database is dropped. At a terminal it asks for <org>/<name> to be typed back; elsewhere (and with --json) it
157
+ needs --yes. An owner of the organisation deletes one, signed in as a person within the last 15 minutes (mason
158
+ login; a CI token deletes none). It waits until the deletion is finished (--timeout bounds that wait; it goes
159
+ on all the same), saying each step as the service words it, then what it did to each database's replication
160
+ slot: dropped and verified gone, none there, or confirmed by a person. A slot NOT verified gone stops the wait
161
+ at once: the service keeps the replica until the slot is seen gone or a person confirms it was dropped, so
162
+ delete prints why and the statement to run on that database by hand, and exits 1; run it, then confirm on
163
+ the replica's page in the service's web app. That statement is Mason's own, rendered from the slot's name the
164
+ service gives; whatever statement the service sends beside it is never printed. A replica deleted another
165
+ way (as from the web app) may be said only as being deleted, when the service says no more of it.
100
166
 
101
167
  The login is MASON_TOKEN, else this machine's (mason login); the service is MASON_CLOUD_URL, else the default.
102
168
  Progress goes to stderr; the answer is printed when it ends.
103
169
 
104
- mason replica create acme/shop --region <region> --size 50
105
- mason replica list
106
- mason replica status acme/shop --json
107
-
108
- --region <region> create: the region it runs in, one the service offers
109
- --size <gb> create: the synced data it holds, in GB, one size the service offers (50)
110
- --timeout <interval> create: how long to wait for its region to make it (default ${DEFAULT_TIMEOUT}; 1s to 24h)
111
- --json as data, once it ends: { replica (create, status: { org, name, region, sizeGb, hostId,
112
- state, createdAt, allowlist, pending: [{ change (create, allowlist), state (pending,
113
- failed), attempts, failure, nextAttemptAt }] }), checks (create: [{ source, outcome
114
- (passed, failed, unknown), checks: [{ name, status (ok, warn, failed, unknown), detail,
115
- fix }], missing }], null when the service's answer was lost), replicas (list: each as
116
- replica is), more (list: true when an organisation has more than the service lists),
117
- error (create, when it was not made, failed or the wait timed out: { code, message,
118
- why, fix }), next }
170
+ Exit status: 0 the replica is ready (create), said (list, status), or deleted, every replication slot dropped,
171
+ none there or confirmed by a person (delete); 1 not signed in, the URL not given (cancelled, stdin empty, more
172
+ than one line or past 2 KiB: nothing sent), refused by the service (its words printed as they came, the code
173
+ the CLI's own), not made (the checks failed: each is said), its region could not make it, its deletion needs a
174
+ person (a slot NOT verified gone: the statement to run is printed, and the replica is kept until it is
175
+ confirmed), or the service answered outside its contract; 2 a wrong command line (delete without a terminal
176
+ and without --yes); 124 the wait timed out (nothing stopped: mason replica status says how it goes). Whatever
177
+ the service sends, it never makes the exit 0, 2 or 124.
119
178
 
120
- Exit status: 0 the replica is ready (create), or said (list, status); 1 not signed in, refused by the service
121
- (its words printed as they came, the code the CLI's own), not made (the checks failed: each is said), its
122
- region could not make it, or the service answered outside its contract; 2 a wrong command line; 124 the wait
123
- timed out (nothing stopped: mason replica status says how it goes). Whatever the service sends, it never
124
- makes the exit 0, 2 or 124.
179
+ The --json answer, once it ends: { replica (create, status, delete: { org, name, region, sizeGb, hostId,
180
+ state, createdAt, allowlist, page (its page, null when the service names none), pending: [{ change
181
+ (create, allowlist), state (pending, failed), attempts, failure, nextAttemptAt }] }), deletion (status,
182
+ delete: null while it is in none, or when the service gave none; { stage (requested, in_region,
183
+ needs_person, finished), requestedAt, handover: { attempts, failure, nextAttemptAt } or null,
184
+ lastHeardAt, finishedAt, steps: [{ step, what, doneAt }], sources: [{ ordinal, outcome (dropped,
185
+ no_slot, not_verified, confirmed), reason (not_verified: unreachable, refused, in_use, still_there,
186
+ another_server, record_unreadable, failed, unprobed), why, slot, publication, publicationOwned, byHand
187
+ (not_verified: Mason's statement to run by hand), reportedAt }] }), checks (create: [{ source, outcome
188
+ (passed, failed, unknown), checks: [{ name, status (ok, warn, failed, unknown), detail, fix }],
189
+ missing }], null when the service's answer was lost), replicas (list: each as replica is), more (list:
190
+ true when an organisation has more than the service lists), error (create, delete: when it was not
191
+ made, failed, needs a person, was refused or the wait timed out: { code, message, why, fix }), next }
125
192
  `,
126
- options: { region: { type: "string" }, size: { type: "string" }, timeout: { type: "string" }, json: { type: "boolean", default: false } },
127
- json: ["replica", "checks", "replicas", "more", "error", "next"],
193
+ options: { replica: { type: "string" }, org: { type: "string" }, region: { type: "string" }, size: { type: "string" }, timeout: { type: "string" },
194
+ yes: { type: "boolean", default: false }, json: { type: "boolean", default: false } },
195
+ json: ["replica", "deletion", "checks", "replicas", "more", "error", "next"],
128
196
  };
129
197
  /** Everything this command prints can hold the service's text, so it is all drawn in serviceStyle (src/cli/command.ts). */
130
198
  const outStyle = () => serviceStyle(styleOut());
@@ -143,7 +211,15 @@ const whole = (v, least, most) => Number.isInteger(v) && v >= least && v <= most
143
211
  export function wordless(json) {
144
212
  if (!obj(json))
145
213
  return json;
146
- const replica = (r) => obj(r) && Array.isArray(r.pending) ? { ...r, pending: r.pending.map((p) => obj(p) ? { ...p, failure: null } : p) } : r;
214
+ // a deletion's words: each step's what, each source's why, the handover's failure (its byHand is no free text: tested,
215
+ // then never read)
216
+ const deletion = (d) => !obj(d) ? d : { ...d,
217
+ ...(obj(d.handover) ? { handover: { ...d.handover, failure: null } } : {}),
218
+ ...(Array.isArray(d.steps) ? { steps: d.steps.map((x) => obj(x) ? { ...x, what: null } : x) } : {}),
219
+ ...(Array.isArray(d.sources) ? { sources: d.sources.map((x) => obj(x) ? { ...x, why: null } : x) } : {}) };
220
+ const replica = (r) => !obj(r) ? r : { ...r,
221
+ ...(Array.isArray(r.pending) ? { pending: r.pending.map((p) => obj(p) ? { ...p, failure: null } : p) } : {}),
222
+ ...("deletion" in r ? { deletion: deletion(r.deletion) } : {}) };
147
223
  const checks = (c) => !obj(c) ? c : { ...c,
148
224
  ...(Array.isArray(c.missing) ? { missing: c.missing.map(() => null) } : {}),
149
225
  ...(Array.isArray(c.checks) ? { checks: c.checks.map((x) => obj(x) ? { ...x, name: null, detail: null, fix: null } : x) } : {}) };
@@ -155,9 +231,11 @@ export function wordless(json) {
155
231
  const words = (v, max, secrets) => typeof v === "string" ? serviceText(v, max, secrets) : null;
156
232
  /**
157
233
  * A REPLICA, held to the contract: of the organisation asked (and the name asked, for one replica), every value to its
158
- * grammar; null when any does not hold. The answer around it has been put to the exact test (`wordless`).
234
+ * grammar; null when any does not hold. The answer around it has been put to the exact test (`wordless`). Its page, when
235
+ * named, is on `service`'s own origin (`pageUrl`: tested as it is printed); one elsewhere, or with no service to hold it
236
+ * to, does not hold.
159
237
  */
160
- export function heldReplica(v, asked, secrets) {
238
+ export function heldReplica(v, asked, secrets, service) {
161
239
  if (!obj(v) || v.org !== asked.org || typeof v.name !== "string" || !REPLICA_PART.test(v.name) || (asked.name !== undefined && v.name !== asked.name))
162
240
  return null;
163
241
  if (typeof v.region !== "string" || !REGION.test(v.region) || !whole(v.sizeGb, 1, 1_000_000) || typeof v.hostId !== "string" || !HOST_ID.test(v.hostId))
@@ -168,6 +246,13 @@ export function heldReplica(v, asked, secrets) {
168
246
  return null;
169
247
  if (!Array.isArray(v.pending) || v.pending.length > MAX_PENDING)
170
248
  return null;
249
+ let page = null;
250
+ if (v.page !== undefined && v.page !== null) {
251
+ const held = service === undefined ? null : pageUrl(service, v.page, secrets);
252
+ if (held === null || !("href" in held))
253
+ return null;
254
+ page = held.href;
255
+ }
171
256
  const pending = [];
172
257
  for (const p of v.pending) {
173
258
  if (!obj(p) || (p.change !== "create" && p.change !== "allowlist") || (p.state !== "pending" && p.state !== "failed") || !whole(p.attempts, 0, 1_000_000))
@@ -180,7 +265,7 @@ export function heldReplica(v, asked, secrets) {
180
265
  nextAttemptAt: typeof p.nextAttemptAt === "string" ? p.nextAttemptAt : null });
181
266
  }
182
267
  return { org: v.org, name: v.name, region: v.region, sizeGb: v.sizeGb, hostId: v.hostId, state: v.state,
183
- createdAt: v.createdAt, allowlist: [...v.allowlist], pending };
268
+ createdAt: v.createdAt, allowlist: [...v.allowlist], page, pending };
184
269
  }
185
270
  /** Mason's checks, held: one entry at most, for the source sent, each check's status and the outcome closed sets, every
186
271
  * word one line; null when they do not hold. */
@@ -207,6 +292,131 @@ export function heldChecks(v, source, secrets) {
207
292
  }
208
293
  return held;
209
294
  }
295
+ // ------------------------------------------------------------------ a deletion, held
296
+ /**
297
+ * A deletion's stages: asked for and not handed over yet, under way (nobody may query it), NEEDS A PERSON (a slot is not
298
+ * verified gone: the service keeps the replica until it is seen gone or a person confirms it, and the CLI stops waiting
299
+ * at once), finished (the tombstone: every slot dropped, none there, or confirmed by a person).
300
+ */
301
+ export const STAGES = ["requested", "in_region", "needs_person", "finished"];
302
+ /** What a deletion did to Mason's replication slot on a source's database: `confirmed` is a slot not verified gone that a
303
+ * person said they dropped by hand, or whose database is no more (never seen gone by the service). */
304
+ export const SOURCE_OUTCOMES = ["dropped", "no_slot", "not_verified", "confirmed"];
305
+ /** Why a slot was not verified gone. */
306
+ export const REASONS = ["unreachable", "refused", "in_use", "still_there", "another_server", "record_unreadable", "failed", "unprobed"];
307
+ /** A step's id: lower case letters, digits and `_` (never printed for a person: its `what` is). */
308
+ const STEP_ID = /^[a-z0-9_]{1,64}$/;
309
+ /** A replication slot's or a publication's name as Postgres takes one unquoted in the statements a person runs: lower
310
+ * case letters, digits and `_`, 1 to 63 (src/cli/replication-mode.ts `PG_NAME_BYTES`). */
311
+ export const PG_NAME = /^[a-z0-9_]{1,63}$/;
312
+ /** Mason's own publication (src/cdc/source.ts `replicationNames`): `mason_` and the rest of a name. */
313
+ const OWN_PUBLICATION = /^mason_[a-z0-9_]{1,57}$/;
314
+ const MAX_STEPS = 64;
315
+ const MAX_SOURCES = 16;
316
+ /**
317
+ * THE STATEMENT A PERSON RUNS BY HAND for a slot not verified gone: Mason's own (`dropStatements`, the one authority),
318
+ * for the slot named and the publication only when the service says it is Mason's own; `listSlotsStatement` when the
319
+ * slot's name is not known. Every name was held to `PG_NAME` first.
320
+ */
321
+ export function byHandOf(s) {
322
+ if (s.slot === null)
323
+ return listSlotsStatement();
324
+ const owned = s.publicationOwned === true && s.publication !== null;
325
+ return dropStatements({ slot: s.slot, publication: owned ? s.publication : "", owned });
326
+ }
327
+ const absent = (v) => v === undefined || v === null;
328
+ const timeOrNull = (v) => absent(v) || isServiceTime(v);
329
+ /** One source's outcome, held: its ordinal its place, every name to `PG_NAME`, its byHand Mason's own rendering (the
330
+ * service's is advisory: never compared, never printed); null when any does not hold. */
331
+ function heldSource(v, at, secrets) {
332
+ if (!obj(v) || v.ordinal !== at || typeof v.outcome !== "string" || !SOURCE_OUTCOMES.includes(v.outcome) || !isServiceTime(v.reportedAt))
333
+ return null;
334
+ const outcome = v.outcome;
335
+ const name = (x) => absent(x) || (typeof x === "string" && PG_NAME.test(x));
336
+ if (!name(v.slot) || !name(v.publication))
337
+ return null;
338
+ const slot = typeof v.slot === "string" ? v.slot : null, publication = typeof v.publication === "string" ? v.publication : null;
339
+ if (outcome === "dropped" && slot === null)
340
+ return null;
341
+ // whether the publication is Mason's own: absent for no_slot; else true (then it is named, and Mason's own), false, null
342
+ let owned = null;
343
+ if (outcome === "no_slot") {
344
+ if (!absent(v.publicationOwned))
345
+ return null;
346
+ }
347
+ else {
348
+ if (v.publicationOwned !== true && v.publicationOwned !== false && v.publicationOwned !== null)
349
+ return null;
350
+ owned = v.publicationOwned;
351
+ if (owned === true && (publication === null || !OWN_PUBLICATION.test(publication)))
352
+ return null;
353
+ }
354
+ // a reason, its words and the SQL by hand: for a slot not verified gone alone
355
+ let reason = null, why = null, byHand = null;
356
+ if (outcome === "not_verified") {
357
+ if (typeof v.reason !== "string" || !REASONS.includes(v.reason) || (!absent(v.why) && typeof v.why !== "string"))
358
+ return null;
359
+ reason = v.reason;
360
+ why = typeof v.why === "string" ? serviceText(v.why, 500, secrets) : null;
361
+ // the statement by hand is Mason's own, from the names held above (#687's review, B1: a service's text compared
362
+ // with it was a fact derived in two places): the service's, a string or nothing, is read for no word of it
363
+ byHand = byHandOf({ slot, publication, publicationOwned: owned });
364
+ }
365
+ else if (!absent(v.reason) || !absent(v.why))
366
+ return null;
367
+ if (!absent(v.byHand) && typeof v.byHand !== "string")
368
+ return null;
369
+ return { ordinal: at, outcome, reason, why, slot, publication, publicationOwned: owned, byHand, reportedAt: v.reportedAt };
370
+ }
371
+ /**
372
+ * A REPLICA'S DELETION, held to the contract: carried only while its state is `deleting` (stage `requested`, `in_region`
373
+ * or `needs_person`) or `deleted` (stage `finished`, its time said), and always while `deleted`; a replica `deleting`
374
+ * may carry none (deleted another way, as from the service's web app: it is said as being deleted, nothing more). The
375
+ * handover at `requested` alone; every step's id once; every source in order; at `needs_person` a slot not verified
376
+ * gone, at `finished` none. `undefined` when it carries none (absent or `null`) and may; null when anything does not
377
+ * hold, a deletion present but malformed refused whole. The answer around it has been put to the exact test (`wordless`).
378
+ */
379
+ export function heldDeletion(v, state, secrets) {
380
+ const deleting = state === "deleting" || state === "deleted";
381
+ if (absent(v))
382
+ return state === "deleted" ? null : undefined;
383
+ if (!deleting || !obj(v) || typeof v.stage !== "string" || !STAGES.includes(v.stage))
384
+ return null;
385
+ const stage = v.stage;
386
+ if ((state === "deleted") !== (stage === "finished"))
387
+ return null;
388
+ if (!isServiceTime(v.requestedAt) || !timeOrNull(v.lastHeardAt) || !timeOrNull(v.finishedAt) || (stage === "finished") !== isServiceTime(v.finishedAt))
389
+ return null;
390
+ let handover = null;
391
+ if (!absent(v.handover)) {
392
+ const h = v.handover;
393
+ if (stage !== "requested" || !obj(h) || !whole(h.attempts, 0, 1_000_000) || (!absent(h.failure) && typeof h.failure !== "string") || !timeOrNull(h.nextAttemptAt))
394
+ return null;
395
+ handover = { attempts: h.attempts, failure: words(h.failure, 500, secrets), nextAttemptAt: typeof h.nextAttemptAt === "string" ? h.nextAttemptAt : null };
396
+ }
397
+ if (!Array.isArray(v.steps) || v.steps.length > MAX_STEPS || !Array.isArray(v.sources) || v.sources.length > MAX_SOURCES)
398
+ return null;
399
+ const steps = [], ids = new Set();
400
+ for (const x of v.steps) {
401
+ if (!obj(x) || typeof x.step !== "string" || !STEP_ID.test(x.step) || ids.has(x.step) || typeof x.what !== "string" || !timeOrNull(x.doneAt))
402
+ return null;
403
+ ids.add(x.step);
404
+ steps.push({ step: x.step, what: serviceText(x.what, 300, secrets), doneAt: typeof x.doneAt === "string" ? x.doneAt : null });
405
+ }
406
+ const sources = [];
407
+ for (const [i, x] of v.sources.entries()) {
408
+ const s = heldSource(x, i, secrets);
409
+ if (s === null)
410
+ return null;
411
+ sources.push(s);
412
+ }
413
+ // a person is needed exactly while a slot is not verified gone at the deletion's end: never finished with one left
414
+ const left = sources.some((x) => x.outcome === "not_verified");
415
+ if ((stage === "needs_person" && !left) || (stage === "finished" && left))
416
+ return null;
417
+ return { stage, requestedAt: v.requestedAt, handover, lastHeardAt: typeof v.lastHeardAt === "string" ? v.lastHeardAt : null,
418
+ finishedAt: typeof v.finishedAt === "string" ? v.finishedAt : null, steps, sources };
419
+ }
210
420
  // ------------------------------------------------------------------ in the CLI's words
211
421
  const STATE_WORDS = {
212
422
  checking: "checking: Mason checks its database; nothing is made yet",
@@ -230,14 +440,96 @@ function rows(r) {
230
440
  return [
231
441
  ["state", STATE_WORDS[r.state]], ["region", r.region], ["size", `${r.sizeGb} GB of synced data`], ["host", r.hostId], ["created", r.createdAt],
232
442
  ["allowlist", r.allowlist.length ? r.allowlist.join(", ") : "none: no address may query it yet"],
443
+ ...(r.page !== null ? [["page", r.page]] : []),
233
444
  ...r.pending.map((p) => ["pending", `${p.change === "create" ? "its creation" : "its allowlist"} ${p.state === "failed" ? "failed" : "is under way"}` +
234
445
  ` (${p.attempts} attempt${p.attempts === 1 ? "" : "s"}${p.nextAttemptAt ? `, the next at ${p.nextAttemptAt}` : ""})${p.failure ? `: ${p.failure}` : ""}`]),
235
446
  ];
236
447
  }
237
448
  const statusStep = (r) => [`mason replica status ${r.org}/${r.name}`, "how it is"];
449
+ const listStep = (org) => [`mason replica list ${org}`, "the organisation's replicas"];
238
450
  const nextOf = (r) => r.state === "ready"
239
451
  ? [[`mason deploy --replica ${r.org}/${r.name}`, "send this project to it"], [`mason token create --replica ${r.org}/${r.name}`, "a CI token for it"]]
240
- : [statusStep(r)];
452
+ : r.state === "deleted" ? [listStep(r.org)] : [statusStep(r)];
453
+ const STAGE_WORDS = {
454
+ requested: "requested: the service has not handed it to its region yet, and the addresses of its allowlist may still query it",
455
+ in_region: "under way: nobody may query it now",
456
+ needs_person: "needs a person: a replication slot is NOT verified gone, and the service keeps the replica until it is seen gone or a person confirms it was dropped",
457
+ finished: "finished",
458
+ };
459
+ const REASON_WORDS = {
460
+ unreachable: "its database could not be reached",
461
+ refused: "its database refused the drop",
462
+ in_use: "a reader still holds the slot",
463
+ still_there: "it was still there after the drop",
464
+ another_server: "the database answering is another server than the one the slot is on",
465
+ record_unreadable: "the replica's record of its slot could not be read",
466
+ failed: "the drop failed",
467
+ unprobed: "whether it is gone was not checked",
468
+ };
469
+ /** A deletion's stage, as a line: its words, and the handover's attempts while it is not handed over. */
470
+ export function stageLine(d) {
471
+ const h = d.handover;
472
+ return `${STAGE_WORDS[d.stage]}${h ? ` (${h.attempts} attempt${h.attempts === 1 ? "" : "s"} to hand it over${h.nextAttemptAt ? `, the next at ${h.nextAttemptAt}` : ""})${h.failure ? `: ${h.failure}` : ""}` : ""}`;
473
+ }
474
+ /** What a deletion did to one source's replication slot, in the CLI's words (and a reason's words, the service's). */
475
+ export function sourceWords(s) {
476
+ const slot = s.slot === null ? "its replication slot" : `replication slot ${s.slot}`;
477
+ if (s.outcome === "dropped")
478
+ return `${slot}: dropped, verified gone`;
479
+ if (s.outcome === "no_slot")
480
+ return s.slot === null ? "no replication slot: nothing to drop" : `${slot}: not there, nothing to drop`;
481
+ if (s.outcome === "confirmed")
482
+ return `${slot}: not verified gone by the service; a person confirmed it was dropped by hand, or that its database is no more`;
483
+ return `${slot}: NOT verified gone, ${REASON_WORDS[s.reason]}${s.why ? ` (${s.why})` : ""}`;
484
+ }
485
+ /** A replica being deleted whose service gave no deletion (deleted another way): what is known, nothing invented. */
486
+ const NO_DELETION = "being deleted: the service said no more of it";
487
+ /** What a person does once a deletion needs one: the statements, then the confirmation, where the service takes it. */
488
+ const NEEDS_PERSON_TIP = "run each statement above on its database, then confirm it on the replica's page in the service's web app (or there, that the database is no more): the service deletes the replica only then";
489
+ /** A replica's deletion as rows: its own (`deletionRows`), the one row that says it is being deleted when the service
490
+ * gave none, else none. */
491
+ export function deletionDetails(state, d) {
492
+ return d !== null ? deletionRows(d) : state === "deleting" ? [["deletion", NO_DELETION]] : [];
493
+ }
494
+ /** A deletion, as rows: its stage, its times, each step (done or not yet), each source's outcome. */
495
+ export function deletionRows(d) {
496
+ return [
497
+ ["deletion", `${stageLine(d)}; asked for at ${d.requestedAt}`],
498
+ ...(d.lastHeardAt ? [["last heard", d.lastHeardAt]] : []),
499
+ ...(d.finishedAt ? [["finished", d.finishedAt]] : []),
500
+ ...d.steps.map((x) => ["step", x.doneAt ? `done: ${x.what} (at ${x.doneAt})` : `not yet: ${x.what}`]),
501
+ ...d.sources.map((x) => [`source ${x.ordinal}`, sourceWords(x)]),
502
+ ];
503
+ }
504
+ /**
505
+ * THE STATEMENTS A PERSON RUNS BY HAND, for each source whose slot is not verified gone: what the CLI says of where, and
506
+ * the statement (Mason's own, `byHandOf`). `[said, sql]` pairs, printed by `pasteLines`; none when every slot was dropped
507
+ * or there was none. The CLI's words hold no piece of SQL: only the statement is pasted.
508
+ */
509
+ export function byHandLines(d) {
510
+ return d.sources.filter((x) => x.outcome === "not_verified").map((x) => [x.slot === null
511
+ ? `by hand, for source ${x.ordinal}: this lists Mason's replication slots on its database; drop this replica's slot from them, as a user that may drop a replication slot`
512
+ : `by hand, for source ${x.ordinal}: run this on its database, as a user that may drop a replication slot (its owner, or one with REPLICATION)`, byHandOf(x)]);
513
+ }
514
+ /**
515
+ * THE ONE WRITER OF A STATEMENT A PERSON PASTES (#687's review, N1: a statement inside a failure's words was broken at the
516
+ * width, mid-word on a narrow terminal; PR 660's B2 met the class with a CI token): for each, the CLI's line saying where,
517
+ * broken as every line holding a service's value is, then the statement alone on a line of its own, indented, WRITTEN
518
+ * UNBROKEN: a break inside a name would be pasted with it, and a terminal narrower than it wraps it softly. Its characters
519
+ * are Mason's own SQL and names held to `PG_NAME`, so no piece of it begins a line of anyone's making.
520
+ */
521
+ export function pasteLines(pairs, st) {
522
+ return pairs.flatMap(([said, sql], i) => [...(i ? [""] : []), strict(` ${said}:`, st), ` ${sql}`]);
523
+ }
524
+ /** A replica's report with its deletion: the rows, the statements by hand (`pasteLines`), then the tips and next steps. */
525
+ function deletionReport(r, st) {
526
+ const hand = r.deletion === null ? [] : byHandLines(r.deletion);
527
+ const report = reportText({ headline: r.headline, details: r.details, ...(hand.length ? {} : { tips: r.tips, next: r.next }) }, st);
528
+ if (!hand.length)
529
+ return report;
530
+ const end = endText({ tips: r.tips, next: r.next }, st);
531
+ return [report, "", ...pasteLines(hand, st), ...(end ? [end] : [])].join("\n");
532
+ }
241
533
  /**
242
534
  * WHAT TO RUN AFTER THE SERVICE REFUSED: the CLI's own, by the status (the service's `next` is never read). A 401 or 403
243
535
  * signs in again, naming the role that may (`may`); a 404 lists the organisation's replicas; else `again`.
@@ -250,36 +542,93 @@ export function refusedNext(status, org, may, again) {
250
542
  return [again];
251
543
  }
252
544
  // ------------------------------------------------------------------ the command
545
+ /** An argument that holds a database's URL, or a connection string's password: `://` anywhere (a scheme, `--url=…`,
546
+ * `DATABASE_URL=…`), `postgres:` / `postgresql:` without it, or `password=`. Never repeated: it is a secret. */
547
+ export const holdsUrl = (a) => a.includes("://") || /(?:^|[=\s])postgres(?:ql)?:/i.test(a) || /(?:^|[\s?&;])password\s*=/i.test(a);
548
+ /**
549
+ * A DATABASE URL ON THE COMMAND LINE, refused before anything else reads the line (a wrong line, exit 2), and never
550
+ * repeated (the parser's refusal of `--url=…`, or a check of `--region`, would quote it): the way out is the exact line
551
+ * to run, the URL on stdin, every other value kept where it holds its grammar and a placeholder where it does not.
552
+ */
553
+ export function urlInArgs(argv) {
554
+ if (!argv.some(holdsUrl))
555
+ return null;
556
+ // the line without it, and without the flag it was the value of (`--region <url>`, `--url <url>`)
557
+ const rest = [];
558
+ for (const a of argv) {
559
+ if (!holdsUrl(a)) {
560
+ rest.push(a);
561
+ continue;
562
+ }
563
+ const before = rest[rest.length - 1];
564
+ if (before !== undefined && /^--[\w-]+$/.test(before) && (REPLICA.options[before.slice(2)]?.type ?? "string") === "string")
565
+ rest.pop();
566
+ }
567
+ const p = parseArgs({ args: rest, options: REPLICA.options, allowPositionals: true, strict: false });
568
+ const str = (v) => typeof v === "string" ? v : undefined;
569
+ const named = [p.positionals[0] === "create" ? p.positionals[1] : undefined, str(p.values.replica)].find((x) => x !== undefined && /^[a-z0-9][a-z0-9-]{0,62}\/[a-z0-9][a-z0-9-]{0,62}$/.test(x));
570
+ const region = str(p.values.region), size = str(p.values.size), timeout = str(p.values.timeout);
571
+ const line = `mason replica create ${named ?? "<org>/<name>"} --region ${region !== undefined && REGION.test(region) ? region : "<region>"} --size ${size !== undefined && SIZE.test(size) ? size : "<gb>"}` +
572
+ `${timeout !== undefined && /^[0-9]{1,6}[smhd]?$/.test(timeout) ? ` --timeout ${timeout}` : ""}${p.values.json === true ? " --json" : ""} < url.txt`;
573
+ return { message: "a database URL is never taken on mason replica's command line", code: "usage",
574
+ why: "a command line is kept in the shell's history and shown to every process on this machine: if that URL holds a real password, change it",
575
+ fix: `give it on stdin: ${line} (or from a secret store's command, piped); at a terminal, mason replica create asks for it and does not show it`,
576
+ next: [[line, "create it, the URL read from stdin"]] };
577
+ }
253
578
  export async function replica(argv) {
579
+ // before the parser, whose refusal of an option would quote its value
580
+ const inArgs = urlInArgs(argv);
581
+ if (inArgs !== null)
582
+ return fail(inArgs, argv.includes("--json"));
254
583
  const f = parseCommand(REPLICA, argv);
255
584
  if (typeof f === "number")
256
585
  return f;
257
586
  const json = f.values.json === true;
258
587
  const usage = (message, fix) => fail({ message, code: "usage", ...(fix ? { fix } : {}), usage: REPLICA.usage }, json);
259
588
  const [verb, arg, ...rest] = f.positionals;
260
- const VERBS = ["create", "list", "status"];
589
+ const VERBS = ["create", "list", "status", "delete"];
261
590
  if (verb === undefined)
262
- return usage("create, list or status: mason replica list says the replicas");
591
+ return usage("create, list, status or delete: mason replica list says the replicas");
263
592
  if (!VERBS.includes(verb))
264
- return usage(`there is no mason replica ${verb}: create, list or status${didYouMean(verb, VERBS)}`);
593
+ return usage(`there is no mason replica ${verb}: create, list, status or delete${didYouMean(verb, VERBS)}`);
265
594
  if (rest.length)
266
595
  return usage(`mason replica ${verb} takes ${verb === "list" ? "one organisation at most" : "one replica"}: ${rest.join(" ")} is too many`);
267
- for (const k of ["region", "size", "timeout"])
596
+ for (const k of ["region", "size"])
268
597
  if (verb !== "create" && f.values[k] !== undefined)
269
598
  return usage(`--${k} goes with mason replica create`);
599
+ if (verb !== "create" && verb !== "delete" && f.values.timeout !== undefined)
600
+ return usage("--timeout goes with mason replica create and mason replica delete");
601
+ if (verb !== "delete" && f.values.yes === true)
602
+ return usage("--yes goes with mason replica delete");
603
+ const flag = (k) => typeof f.values[k] === "string" ? f.values[k] : undefined;
270
604
  if (verb === "list") {
271
- const org = arg === undefined ? null : orgNamed(arg, REPLICA.usage);
605
+ if (flag("replica") !== undefined)
606
+ return usage("--replica goes with mason replica create and status", "name the organisation: mason replica list <org>, or --org <org>");
607
+ const org = orgOf(flag("org"), arg, REPLICA.usage);
272
608
  if (org !== null && isFailure(org))
273
609
  return fail(org, json);
274
610
  return list(org, json);
275
611
  }
276
- if (arg === undefined)
277
- return usage(`mason replica ${verb} names the replica: mason replica ${verb} <org>/<name>`);
278
- const r = replicaNamed(arg, "argument", REPLICA.usage);
612
+ if (flag("org") !== undefined)
613
+ return usage(`--org goes with mason replica list`, `mason replica ${verb} names the replica: <org>/<name>, or --replica <org>/<name>`);
614
+ // the one resolver of every command that names a replica: the argument, else --replica, else MASON_REPLICA (said)
615
+ const r = replicaOf(flag("replica"), REPLICA.usage, process.env, { argument: arg });
616
+ if (r === null)
617
+ return usage(`mason replica ${verb} names the replica: mason replica ${verb} <org>/<name>`, `or --replica <org>/<name>, or ${REPLICA_ENV}=<org>/<name>`);
279
618
  if (isFailure(r))
280
619
  return fail(r, json);
281
620
  if (verb === "status")
282
621
  return status(r, json);
622
+ if (verb === "delete") {
623
+ const timeout = parseDuration(typeof f.values.timeout === "string" ? f.values.timeout : DEFAULT_TIMEOUT, { min: MIN_POLL_SECONDS, max: MAX_POLL_SECONDS });
624
+ if ("problem" in timeout)
625
+ return usage(`--timeout ${timeout.problem}`);
626
+ // a deletion is asked for twice: on the line, and typed back by a person at a terminal, else by --yes
627
+ if (f.values.yes !== true && !canAsk(json)) {
628
+ return usage(`mason replica delete asks for ${replicaText(r)} to be typed back at a terminal, and ${json ? "--json is no terminal" : "there is none here"}`, `say it on the line: mason replica delete ${replicaText(r)} --yes`);
629
+ }
630
+ return remove(r, { timeout, yes: f.values.yes === true }, json);
631
+ }
283
632
  const region = typeof f.values.region === "string" ? f.values.region : undefined;
284
633
  const size = typeof f.values.size === "string" ? f.values.size : undefined;
285
634
  if (region === undefined || size === undefined)
@@ -288,7 +637,7 @@ export async function replica(argv) {
288
637
  return usage(`--region ${JSON.stringify(serviceText(region, 100))} is not a region's name`, "a region's name is lower case letters, digits and -, as the service lists it");
289
638
  if (!SIZE.test(size))
290
639
  return usage(`--size ${JSON.stringify(serviceText(size, 100))} is not a size`, "a size is a whole number of GB, as the service lists it: --size 50");
291
- const timeout = parseDuration(typeof f.values.timeout === "string" ? f.values.timeout : DEFAULT_TIMEOUT);
640
+ const timeout = parseDuration(typeof f.values.timeout === "string" ? f.values.timeout : DEFAULT_TIMEOUT, { min: MIN_POLL_SECONDS, max: MAX_POLL_SECONDS });
292
641
  if ("problem" in timeout)
293
642
  return usage(`--timeout ${timeout.problem}`);
294
643
  return create(r, { region, sizeGb: Number(size), timeout }, json);
@@ -305,11 +654,17 @@ function signedIn(asked, again, json, more = []) {
305
654
  holdOutput({ command: "replica", json, secrets: [token, ...more] });
306
655
  return { service, token };
307
656
  }
308
- /** One replica's answer (`GET …/{name}`): its strings put to the exact test first, its words masked, by the one list. */
309
- function replicaAnswer(a, asked, secrets) {
657
+ /** One replica's answer (`GET …/{name}`, a deletion's): its strings put to the exact test first, its words masked, by
658
+ * the one list; the replica and its deletion (null when it is in none, or the service gave none while it is being
659
+ * deleted), or null when either does not hold. */
660
+ function replicaAnswer(service, a, asked, secrets) {
310
661
  if (!obj(a.json) || !carriesNoSecret(wordless(a.json), secrets))
311
662
  return null;
312
- return heldReplica(a.json.replica, asked, secrets);
663
+ const replica = heldReplica(a.json.replica, asked, secrets, service);
664
+ if (replica === null)
665
+ return null;
666
+ const deletion = heldDeletion(a.json.replica.deletion, replica.state, secrets);
667
+ return deletion === null ? null : { replica, deletion: deletion ?? null };
313
668
  }
314
669
  async function status(r, json) {
315
670
  const s = signedIn("a replica's state is asked for", `mason replica status ${replicaText(r)}`, json);
@@ -321,15 +676,17 @@ async function status(r, json) {
321
676
  return fail(a, json);
322
677
  if (a.status !== 200)
323
678
  return fail(serviceFailure(a, secrets, (st) => refusedNext(st, r.org, "a member", statusStep(r))) ?? unexpected(service, a, "a replica's state was asked for"), json);
324
- const held = replicaAnswer(a, r, secrets);
325
- if (held === null)
679
+ const answer = replicaAnswer(service, a, r, secrets);
680
+ if (answer === null)
326
681
  return fail(unexpected(service, a, "a replica's state was asked for"), json);
682
+ const { replica: held, deletion } = answer;
327
683
  const next = nextOf(held);
328
684
  if (json) {
329
- out(jsonText(REPLICA, { replica: held }, next));
685
+ out(jsonText(REPLICA, { replica: held, deletion }, next));
330
686
  return 0;
331
687
  }
332
- out(reportText({ headline: `${replicaText(r)} on ${service.base}: ${held.state}`, details: rows(held), next }, outStyle()));
688
+ out(deletionReport({ headline: `${replicaText(r)} on ${service.base}: ${held.state}`, details: [...rows(held), ...deletionDetails(held.state, deletion)], deletion,
689
+ ...(deletion?.stage === "needs_person" ? { tips: [NEEDS_PERSON_TIP] } : {}), next }, outStyle()));
333
690
  return 0;
334
691
  }
335
692
  async function list(org, json) {
@@ -354,7 +711,7 @@ async function list(org, json) {
354
711
  if (!obj(j) || !Array.isArray(j.replicas) || j.replicas.length > MAX_REPLICAS || typeof j.more !== "boolean" || !carriesNoSecret(wordless(j), secrets))
355
712
  return fail(unexpected(service, a, "the replicas were asked for"), json);
356
713
  for (const v of j.replicas) {
357
- const h = heldReplica(v, { org: slug }, secrets);
714
+ const h = heldReplica(v, { org: slug }, secrets, service);
358
715
  if (h === null)
359
716
  return fail(unexpected(service, a, "the replicas were asked for"), json);
360
717
  replicas.push(h);
@@ -419,10 +776,12 @@ export function createWords(c) {
419
776
  const status = statusStep(c.r);
420
777
  return {
421
778
  again, status,
422
- progress: `Creating ${name} on ${c.base}: Mason checks the database ${c.variable} names (${c.from}) from the replica's side, up to a minute`,
779
+ progress: `Creating ${name} on ${c.base}: Mason checks the database (${c.variable}, its URL ${c.from}) from the replica's side, up to a minute`,
423
780
  lost: (why, st) => ` The creation's answer was lost (${why}); the service has ${name}, ${STATE_WORDS[st]}`,
424
781
  wait: (st, unsure) => unsure === null ? STATE_WORDS[st] : `${STATE_WORDS[st]}; the service could not say more just now (${unsure}): asking again`,
425
782
  unsureOf: (what, statusCode) => `${what} answered ${statusCode}`,
783
+ /** a 503 with Retry-After: when the service asks for another try (the CLI does not wait for it) */
784
+ retryIn: (why, seconds) => `${why ? `${why}; ` : ""}the service asks for another try in ${seconds} s: run the same command again then`,
426
785
  /** a 403 says who may, and where an account's first replica is made (the API refuses it: a person is asked there) */
427
786
  refusedNext: (st) => st === 403
428
787
  ? [["mason login", `sign in as an admin or an owner of ${c.r.org}; an account's first replica is made in the service's web app, ${c.base}`]]
@@ -442,12 +801,13 @@ export function createWords(c) {
442
801
  fix: `mason replica list ${c.r.org} says the organisation's replicas`, next: [[`mason replica list ${c.r.org}`, "the organisation's replicas"]] }),
443
802
  report: (state, next, st) => reportText({ headline: `Created ${name} on ${c.base}: it is ready`,
444
803
  details: rows(state).filter(([k]) => k !== "state"),
445
- tips: ["The service does not keep the database's URL: the replica reads it by its variable, from its own environment."], next }, st),
804
+ tips: [state.page !== null ? "Its allowlist, the addresses that may query it, is set on its page, above." : `Its allowlist, the addresses that may query it, is set on its page in the service's web app, ${c.base}`,
805
+ "The service does not keep the database's URL: the replica reads it by its variable, from its own environment."], next }, st),
446
806
  };
447
807
  }
448
808
  /** The `/v1/` failures the contract documents for a creation and a state's answer, each status with its codes. */
449
809
  const DOCUMENTED = [[400, ["bad_request"]], [401, ["unauthenticated"]], [403, ["refused"]], [404, ["not_found"]],
450
- [409, ["refused", "busy"]], [413, ["too_large"]], [415, ["bad_request"]], [422, ["checks_failed"]], [429, ["slow_down"]], [500, ["failed"]],
810
+ [409, ["refused", "busy"]], [413, ["too_large"]], [415, ["bad_request"]], [422, ["checks_failed"]], [426, ["refused", "upgrade_required"]], [429, ["slow_down"]], [500, ["failed"]],
451
811
  [502, ["unavailable"]], [503, ["checks_unknown", "unavailable"]], [504, ["unavailable"]]];
452
812
  /**
453
813
  * EVERYTHING A CREATION CAN PRINT OF ITS OWN, rendered (R2-B2: a password made of the CLI's own words failed an honest run,
@@ -459,7 +819,7 @@ export function printedCorpus(c, tail = 256) {
459
819
  const w = createWords(c), service = { base: c.base }, P = "#";
460
820
  const failure = (f) => failureText("replica", f, PLAIN);
461
821
  const asJson = (v, next) => jsonText(REPLICA, v, next);
462
- const sample = (state, pending = []) => ({ org: c.r.org, name: c.r.name, region: c.region, sizeGb: c.sizeGb, hostId: P, state, createdAt: P, allowlist: [], pending });
822
+ const sample = (state, pending = [], page = P) => ({ org: c.r.org, name: c.r.name, region: c.region, sizeGb: c.sizeGb, hostId: P, state, createdAt: P, allowlist: [], page, pending });
463
823
  const pendings = [[], ...["create", "allowlist"].flatMap((change) => ["pending", "failed"].flatMap((st) => [0, 1, 2].map((attempts) => [{ change, state: st, attempts, failure: P, nextAttemptAt: P }])))];
464
824
  const checks = OUTCOMES.map((outcome) => ({ source: c.source, outcome, checks: CHECK_STATUSES.map((status) => ({ name: P, status, detail: P, fix: P })), missing: [P] }));
465
825
  const ends = [];
@@ -467,16 +827,16 @@ export function printedCorpus(c, tail = 256) {
467
827
  const failures = [w.failed(P), w.failed(""), w.deleted("deleting"), w.deleted("deleted"), w.gone(),
468
828
  ...STATES.map((st) => w.timedOut(st)),
469
829
  ...[{ why: P }, { why: "no answer within 60 s" }, { why: "no answer within 15 s" }].flatMap((x) => [true, false].map((absent) => w.lostFailure({ message: `could not reach ${c.base}`, code: "failed", why: x.why, fix: `check this machine is online, and that MASON_CLOUD_URL names the service (it is ${c.base})`, next: [] }, absent))),
470
- ...DOCUMENTED.flatMap(([status, codes]) => codes.flatMap((code) => [P, undefined].map((why) => {
830
+ ...DOCUMENTED.flatMap(([status, codes]) => codes.flatMap((code) => [P, undefined].flatMap((why) => {
471
831
  const f = serviceFailure({ status, json: { error: { code, message: P, ...(why ? { why, fix: P } : {}) } }, what: `POST /v1/replicas/${c.r.org}`, retryAfter: null }, [], w.refusedNext);
472
- return status === 422 ? { ...f, next: w.checksFailedNext } : f;
832
+ return status === 422 ? [{ ...f, next: w.checksFailedNext }] : status === 503 ? [f, { ...f, why: w.retryIn(f.why, P) }] : [f];
473
833
  }))),
474
834
  ...[200, 201, ...DOCUMENTED.map(([st]) => st)].flatMap((status) => [true, false].flatMap((isJson) => ["a replica was created", "a replica's state was asked for"].map((step) => unexpected(service, { status, json: isJson ? {} : undefined, what: step === "a replica was created" ? `POST /v1/replicas/${c.r.org}` : `GET /v1/replicas/${c.r.org}/${c.r.name}`, retryAfter: null }, step))))];
475
835
  for (const f of failures)
476
- ends.push({ stderr: failure(f) }, { stdout: asJson({ replica: sample("creating"), checks, error: errorJson(f) }, f.next ?? []) }, { stdout: asJson({ checks, error: errorJson(f) }, f.next ?? []) });
836
+ ends.push({ stderr: failure(f) }, { stdout: asJson({ replica: sample("creating"), checks, error: errorJson(f) }, f.next ?? []) }, { stdout: asJson({ replica: sample("creating", [], null), checks, error: errorJson(f) }, f.next ?? []) }, { stdout: asJson({ checks, error: errorJson(f) }, f.next ?? []) });
477
837
  for (const pending of pendings)
478
838
  for (const next of nexts) {
479
- ends.push({ stdout: w.report(sample("ready", pending), next, PLAIN) }, { stdout: asJson({ replica: sample("ready", pending), checks }, next) }, { stdout: asJson({ replica: sample("ready", pending), checks: null }, next) });
839
+ ends.push({ stdout: w.report(sample("ready", pending), next, PLAIN) }, { stdout: w.report(sample("ready", pending, null), next, PLAIN) }, { stdout: asJson({ replica: sample("ready", pending), checks }, next) }, { stdout: asJson({ replica: sample("ready", pending, null), checks }, next) }, { stdout: asJson({ replica: sample("ready", pending), checks: null }, next) });
480
840
  }
481
841
  // the lines on stderr before an end, in the order they are printed: the progress, the checks or a lost answer's line,
482
842
  // then the wait's lines (each state, as it is or unsure), in both orders
@@ -512,34 +872,133 @@ export function ownWordsHold(password, c) {
512
872
  return reads.some((read) => readingsCarry(read, [password]));
513
873
  }
514
874
  const CORPUS = new Map();
875
+ /** How long stdin may stay silent when it is not a terminal: a pipe left open with nothing in it never holds create. */
876
+ export const STDIN_MS = 60_000;
877
+ /** The URL's own bound: what the contract takes (docs/HOSTED-API.md). */
878
+ export const MAX_URL = 2048;
879
+ /** The one variable a replica sets for its source (docs/HOSTED-API.md): a service refuses any other `variable`. */
880
+ export const REPLICA_VARIABLE = "DATABASE_URL";
881
+ /** The lowest port a replica connects to: below it, a service refuses the URL. */
882
+ export const MIN_PORT = 1024;
883
+ /** libpq's parameter names, said by name when a URL carries one; any other is "a parameter", never its text. */
884
+ const LIBPQ_PARAMS = new Set(["host", "hostaddr", "port", "dbname", "user", "password", "passfile", "options", "application_name", "fallback_application_name",
885
+ "connect_timeout", "client_encoding", "keepalives", "target_session_attrs", "sslcert", "sslkey", "sslrootcert", "sslcrl", "sslpassword", "sslnegotiation",
886
+ "gssencmode", "krbsrvname", "service", "replication", "channel_binding", "require_auth", "load_balance_hosts"]);
887
+ /**
888
+ * WHAT A SERVICE REFUSES OF A URL (docs/HOSTED-API.md "The URL"), checked here before anything is sent: more than one
889
+ * host, no host, no password, no database name, a query parameter other than `sslmode`, a port below `MIN_PORT`. Each
890
+ * is said by what is wrong, never by a part of the URL (a parameter by libpq's name alone). Null when none applies,
891
+ * or when it is not a URL at all (Mason's own grammar, `parseSource`, says so after).
892
+ */
893
+ export function replicaUrlProblem(url) {
894
+ const after = url.trim().replace(/^[a-z]+:\/\//i, "");
895
+ const authority = after.split(/[/?#]/, 1)[0] ?? "";
896
+ if (authority.slice(authority.lastIndexOf("@") + 1).includes(","))
897
+ return "it names more than one host: a replica connects to one host, postgres://user:password@host:5432/your_app";
898
+ let u;
899
+ try {
900
+ u = new URL(url.trim());
901
+ }
902
+ catch {
903
+ return null;
904
+ }
905
+ if (u.hostname === "")
906
+ return "it names no host: postgres://user:password@host:5432/your_app";
907
+ if (u.password === "")
908
+ return "it has no password: a replica connects as a user with one, postgres://user:password@host:5432/your_app";
909
+ if (u.pathname.replace(/^\//, "") === "")
910
+ return "it names no database: add it after the port, postgres://user:password@host:5432/your_app";
911
+ const others = [...new Set([...u.searchParams.keys()].filter((k) => k !== "sslmode"))];
912
+ if (others.length) {
913
+ const named = others.map((k) => k.toLowerCase()).filter((k) => LIBPQ_PARAMS.has(k));
914
+ const what = named.length === others.length ? `${named.length === 1 ? "the parameter" : "the parameters"} ${named.join(", ")}` : named.length ? `the parameters ${named.join(", ")} and another` : "a parameter";
915
+ return `it has ${what} after the ?: a replica takes sslmode alone; leave the rest out`;
916
+ }
917
+ if (u.port !== "" && Number(u.port) < MIN_PORT)
918
+ return `its port is below ${MIN_PORT}: a replica connects to a database on port ${MIN_PORT} or above (Postgres's own is 5432)`;
919
+ return null;
920
+ }
921
+ /**
922
+ * THE DATABASE'S URL, from the person or the pipe (src/cli/secret-input.ts): stdin a terminal, a hidden prompt, its
923
+ * question on stderr; else stdin read whole. Never a flag, an argument or a file. What went wrong is said by kind,
924
+ * never by value, with the line to run instead (`again`).
925
+ */
926
+ export async function readUrl(variable, again, input = process.stdin, output = process.stderr, o = {}) {
927
+ const tty = input.isTTY === true;
928
+ const from = tty ? "typed at the prompt" : "read from stdin";
929
+ const piped = `${again} < url.txt`;
930
+ let read;
931
+ try {
932
+ read = tty ? await readHidden(input, output, `${variable}, the database's URL (not shown; Ctrl-C cancels): `, MAX_URL)
933
+ : await readPiped(input, MAX_URL, o.stdinMs ?? STDIN_MS);
934
+ }
935
+ catch (e) {
936
+ // the stream's own error (a terminal gone, a pipe broken): its words are the system's, and hold nothing typed
937
+ return { message: `the database's URL could not be ${tty ? "read at the prompt" : "read from stdin"}: nothing was sent`, code: "failed",
938
+ why: serviceText(e instanceof Error ? e.message : String(e), 200), fix: tty ? `run it again, or give the URL on stdin: ${piped}` : `run it again: ${piped}` };
939
+ }
940
+ if ("value" in read)
941
+ return { url: read.value, from };
942
+ const fix = tty ? `run it again and paste the URL when it asks, or give it on stdin: ${piped}` : `give the URL on stdin: ${piped} (or from a secret store's command, piped); at a terminal, it is asked for`;
943
+ const said = {
944
+ cancelled: { message: "cancelled: no URL was given, and nothing was sent", why: tty ? "Ctrl-C, or the input ended, at the prompt" : "stdin ended" },
945
+ empty: tty ? { message: "cancelled: no URL was typed, and nothing was sent", why: "an empty line cancels the prompt" }
946
+ : { message: "stdin was empty: no URL was given, and nothing was sent", why: "stdin is not a terminal, so mason replica create reads the database's URL from it, whole" },
947
+ too_long: { message: `the database's URL ${from} is longer than ${MAX_URL} bytes: nothing was sent`, why: "a URL is sent as postgres://… or postgresql://…, at most 2 KiB" },
948
+ lines: { message: "stdin holds more than one line: nothing was sent", why: "the database's URL is read from stdin whole, one line (one trailing newline is trimmed)" },
949
+ timeout: { message: `nothing arrived on stdin within ${Math.round((o.stdinMs ?? STDIN_MS) / 1000)} s: nothing was sent`, why: "stdin is not a terminal, so mason replica create reads the database's URL from it, and it stayed open with nothing in it" },
950
+ closed: { message: "the prompt's input ended before the line did: nothing was sent", why: "the terminal closed while the URL was typed" },
951
+ };
952
+ return { ...said[read.none], code: "refused", fix };
953
+ }
515
954
  async function create(r, o, json) {
516
- // THE DATABASE: the project's sources file names it by a variable, read here by the one reader
955
+ // THE DATABASE: the project's sources file names the variable the replica reads it by; its value is never read here
517
956
  const t = theProject(process.cwd());
518
957
  if ("problem" in t)
519
958
  return fail({ message: t.problem, code: t.code, why: t.why ?? "a replica syncs the database this project's sources file names", fix: t.fix, next: t.next }, json);
520
959
  const p = t.project;
521
- const src = readSource(p.folder, p.root);
960
+ const src = readSourceFile(p.folder, p.root);
522
961
  if (isFailure(src))
523
962
  return fail(src, json);
524
963
  const file = shownPath(p.root, src.file);
964
+ const again = `mason replica create ${replicaText(r)} --region ${o.region} --size ${o.sizeGb}`;
525
965
  if ("literal" in src.url) {
526
966
  return fail({ message: `${file}: url: is written in the file`, code: "refused",
527
967
  why: "a replica reads its database's URL from a variable of its own environment, by the name the sources file gives it; a URL written in the file would also be kept with the project's files",
528
- fix: `write url: \${DATABASE_URL} in ${file}, and put DATABASE_URL=<the URL> in .env (or export it), then mason replica create again` }, json);
968
+ fix: `write url: \${DATABASE_URL} in ${file}, then ${again} again: it asks for the URL at a terminal, or reads it from stdin (${again} < url.txt)` }, json);
529
969
  }
530
970
  const variable = src.url.env;
531
- // the value is held to the grammar Mason connects by before it leaves this machine, never repeated (preflight's rule)
532
- const parsed = parseSource(src.resolved, `${variable} (${src.from})`);
533
- if ("problem" in parsed || !/^postgres(?:ql)?:\/\//i.test(src.resolved.trim()) || src.resolved.length > 2048) {
534
- return fail({ message: `${variable} (${src.from}) is not a database URL a replica can be made for`, code: "refused",
535
- why: "problem" in parsed ? parsed.problem : "it is sent as postgres://… or postgresql://…, at most 2 KiB",
536
- fix: `set ${variable} to the database's URL, postgres://user:password@host:5432/your_app` }, json);
971
+ // the one variable a replica sets for its database: a service refuses any other name (400), so it is refused here,
972
+ // before the URL is asked for
973
+ if (variable !== REPLICA_VARIABLE) {
974
+ return fail({ message: `${file}: url: \${${variable}} names a variable a replica does not set`, code: "refused",
975
+ why: `a replica sets one variable for its database, ${REPLICA_VARIABLE}, and reads the URL by that name; a service refuses any other`,
976
+ fix: `write url: \${${REPLICA_VARIABLE}} in ${file} (and ${REPLICA_VARIABLE}= in .env, for mason start), then ${again} again` }, json);
537
977
  }
538
- const url = src.resolved;
539
978
  const service = serviceOf();
540
979
  if (isFailure(service))
541
980
  return fail(service, json);
542
- const run = { r, region: o.region, sizeGb: o.sizeGb, timeout: o.timeout, base: service.base, variable, from: src.from, source: src.name };
981
+ const s = signedIn("a replica is created", `mason replica create ${replicaText(r)}`, json);
982
+ if (isFailure(s))
983
+ return fail(s, json);
984
+ const { token } = s;
985
+ // THE URL: asked for, or read from stdin; never a flag, an argument or a file
986
+ const got = await readUrl(variable, again);
987
+ if (isFailure(got))
988
+ return fail(got, json);
989
+ const { url, from } = got;
990
+ const named = `the URL given for ${variable} (${from})`;
991
+ // the value is held to the grammar Mason connects by before it leaves this machine, never repeated (preflight's rule)
992
+ // first its form, then what a service refuses (each said by what is wrong, never by a part of it), then the grammar
993
+ // Mason connects by
994
+ const form = !/^postgres(?:ql)?:\/\//i.test(url.trim()) || url.length > MAX_URL ? "it is sent as postgres://… or postgresql://…, at most 2 KiB" : replicaUrlProblem(url);
995
+ const parsed = form === null ? parseSource(url, named) : null;
996
+ const problem = form ?? (parsed !== null && "problem" in parsed ? parsed.problem : null);
997
+ if (problem !== null) {
998
+ return fail({ message: `${named} is not a database URL a replica can be made for`, code: "refused", why: problem,
999
+ fix: `give the database's URL, postgres://user:password@host:5432/your_app: ${again} < url.txt, or at the prompt` }, json);
1000
+ }
1001
+ const run = { r, region: o.region, sizeGb: o.sizeGb, timeout: o.timeout, base: service.base, variable, from, source: src.name };
543
1002
  const w = createWords(run);
544
1003
  // THE PASSWORD, before anything is held or sent (neither refusal names it): one too short to be told apart from
545
1004
  // words, or one the CLI's own words hold, is not sent, since no honest run could print without the gate refusing it
@@ -547,16 +1006,14 @@ async function create(r, o, json) {
547
1006
  const notSent = (message, why) => fail({ message, code: "refused", why,
548
1007
  fix: "name a database user with another password in the URL, or create the replica in the service's web app" }, json);
549
1008
  if ("short" in pw) {
550
- return notSent(`the password in ${variable} (${src.from}) is shorter than the ${MIN_PASSWORD} characters this command sends`, "a password this short cannot be told apart from ordinary words in what the service answers, so nothing could keep it out of what is printed; it is not sent");
1009
+ return notSent(`the password in ${named} is shorter than the ${MIN_PASSWORD} characters this command sends`, "a password this short cannot be told apart from ordinary words in what the service answers, so nothing could keep it out of what is printed; it is not sent");
551
1010
  }
552
1011
  if (pw.password !== null && ownWordsHold(pw.password, run)) {
553
- return notSent(`the password in ${variable} (${src.from}) is in the words this command prints itself`, "this command keeps the password out of everything it prints by finding it there, and its own words (its lines, its steps, the replica's name or the service's address) hold this one: it could not print its answer, so it is not sent");
1012
+ return notSent(`the password in ${named} is in the words this command prints itself`, "this command keeps the password out of everything it prints by finding it there, and its own words (its lines, its steps, the replica's name or the service's address) hold this one: it could not print its answer, so it is not sent");
554
1013
  }
555
1014
  const held = pw.password === null ? [] : [pw.password];
556
- const s = signedIn("a replica is created", `mason replica create ${replicaText(r)}`, json, held);
557
- if (isFailure(s))
558
- return fail(s, json);
559
- const { token } = s;
1015
+ // the gate holds it beside the token before anything more is written (src/cli/output-gate.ts)
1016
+ holdOutput({ command: "replica", json, secrets: held });
560
1017
  // ONE list of what this command holds, read by every boundary: the exact test, the masks and the output's gate
561
1018
  const secrets = [token, ...held];
562
1019
  const where = `/v1/replicas/${r.org}`;
@@ -565,14 +1022,14 @@ async function create(r, o, json) {
565
1022
  say(w.progress);
566
1023
  // THE CREATION: the service checks the source, then answers 201, or why it was not made (with the checks, said)
567
1024
  const sent = await call(service, "POST", where, { token, timeoutMs: CREATE_MS,
568
- json: { name: r.name, region: o.region, sizeGb: o.sizeGb, sources: [{ name: src.name, variable, url }] } });
1025
+ json: { name: r.name, region: o.region, sizeGb: o.sizeGb, sources: [{ name: src.name, variable, url }], client: { version: version() } } });
569
1026
  let checks = null;
570
1027
  let state;
571
1028
  let waitMs = REPLICA_POLL_MS;
572
1029
  if (isFailure(sent)) {
573
1030
  // a POST that got no answer may have made the replica: its state says so (the contract)
574
1031
  const a = await call(service, "GET", `${where}/${r.name}`, { token });
575
- const found = !isFailure(a) && a.status === 200 ? replicaAnswer(a, r, secrets) : null;
1032
+ const found = !isFailure(a) && a.status === 200 ? replicaAnswer(service, a, r, secrets)?.replica ?? null : null;
576
1033
  // why it got no answer is the system's words, never the CLI's own: masked by what this command holds, as a service's are
577
1034
  const why = serviceText(sent.why ?? sent.message, 300, secrets);
578
1035
  if (found === null)
@@ -587,7 +1044,9 @@ async function create(r, o, json) {
587
1044
  if (obj(sent.json) && sent.status !== 201 && "checks" in sent.json && carriesNoSecret(wordless({ checks: sent.json.checks }), secrets))
588
1045
  checks = heldChecks(sent.json.checks, src.name, secrets);
589
1046
  if (sent.status !== 201) {
590
- const f = refusedOf(sent, "a replica was created");
1047
+ const refused = refusedOf(sent, "a replica was created");
1048
+ // a launch the service could not start: its words, and when it asks to be tried again (the CLI does not wait)
1049
+ const f = sent.status === 503 && sent.retryAfter !== null ? { ...refused, why: w.retryIn(refused.why, sent.retryAfter) } : refused;
591
1050
  if (!json && checks !== null)
592
1051
  for (const l of checkLines(checks))
593
1052
  say(l);
@@ -600,7 +1059,7 @@ async function create(r, o, json) {
600
1059
  return fail(failure, false);
601
1060
  }
602
1061
  const j = sent.json;
603
- const made = obj(j) && carriesNoSecret(wordless(j), secrets) ? heldReplica(j.replica, r, secrets) : null;
1062
+ const made = obj(j) && carriesNoSecret(wordless(j), secrets) ? heldReplica(j.replica, r, secrets, service) : null;
604
1063
  checks = obj(j) ? heldChecks(j.checks, src.name, secrets) : null;
605
1064
  if (made === null || checks === null)
606
1065
  return fail(unexpected(service, sent, "a replica was created"), json);
@@ -657,9 +1116,209 @@ async function create(r, o, json) {
657
1116
  return ended(w.gone());
658
1117
  if (a.status !== 200)
659
1118
  return ended(refusedOf(a, "a replica's state was asked for"));
660
- const now = replicaAnswer(a, r, secrets);
1119
+ const now = replicaAnswer(service, a, r, secrets);
661
1120
  if (now === null)
662
1121
  return ended(unexpected(service, a, "a replica's state was asked for"));
663
- state = now;
1122
+ state = now.replica;
1123
+ }
1124
+ }
1125
+ // ------------------------------------------------------------------ delete
1126
+ /** Whether a person is there to type the replica's name back: a terminal both ways, and no --json (never an environment
1127
+ * variable: src/cli/share.ts's rule). */
1128
+ const canAsk = (json) => !json && process.stdin.isTTY === true && process.stderr.isTTY === true;
1129
+ /**
1130
+ * A PERSON TYPES THE REPLICA'S NAME BACK: whether they typed exactly it (nothing trimmed or re-cased, as the service takes
1131
+ * the confirm). What was typed is never printed: it may be anything, a secret pasted in the wrong place. The terminal
1132
+ * echoes it, as a terminal does; the CLI writes nothing of it.
1133
+ */
1134
+ async function typedBack(name) {
1135
+ const rl = createInterface({ input: process.stdin, output: process.stderr, terminal: false });
1136
+ const closed = once(rl, "close").then(() => null);
1137
+ const answer = await Promise.race([rl.question("> "), closed]);
1138
+ rl.close();
1139
+ // the input ended with nothing typed (a terminal echoes no line for it): the prompt's line ends here
1140
+ if (answer === null)
1141
+ process.stderr.write("\n");
1142
+ return answer === name;
1143
+ }
1144
+ /** EVERY WORD A DELETION PRINTS OF ITS OWN, by the builders it prints with; what the service sends goes in where a
1145
+ * builder takes it, held and masked. */
1146
+ export function deleteWords(r, base, every) {
1147
+ const name = replicaText(r);
1148
+ const again = [`mason replica delete ${name}`, "delete it again (a repeat changes nothing)"];
1149
+ const status = statusStep(r);
1150
+ const at = (seconds) => new Date(Date.now() + seconds * 1000).toISOString().replace(/\.\d{3}Z$/, "Z");
1151
+ return {
1152
+ again, status,
1153
+ ask: `Deleting ${name} on ${base} cannot be undone: the replica and its synced data go, and Mason's replication slot on its database is dropped. Type ${name} to delete it, anything else to keep it.`,
1154
+ notTyped: { message: `${name} was not deleted: what was typed is not its name`, code: "refused",
1155
+ why: "a deletion is asked for twice: on the command line, and typed back exactly (nothing trimmed, nothing re-cased)",
1156
+ fix: `run it again and type ${name}; where no person is asked, mason replica delete ${name} --yes`, next: [[`mason replica delete ${name}`, "ask again"]] },
1157
+ progress: `Deleting ${name} on ${base}: each step is said as the service reports it, waiting up to ${every}`,
1158
+ lost: (why, d) => ` The deletion's answer was lost (${why}); the service has ${name}, its deletion ${d ? stageLine(d) : NO_DELETION}`,
1159
+ unsure: (d, why) => `${d ? stageLine(d) : NO_DELETION}; the service could not say more just now (${why}): asking again`,
1160
+ noDeletion: NO_DELETION,
1161
+ headline: `Deleted ${name} on ${base}`,
1162
+ waiting: `${name} on ${base} is not deleted yet: its deletion needs a person`,
1163
+ /** a refusal's next step, the CLI's own, by the status (and a 403's code, which the service's words carry) */
1164
+ refusedNext: (status, code, retryAfter) => {
1165
+ if (status === 403 && code === "fresh_login_needed") {
1166
+ return [["mason login", `sign in again, then mason replica delete ${name} within 15 minutes of it; or delete it on its page in the service's web app, ${base}`]];
1167
+ }
1168
+ if (status === 401 || status === 403)
1169
+ return [["mason login", `sign in as an owner of ${r.org}: an owner deletes a replica, signed in as a person (a CI token deletes none)`]];
1170
+ if (status === 404)
1171
+ return [listStep(r.org)];
1172
+ if (status === 429 || status === 503)
1173
+ return [[again[0], retryAfter !== null ? `again after ${at(retryAfter)} (in ${retryAfter} s), when the service takes it` : "again later: the service did not say when"]];
1174
+ return [again];
1175
+ },
1176
+ lostFailure: (sent, seen) => ({ ...sent,
1177
+ why: `${sent.why ?? "no answer"}; ${seen === null ? "whether its deletion began is not known" : "absent" in seen ? `asked since, the service has no ${name} for this login` : `asked since, it is ${seen.state}: its deletion did not begin`}`,
1178
+ fix: `mason replica status ${name} says whether it is being deleted; if it is not, delete it again (a repeat changes nothing)`, next: [status, again] }),
1179
+ /**
1180
+ * NEEDS A PERSON: a slot not verified gone, the replica kept until it is seen gone or confirmed. The statements were
1181
+ * printed; the wait stops at once (nothing more comes without the person), and the exit is 1: it is not deleted.
1182
+ */
1183
+ needsPerson: (d) => {
1184
+ const left = d.sources.filter((x) => x.outcome === "not_verified");
1185
+ const one = left.length === 1;
1186
+ return { message: `${name} is not deleted yet: ${one ? `the replication slot of source ${left[0].ordinal} is` : `the replication slots of ${left.length} sources are`} NOT verified gone`, code: "failed",
1187
+ why: "a replication slot holds its database's WAL while it exists, whatever the disk, so the service keeps the replica until each slot is seen gone or a person confirms it was dropped",
1188
+ fix: `run the statement printed for ${one ? "it" : "each"} on ${one ? "its database" : "each one's database"} (with --json, deletion.sources[].byHand), then confirm it on the replica's page in the service's web app (or there, that the database is no more): its deletion then finishes`,
1189
+ next: [[status[0], "how its deletion goes, once confirmed"]] };
1190
+ },
1191
+ timedOut: (d) => ({ message: `${name}'s deletion did not finish within ${every}`, code: "timeout", why: `it was last ${d ? stageLine(d) : NO_DELETION}`,
1192
+ fix: `nothing was stopped, and its deletion goes on: mason replica status ${name} says how it goes; a longer wait is --timeout 30m`, next: [status] }),
1193
+ /** gone midway: the statement that lists Mason's slots, pasted (`pasteLines`) — under --json, where nothing is
1194
+ * broken, in the fix too */
1195
+ gone: (json) => ({ message: `the service has no ${name} now`, code: "not_found",
1196
+ why: "it was being deleted, and the service has no record of it now: what its deletion did to each replication slot is not known",
1197
+ fix: json ? `look for Mason's slots on each database it synced, and drop this replica's: ${listSlotsStatement()}` : "look for Mason's slots on each database it synced with the statement printed above, and drop this replica's",
1198
+ next: [listStep(r.org)] }),
1199
+ goneSaid: "by hand, on each database it synced: this lists Mason's replication slots; drop this replica's slot from them, as a user that may drop a replication slot",
1200
+ };
1201
+ }
1202
+ async function remove(r, o, json) {
1203
+ const name = replicaText(r);
1204
+ const s = signedIn("a replica is deleted", `mason replica delete ${name}`, json);
1205
+ if (isFailure(s))
1206
+ return fail(s, json);
1207
+ const { service, token } = s, secrets = [token];
1208
+ const w = deleteWords(r, service.base, o.timeout.every);
1209
+ // A PERSON TYPES IT BACK (else --yes said it on the line): nothing is sent before
1210
+ if (!o.yes) {
1211
+ say(w.ask);
1212
+ if (!(await typedBack(name)))
1213
+ return fail(w.notTyped, json);
1214
+ }
1215
+ const where = `/v1/replicas/${r.org}/${r.name}`;
1216
+ const step = "a replica's deletion was asked for";
1217
+ if (!json)
1218
+ say(w.progress);
1219
+ // THE DELETION: 202 under way, 200 done already (a repeat changes nothing), else why not, as the service worded it
1220
+ const sent = await call(service, "DELETE", where, { token, json: { confirm: name } });
1221
+ // its deletion: null while the service gives none (a replica deleting may carry none)
1222
+ let state, deletion, waitMs = REPLICA_POLL_MS;
1223
+ const begun = (x) => x.state === "deleting" || x.state === "deleted";
1224
+ if (isFailure(sent)) {
1225
+ // a DELETE that got no answer may have begun it: its state says so
1226
+ const a = await call(service, "GET", where, { token });
1227
+ const found = !isFailure(a) && a.status === 200 ? replicaAnswer(service, a, r, secrets) : null;
1228
+ const why = serviceText(sent.why ?? sent.message, 300, secrets);
1229
+ if (found === null || !begun(found.replica)) {
1230
+ return fail(w.lostFailure({ ...sent, why }, found !== null ? { state: found.replica.state } : !isFailure(a) && a.status === 404 ? { absent: true } : null), json);
1231
+ }
1232
+ if (!json)
1233
+ say(w.lost(why, found.deletion));
1234
+ state = found.replica;
1235
+ deletion = found.deletion;
1236
+ if (!isFailure(a) && a.retryAfter !== null)
1237
+ waitMs = a.retryAfter * 1000;
1238
+ }
1239
+ else {
1240
+ if (sent.status !== 200 && sent.status !== 202) {
1241
+ const e = obj(sent.json) && obj(sent.json.error) && typeof sent.json.error.code === "string" ? sent.json.error.code : "";
1242
+ return fail(serviceFailure(sent, secrets, (st) => w.refusedNext(st, e, sent.retryAfter)) ?? unexpected(service, sent, step), json);
1243
+ }
1244
+ const held = replicaAnswer(service, sent, r, secrets);
1245
+ // 202 is a deletion under way (its deletion, if the service gives one, not finished), 200 one finished: anything else
1246
+ // is outside the contract
1247
+ if (held === null || !begun(held.replica) || (sent.status === 202) !== (held.replica.state === "deleting"))
1248
+ return fail(unexpected(service, sent, step), json);
1249
+ state = held.replica;
1250
+ deletion = held.deletion;
1251
+ if (sent.retryAfter !== null)
1252
+ waitMs = sent.retryAfter * 1000;
1253
+ }
1254
+ // THE WAIT: until its deletion is finished, bounded by --timeout; each step said once, as it is done
1255
+ const deadline = Date.now() + o.timeout.seconds * 1000;
1256
+ let said = "", saidAt = 0;
1257
+ const told = new Set();
1258
+ const show = (line) => { if (!json && sayAgain(line, said, saidAt, Date.now())) {
1259
+ say(` ${line}`);
1260
+ said = line;
1261
+ saidAt = Date.now();
1262
+ } };
1263
+ const done = (d) => { if (!json)
1264
+ for (const x of d.steps)
1265
+ if (x.doneAt !== null && !told.has(x.step)) {
1266
+ told.add(x.step);
1267
+ say(` done: ${x.what}`);
1268
+ } };
1269
+ const ended = (error) => {
1270
+ const next = error === null ? nextOf(state) : error.next ?? [];
1271
+ if (json) {
1272
+ out(jsonText(REPLICA, { replica: state, deletion, ...(error ? { error: errorJson(error) } : {}) }, next));
1273
+ return error ? exitOf(error) : 0;
1274
+ }
1275
+ // finished, or needing a person: what it did to each slot, and the statements by hand (a failure after them says why
1276
+ // the exit is 1)
1277
+ if (deletion?.stage === "finished" || deletion?.stage === "needs_person") {
1278
+ out(deletionReport({ headline: deletion.stage === "finished" ? w.headline : w.waiting, details: deletionRows(deletion), deletion, next: error === null ? next : [] }, outStyle()));
1279
+ }
1280
+ return error === null ? 0 : fail(error, false);
1281
+ };
1282
+ for (;;) {
1283
+ if (deletion?.stage === "finished") {
1284
+ done(deletion);
1285
+ return ended(null);
1286
+ }
1287
+ // a person is needed: nothing more comes of waiting, so it stops at once, whatever --timeout
1288
+ if (deletion?.stage === "needs_person") {
1289
+ done(deletion);
1290
+ return ended(w.needsPerson(deletion));
1291
+ }
1292
+ show(deletion ? stageLine(deletion) : w.noDeletion);
1293
+ if (deletion)
1294
+ done(deletion);
1295
+ const left = deadline - Date.now();
1296
+ if (left <= 0)
1297
+ return ended(w.timedOut(deletion));
1298
+ await sleep(Math.min(waitMs, left));
1299
+ const a = await call(service, "GET", where, { token });
1300
+ waitMs = REPLICA_POLL_MS;
1301
+ // an answer that cannot say how it goes is a wait, never an outcome: the timeout bounds it
1302
+ if (isFailure(a) || a.status === 429 || a.status >= 500) {
1303
+ if (!isFailure(a) && a.retryAfter !== null)
1304
+ waitMs = a.retryAfter * 1000;
1305
+ show(w.unsure(deletion, isFailure(a) ? serviceText(a.why ?? a.message, 300, secrets) : `${a.what} answered ${a.status}`));
1306
+ continue;
1307
+ }
1308
+ if (a.retryAfter !== null)
1309
+ waitMs = a.retryAfter * 1000;
1310
+ if (a.status === 404) {
1311
+ if (!json)
1312
+ out(pasteLines([[w.goneSaid, listSlotsStatement()]], outStyle()).join("\n"));
1313
+ return ended(w.gone(json));
1314
+ }
1315
+ if (a.status !== 200)
1316
+ return ended(serviceFailure(a, secrets, (st) => refusedNext(st, r.org, "a member", w.status)) ?? unexpected(service, a, step));
1317
+ const now = replicaAnswer(service, a, r, secrets);
1318
+ // a deletion does not end otherwise than finished: a replica in none now is outside the contract
1319
+ if (now === null || !begun(now.replica))
1320
+ return ended(unexpected(service, a, step));
1321
+ state = now.replica;
1322
+ deletion = now.deletion;
664
1323
  }
665
1324
  }