@mason-data/cli 0.7.0 → 0.9.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 (155) hide show
  1. package/README.md +101 -73
  2. package/dist/src/api/error.js +3 -0
  3. package/dist/src/api/postgres/catalog-worker.js +49 -2
  4. package/dist/src/api/postgres/catalog.js +368 -25
  5. package/dist/src/api/postgres/encode.js +3 -1
  6. package/dist/src/api/postgres/explain.js +3 -2
  7. package/dist/src/api/postgres/listener.js +11 -3
  8. package/dist/src/api/postgres/protocol.js +39 -23
  9. package/dist/src/api/postgres/proxy.js +283 -0
  10. package/dist/src/api/postgres/schema.js +6 -0
  11. package/dist/src/api/postgres/server.js +1617 -245
  12. package/dist/src/api/postgres/translate.js +127 -10
  13. package/dist/src/api/request-log.js +18 -5
  14. package/dist/src/cdc/poll.js +5 -5
  15. package/dist/src/cli/background.js +47 -25
  16. package/dist/src/cli/bridge-stdin.js +101 -0
  17. package/dist/src/cli/cancel.js +75 -0
  18. package/dist/src/cli/command.js +170 -11
  19. package/dist/src/cli/commands.js +804 -209
  20. package/dist/src/cli/control.js +59 -0
  21. package/dist/src/cli/credentials.js +182 -0
  22. package/dist/src/cli/demo.js +20 -11
  23. package/dist/src/cli/deploy.js +574 -0
  24. package/dist/src/cli/dev.js +491 -130
  25. package/dist/src/cli/doctor.js +131 -38
  26. package/dist/src/cli/generated-files.js +143 -0
  27. package/dist/src/cli/guides.js +175 -36
  28. package/dist/src/cli/hosted.js +850 -0
  29. package/dist/src/cli/init.js +162 -65
  30. package/dist/src/cli/login.js +289 -0
  31. package/dist/src/cli/machine.js +13 -12
  32. package/dist/src/cli/main.js +11 -5
  33. package/dist/src/cli/mason.cjs +17 -0
  34. package/dist/src/cli/mcp.js +31 -22
  35. package/dist/src/cli/metrics.js +79 -0
  36. package/dist/src/cli/output-gate.js +176 -0
  37. package/dist/src/cli/page-share.js +96 -0
  38. package/dist/src/cli/pg-tls.js +14 -4
  39. package/dist/src/cli/pg-url.js +4 -0
  40. package/dist/src/cli/pin.js +19 -14
  41. package/dist/src/cli/pool-size.cjs +85 -0
  42. package/dist/src/cli/ports.js +2 -0
  43. package/dist/src/cli/preflight.js +138 -46
  44. package/dist/src/cli/project.js +142 -4
  45. package/dist/src/cli/readiness.js +76 -6
  46. package/dist/src/cli/registry.js +65 -38
  47. package/dist/src/cli/replica-hosted.js +665 -0
  48. package/dist/src/cli/replica.js +76 -0
  49. package/dist/src/cli/replication-checks.js +7 -5
  50. package/dist/src/cli/replication-mode.js +105 -26
  51. package/dist/src/cli/request-filter.js +134 -0
  52. package/dist/src/cli/roles.js +585 -0
  53. package/dist/src/cli/run-server.js +637 -48
  54. package/dist/src/cli/runs.js +1 -1
  55. package/dist/src/cli/security.js +67 -18
  56. package/dist/src/cli/serve.js +204 -46
  57. package/dist/src/cli/share-run.js +329 -0
  58. package/dist/src/cli/share.js +286 -0
  59. package/dist/src/cli/source-edit.js +75 -17
  60. package/dist/src/cli/source-role.js +344 -0
  61. package/dist/src/cli/sources.js +306 -47
  62. package/dist/src/cli/style.js +2 -1
  63. package/dist/src/cli/supervise.js +27 -24
  64. package/dist/src/cli/sync.js +55 -25
  65. package/dist/src/cli/token-hosted.js +337 -0
  66. package/dist/src/cli/tunnel.js +200 -0
  67. package/dist/src/cli/ui-ssh.js +460 -0
  68. package/dist/src/cli/ui.js +124 -17
  69. package/dist/src/cli/usage.js +128 -0
  70. package/dist/src/cli/user-hosted.js +299 -0
  71. package/dist/src/cli/user.js +573 -65
  72. package/dist/src/cli/version.js +9 -7
  73. package/dist/src/demo/seed.js +29 -0
  74. package/dist/src/duck/analyze.js +36 -20
  75. package/dist/src/duck/cancel.js +10 -1
  76. package/dist/src/duck/collation.js +266 -0
  77. package/dist/src/duck/constant-key.js +94 -0
  78. package/dist/src/duck/distinct-key.js +283 -26
  79. package/dist/src/duck/events.js +165 -0
  80. package/dist/src/duck/executor-worker.js +20 -11
  81. package/dist/src/duck/executor.js +47 -33
  82. package/dist/src/duck/explain.js +60 -46
  83. package/dist/src/duck/extensions.js +160 -0
  84. package/dist/src/duck/functions.js +461 -63
  85. package/dist/src/duck/gaps.js +48 -21
  86. package/dist/src/duck/generated-plan.js +1070 -0
  87. package/dist/src/duck/generated.js +906 -0
  88. package/dist/src/duck/grants.js +176 -0
  89. package/dist/src/duck/held-types.js +64 -0
  90. package/dist/src/duck/history.js +127 -0
  91. package/dist/src/duck/jsonb-order.js +9 -12
  92. package/dist/src/duck/kept-route.js +6 -5
  93. package/dist/src/duck/kept.js +15 -13
  94. package/dist/src/duck/late.js +1469 -40
  95. package/dist/src/duck/libc-bound.js +341 -0
  96. package/dist/src/duck/libc-order.js +892 -126
  97. package/dist/src/duck/pg-eval-worker.js +7 -1
  98. package/dist/src/duck/pg-eval.js +6 -4
  99. package/dist/src/duck/pgtree.js +774 -23
  100. package/dist/src/duck/plan.js +369 -47
  101. package/dist/src/duck/publication.js +20 -6
  102. package/dist/src/duck/requests.js +269 -93
  103. package/dist/src/duck/resolved.js +117 -2
  104. package/dist/src/duck/rewrite.js +4584 -580
  105. package/dist/src/duck/row-filter.js +655 -0
  106. package/dist/src/duck/statement.js +19 -0
  107. package/dist/src/duck/store.js +439 -177
  108. package/dist/src/duck/stream.js +19 -10
  109. package/dist/src/duck/sync.js +1411 -368
  110. package/dist/src/duck/token.js +4 -1
  111. package/dist/src/duck/types.js +69 -12
  112. package/dist/src/duck/usage.js +326 -0
  113. package/dist/src/duck/users.js +69 -15
  114. package/dist/src/duck/verified.js +161 -1
  115. package/dist/src/duck/views.js +5 -5
  116. package/dist/src/lib/database-name.js +62 -0
  117. package/dist/src/lib/database-url.js +55 -10
  118. package/dist/src/lib/dotenv.js +17 -4
  119. package/dist/src/lib/instant.js +50 -0
  120. package/dist/src/lib/log-buffer.js +77 -0
  121. package/dist/src/lib/log-read.js +196 -0
  122. package/dist/src/lib/pg-numeric.js +390 -49
  123. package/dist/src/lib/project-version.js +23 -23
  124. package/dist/src/lib/secret-mask.js +142 -0
  125. package/dist/src/lib/table-sync.js +282 -0
  126. package/dist/src/web/app.css +706 -66
  127. package/dist/src/web/app.js +331 -409
  128. package/dist/src/web/charts.js +229 -0
  129. package/dist/src/web/index.html +82 -18
  130. package/dist/src/web/pages/queries.js +192 -0
  131. package/dist/src/web/pages/status.js +611 -0
  132. package/dist/src/web/pages/syncs.js +155 -0
  133. package/dist/src/web/pages/tables.js +439 -0
  134. package/dist/src/web/pages/usage.js +397 -0
  135. package/dist/src/web/pages/users.js +219 -0
  136. package/dist/src/web/shared.js +584 -0
  137. package/dist/src/web/vendor/README.md +25 -0
  138. package/dist/src/web/vendor/geist-1.7.2/Geist-Variable.woff2 +0 -0
  139. package/dist/src/web/vendor/geist-1.7.2/GeistMono-Variable.woff2 +0 -0
  140. package/dist/src/web/vendor/geist-1.7.2/LICENSE.txt +92 -0
  141. package/dist/src/web/vendor/lucide-1.56.0/LICENSE.txt +43 -0
  142. package/dist/src/web/vendor/lucide-1.56.0/chart-column.mjs +29 -0
  143. package/dist/src/web/vendor/lucide-1.56.0/layout-dashboard.mjs +29 -0
  144. package/dist/src/web/vendor/lucide-1.56.0/logs.mjs +34 -0
  145. package/dist/src/web/vendor/lucide-1.56.0/menu.mjs +28 -0
  146. package/dist/src/web/vendor/lucide-1.56.0/square-terminal.mjs +28 -0
  147. package/dist/src/web/vendor/lucide-1.56.0/table.mjs +29 -0
  148. package/dist/src/web/vendor/lucide-1.56.0/users.mjs +29 -0
  149. package/dist/src/web/vendor/lucide-1.56.0/x.mjs +27 -0
  150. package/dist/src/web/vendor/uplot-1.6.32/LICENSE.txt +21 -0
  151. package/dist/src/web/vendor/uplot-1.6.32/uPlot.esm.js +6140 -0
  152. package/dist/src/web/vendor/uplot-1.6.32/uPlot.min.css +1 -0
  153. package/dist/src/web/vendor/zilla-slab-5.3.0/LICENSE.txt +93 -0
  154. package/dist/src/web/vendor/zilla-slab-5.3.0/zilla-slab-latin-700-normal.woff2 +0 -0
  155. package/package.json +12 -4
@@ -0,0 +1,76 @@
1
+ import { environmentToken, readLogins, TOKEN_ENV } from "./credentials.js";
2
+ import { isFailure, serviceText, whoAmI } from "./hosted.js";
3
+ /** What one part of a replica's name may be: an organisation's slug, a replica's name. */
4
+ export const REPLICA_PART = /^[a-z0-9][a-z0-9-]{0,62}$/;
5
+ export const REPLICA_ENV = "MASON_REPLICA";
6
+ export const replicaText = (r) => `${r.org}/${r.name}`;
7
+ /** The replica the command means: `--replica`, else `MASON_REPLICA`; null when neither names one, a Failure when it is
8
+ * not `<org>/<name>` (never repeated beyond 200 characters, made printable). `usage`: the command's, said after a wrong
9
+ * `--replica`. */
10
+ export function replicaOf(flag, usage, env = process.env) {
11
+ const from = flag !== undefined ? "--replica" : env[REPLICA_ENV] !== undefined ? REPLICA_ENV : null;
12
+ if (from === null)
13
+ return null;
14
+ return replicaNamed((from === "--replica" ? flag : env[REPLICA_ENV]) ?? "", from, usage);
15
+ }
16
+ /** `raw` as a replica's name, `<org>/<name>`, each part `REPLICA_PART`: the one grammar of `--replica`, `MASON_REPLICA`
17
+ * and a command's own argument (`mason replica status <org>/<name>`); a Failure otherwise, a wrong line's (`usage`)
18
+ * unless the environment named it. */
19
+ export function replicaNamed(raw, from, usage) {
20
+ const parts = raw.split("/");
21
+ if (parts.length === 2 && REPLICA_PART.test(parts[0]) && REPLICA_PART.test(parts[1]))
22
+ return { org: parts[0], name: parts[1], from };
23
+ const named = from === "argument" ? "the replica" : from;
24
+ return { message: `${named} ${JSON.stringify(serviceText(raw, 200))} is not <org>/<name>`, code: from === REPLICA_ENV ? "refused" : "usage",
25
+ why: "an organisation's slug and a replica's name, each 1 to 63 lower case letters, digits and -, starting with a letter or digit",
26
+ fix: from === "--replica" ? "name it as the service lists it: --replica acme/shop" : from === "argument" ? "name it as the service lists it: acme/shop (mason replica list)"
27
+ : `set ${REPLICA_ENV}=<org>/<name> (acme/shop), or unset it and pass --replica`,
28
+ ...(from === REPLICA_ENV ? {} : { usage }) };
29
+ }
30
+ /** `raw` as an organisation's slug (`REPLICA_PART`: safe in a path as it is), or a wrong line's Failure. */
31
+ export function orgNamed(raw, usage) {
32
+ if (REPLICA_PART.test(raw))
33
+ return raw;
34
+ return { message: `the organisation ${JSON.stringify(serviceText(raw, 200))} is not an organisation's slug`, code: "usage",
35
+ why: "an organisation's slug is 1 to 63 lower case letters, digits and -, starting with a letter or digit", fix: "name it as the service lists it: acme", usage };
36
+ }
37
+ /** How many organisations a command reads when none is named (`loginOrgs`): past it, it asks for one. */
38
+ export const MAX_ORGS = 20;
39
+ /**
40
+ * THE ORGANISATIONS A COMMAND ACTS IN WHEN NONE IS NAMED: the login's own, as `GET /v1/me` says them (src/cli/hosted.ts
41
+ * `whoAmI`, its answer held there), those where its role is one of `roles` (every role when absent), each slug held to
42
+ * `REPLICA_PART` before it is in a path. A Failure when the service cannot say (a CI token is no person: 403), when a
43
+ * slug is outside the grammar, or past `MAX_ORGS`; `again` is the command that names one.
44
+ */
45
+ export async function loginOrgs(service, token, again, roles) {
46
+ const me = await whoAmI(service, token);
47
+ const name = [again, "name the organisation"];
48
+ if (isFailure(me))
49
+ return { ...me, next: [name, ...(me.next ?? [])] };
50
+ if (!me.orgs.every((o) => REPLICA_PART.test(o.slug)))
51
+ return { message: `${service.base} named an organisation whose slug is not one`, code: "failed",
52
+ why: "a slug is 1 to 63 lower case letters, digits and -: the answer is not the one docs/HOSTED-API.md documents", fix: `name the organisation: ${again}`, next: [name] };
53
+ const orgs = me.orgs.filter((o) => roles === undefined || roles.includes(o.role)).map((o) => ({ slug: o.slug, role: o.role }));
54
+ if (orgs.length > MAX_ORGS)
55
+ return { message: `this login is in ${orgs.length} organisations, more than the ${MAX_ORGS} a command reads when none is named`, code: "refused",
56
+ fix: `name the organisation: ${again}`, next: [name] };
57
+ return orgs;
58
+ }
59
+ /**
60
+ * The token a request to a replica is sent with: MASON_TOKEN, else this machine's login to the service; a Failure naming
61
+ * mason login. `asked`: what the command sends, in its words ("a deploy is sent"); `again`: the command to run once
62
+ * signed in ("mason deploy").
63
+ */
64
+ export function tokenFor(service, o) {
65
+ const env = environmentToken();
66
+ if (env !== null)
67
+ return env;
68
+ const read = readLogins();
69
+ if (isFailure(read))
70
+ return read;
71
+ const saved = read.logins.logins[service.base];
72
+ if (saved !== undefined)
73
+ return saved.token;
74
+ return { message: `this machine is not signed in to ${service.base}`, code: "refused", why: `${o.asked} as a person signed in (mason login), or with a token in ${TOKEN_ENV} (CI)`,
75
+ fix: `run mason login, then ${o.again} again; in CI, set ${TOKEN_ENV} to a CI token`, next: [["mason login", `sign this machine in to ${service.base}`]] };
76
+ }
@@ -64,6 +64,8 @@ export function suggestedWalCapGb(bytesPerSecond) {
64
64
  return null;
65
65
  return Math.max(4, Math.ceil((bytesPerSecond * 86_400) / 1024 ** 3));
66
66
  }
67
+ /** What Mason does about a lost slot (src/duck/sync.ts `prepareReplication`), one wording for every check that says it. */
68
+ const SLOT_REMADE = "Mason makes a new slot and catches each table up with one poll (a scan of each table on your database), never a full sync";
67
69
  /** What wal_level = logical takes on this server: its provider's way, or postgresql.conf's. */
68
70
  function walLevelFix(f) {
69
71
  if (f.rds)
@@ -77,12 +79,12 @@ export function replicationChecks(f, slot, slotState) {
77
79
  const out = [];
78
80
  out.push(f.walLevel === "logical"
79
81
  ? { name: "wal_level", ok: true, detail: "logical" }
80
- : { name: "wal_level", ok: false, detail: `${f.walLevel}: logical replication needs logical. To fix: ${walLevelFix(f)}. Or read changes by polling instead (--poll: SELECT alone, for a small database)` });
82
+ : { name: "wal_level", ok: false, detail: `${f.walLevel}: logical replication needs logical. To fix: ${walLevelFix(f)}. Or poll the table instead: give its entry sync: poll <interval> in the sources file (SELECTs alone, no change log)` });
81
83
  if (f.standby) {
82
84
  out.push(f.version >= 160000
83
85
  ? { name: "standby", ok: true, warn: !f.hotStandbyFeedback,
84
- detail: `a read replica (Postgres 16+ decodes on a standby)${f.hotStandbyFeedback ? "" : ". hot_standby_feedback is off: the primary may remove catalog rows the slot still needs, which invalidates it (Mason then rebuilds from scratch) — turn it on"}` }
85
- : { name: "standby", ok: false, detail: `a read replica on Postgres ${f.versionText}: logical decoding on a standby needs Postgres 16. Point Mason at the primary, upgrade the replica, or poll it (--poll)` });
86
+ detail: `a read replica (Postgres 16+ decodes on a standby)${f.hotStandbyFeedback ? "" : `. hot_standby_feedback is off: the primary may remove catalog rows the slot still needs, which invalidates it (${SLOT_REMADE}) — turn it on`}` }
87
+ : { name: "standby", ok: false, detail: `a read replica on Postgres ${f.versionText}: logical decoding on a standby needs Postgres 16. Point Mason at the primary, upgrade the replica, or poll the table: give its entry sync: poll <interval> in the sources file` });
86
88
  }
87
89
  const canReplicate = f.superuser || f.replication || f.rds?.member === true;
88
90
  out.push(canReplicate
@@ -92,7 +94,7 @@ export function replicationChecks(f, slot, slotState) {
92
94
  const free = f.maxSlots - f.slotsUsed;
93
95
  if (slotState) {
94
96
  out.push(slotState.walStatus === "lost"
95
- ? { name: "slot", ok: true, warn: true, detail: `${slot} exists and Postgres invalidated it (wal_status lost): Mason drops it and rebuilds from scratch at its start` }
97
+ ? { name: "slot", ok: true, warn: true, detail: `${slot} exists and Postgres invalidated it (wal_status lost): ${SLOT_REMADE}` }
96
98
  : slotState.active
97
99
  ? { name: "slot", ok: false, detail: `${slot} is in use by another reader${slotState.activePid !== null ? ` (pid ${slotState.activePid} on the database)` : ""}: one slot has one reader. ` +
98
100
  `Stop the other Mason for this project (mason stop). A run that crashed holds it until its connection ends${slotState.activePid !== null ? `: SELECT pg_terminate_backend(${slotState.activePid}) ends it now` : ""}` }
@@ -110,7 +112,7 @@ export function replicationChecks(f, slot, slotState) {
110
112
  const size = gb === null ? "a day of this database's WAL" : `${gb}GB`;
111
113
  const basis = gb === null ? "" : ` (a day of the WAL this server writes: ${bytesText(f.walBytesPerSecond)}/s since its statistics were reset)`;
112
114
  out.push(f.slotWalKeepMb >= 0
113
- ? { name: "WAL cap", ok: true, detail: `max_slot_wal_keep_size = ${f.slotWalKeepMb >= 1024 ? `${(f.slotWalKeepMb / 1024).toFixed(f.slotWalKeepMb % 1024 ? 1 : 0)}GB` : `${f.slotWalKeepMb}MB`}: past it Postgres drops the slot rather than fill its disk, and Mason rebuilds from scratch${gb !== null && f.slotWalKeepMb < gb * 1024 ? `. A day of this server's WAL is about ${gb}GB${basis}: an outage longer than the cap's worth means a rebuild` : ""}` }
115
+ ? { name: "WAL cap", ok: true, detail: `max_slot_wal_keep_size = ${f.slotWalKeepMb >= 1024 ? `${(f.slotWalKeepMb / 1024).toFixed(f.slotWalKeepMb % 1024 ? 1 : 0)}GB` : `${f.slotWalKeepMb}MB`}: past it Postgres invalidates the slot rather than fill its disk, and ${SLOT_REMADE}${gb !== null && f.slotWalKeepMb < gb * 1024 ? `. A day of this server's WAL is about ${gb}GB${basis}: an outage longer than the cap's worth means that catch-up` : ""}` }
114
116
  : { name: "WAL cap", ok: false,
115
117
  detail: `max_slot_wal_keep_size is unlimited: while Mason is stopped its slot holds every byte of WAL, until the disk fills and Postgres stops taking writes. Cap it at the outage you want Mason to survive, e.g. ${size}${basis}` +
116
118
  (f.rds || f.cloudSql ? ` — in the ${f.rds ? "DB parameter group" : "database flags"} (max_slot_wal_keep_size, in MB)` : ""),
@@ -1,15 +1,17 @@
1
1
  /**
2
- * `mason start --replication` (the journey to production, item 6): a project's run reading its database's
3
- * change log as production does — a publication, a replication slot, `wal_level = logical` — instead of polling it with
4
- * SELECTs (src/cdc/poll.ts). A developer then meets the setup production needs on their own database first. The mode is
5
- * the project's (`.mason/config.json` `changes`), kept between starts until `--poll` takes it back.
2
+ * LOGICAL REPLICATION FOR THE TABLES WHOSE `sync:` SAYS SO (the journey to production, item 6; issue 33): a project's run
3
+ * reading its database's change log as production does — a publication, a replication slot, `wal_level = logical` —
4
+ * for each table whose sync is `replication`, beside the polled ones (src/cdc/poll.ts). A developer then meets the setup
5
+ * production needs on their own database first. The sources file decides it, per table, and nothing else does
6
+ * (`mason start --replication`, `--poll`, `--publication` and `MASON_CHANGES` are gone: src/lib/table-sync.ts
7
+ * `removedStartFlags`, `changesRetired`).
6
8
  *
7
9
  * What it costs the developer's database, said where it happens: Mason creates its slot there (`mason_<deployment>`), and
8
- * a slot holds WAL while nobody reads it — so `mason stop` says so, and `mason destroy`, or going back to polling, drops
10
+ * a slot holds WAL while nobody reads it — so `mason stop` says so, and `mason destroy`, or no table replicated any more, drops
9
11
  * it (`dropOwnReplication`), verified gone, by the name the start that made it RECORDED (`madeReplication`), never one
10
12
  * computed again. The PUBLICATION is Mason's own (`mason_<deployment>`, made and dropped with
11
- * the slot) unless the operator names one (`--publication <name>`, else `PG_PUBLICATION`, else the one
12
- * an earlier start recorded): that one is the database owner's, read as it is and never created, altered or dropped
13
+ * the slot) unless the operator names one (`publication:` at the top of the sources file, else `PG_PUBLICATION`):
14
+ * that one is the database owner's, read as it is and never created, altered or dropped
13
15
  * (src/duck/publication.ts) — on a read replica it is the only way, since CREATE PUBLICATION is refused on a standby.
14
16
  *
15
17
  * In the ClickHouse service this was cut from, the two modes kept row versions that could not continue each other (a
@@ -20,6 +22,8 @@
20
22
  import { createHash } from "node:crypto";
21
23
  import { replicationNames } from "../cdc/source.js";
22
24
  import { connectSource, systemIdentifier } from "./preflight.js";
25
+ import { STATE_DIR } from "./project.js";
26
+ import { replicatedEntries, silentOverSlot, syncWritten } from "./sources.js";
23
27
  import { readServerFacts, readSlotFacts, replicationChecks } from "./replication-checks.js";
24
28
  /** Postgres's longest identifier (NAMEDATALEN - 1): a longer slot name is refused, a longer publication's cut with a notice. */
25
29
  export const PG_NAME_BYTES = 63;
@@ -38,8 +42,8 @@ export function deploymentOf(project, source) {
38
42
  return `${full.slice(0, room - 9)}_${createHash("sha256").update(full).digest("hex").slice(0, 8)}`;
39
43
  }
40
44
  /**
41
- * The publication and slot a project's run reads: the publication `named` (the flag, `PG_PUBLICATION` or the recorded
42
- * one: `namedPublication`), else Mason's own — by the change reader's own rule (src/cdc/source.ts `replicationNames`),
45
+ * The publication and slot a project's run reads: the publication `named` (the sources file's or `PG_PUBLICATION`:
46
+ * `namedPublication`, or the recorded one), else Mason's own — by the change reader's own rule (src/cdc/source.ts `replicationNames`),
43
47
  * which also says whether it is Mason's to make. The slot is always the project's own.
44
48
  */
45
49
  export function projectReplication(name, source, named = null) {
@@ -48,27 +52,69 @@ export function projectReplication(name, source, named = null) {
48
52
  }
49
53
  /**
50
54
  * WHAT REPLICATION MODE MADE ON THE DATABASE, as the start that made it recorded it (`config.json` `replication`): what
51
- * `--poll` and `mason destroy` drop. Recorded, never computed again, so a name that changes since — the sources file
55
+ * a start whose sources file replicates no table, and `mason destroy`, drop. Recorded, never computed again, so a name that changes since — the sources file
52
56
  * renamed, the naming rule — never leaves a slot that nobody drops, holding the database's WAL for ever. A project with
53
57
  * no record (none started in replication mode since it was kept) has the names the rule gives now.
54
58
  */
55
59
  export function madeReplication(cfg, source) {
56
60
  return cfg.replication ?? projectReplication(cfg.name, source, cfg.publication ?? null);
57
61
  }
58
- /** The publication an operator named, and where: `--publication`, else `PG_PUBLICATION`, else the one an earlier start
59
- * of this copy recorded (`config.json` `publication`), else none (Mason's own). */
60
- export function namedPublication(flag, env, recorded) {
61
- if (flag)
62
- return { name: flag, from: "flag" };
62
+ /**
63
+ * THE PUBLICATION A START READS, and where it is named: the sources file's `publication:` (its `${PG_PUBLICATION}`
64
+ * resolved), else `PG_PUBLICATION` in the environment, else none — Mason's own, which `publication: null` asks for past
65
+ * the environment too. NEVER the one an earlier start recorded, read silently: a record the file does not take up is
66
+ * refused (`recordRefusal`), so what runs is what is committed.
67
+ */
68
+ export function namedPublication(file, env) {
69
+ if (file.publicationName !== null)
70
+ return { name: file.publicationName, from: "file" };
71
+ if (file.publication && "own" in file.publication)
72
+ return null;
63
73
  if (env.PG_PUBLICATION)
64
74
  return { name: env.PG_PUBLICATION, from: "env" };
65
- if (recorded)
66
- return { name: recorded, from: "recorded" };
67
75
  return null;
68
76
  }
69
- /** The mode a start runs: the flag, else the project's, else polling. */
70
- export function changesOf(flag, recorded) {
71
- return flag ?? recorded ?? "poll";
77
+ /**
78
+ * WHAT `.mason/config.json` RECORDS AND THE SOURCES FILE DOES NOT SAY, refused before anything is written or connected
79
+ * (one report, for the start that is typed, the run it spawns and `mason serve`). The file says how a project replicates,
80
+ * and a file that is silent is an omission, not a decision, whose price is on someone's production database:
81
+ * - a project that streams (a slot recorded, of any release, or an older Mason's `changes: "replication"`) under a file
82
+ * naming tables with no `sync:` at its top or on any entry would have its slot dropped, and streaming again copies
83
+ * every table whole. Any `sync:` written is the file saying so: `off` or `poll <interval>` confirms the drop,
84
+ * `replication` keeps the slot. The run's re-read holds the same line (`silentOverSlot`, `rereadSources`).
85
+ * - a project that replicates through a publication it recorded, with no `publication:` and no `PG_PUBLICATION`,
86
+ * would silently make and read a publication of Mason's own. The literal `publication: null` asks for that on purpose.
87
+ * - `publication: null` beside a set `PG_PUBLICATION` says two things, and one of them is a CREATE on the source.
88
+ * `file` null: there is no sources file (a serve would write one saying `sync: off`, which must not read as a choice). A
89
+ * project is told the first two at once: the first edit must not meet the second refusal.
90
+ */
91
+ export function recordRefusal(cfg, file, env, shown, cmd) {
92
+ const recorded = cfg.publication ?? null;
93
+ const declined = file?.publication != null && "own" in file.publication;
94
+ const unnamed = recorded !== null && !declined && namedPublication(file ?? { publicationName: null }, env) === null;
95
+ const next = [[cmd, "once the file says it; nothing was dropped or changed"]];
96
+ if (declined && env.PG_PUBLICATION) {
97
+ return { message: `${shown} says publication: null, and PG_PUBLICATION names ${env.PG_PUBLICATION}`, code: "refused",
98
+ why: "one asks Mason to make a publication of its own on your database, the other to read that one, and the file would win by saying nothing of the setting",
99
+ fix: `for Mason's own, unset PG_PUBLICATION; to read ${env.PG_PUBLICATION}, take publication: null out of ${shown}`, next };
100
+ }
101
+ const slotRecorded = (cfg.replication ?? null) !== null;
102
+ const older = cfg.changes === "replication";
103
+ if (file === null ? slotRecorded || older : older ? !syncWritten(file) : silentOverSlot(slotRecorded, file)) {
104
+ const slot = cfg.replication?.slot;
105
+ const made = `${slot === undefined ? "its replication slot" : `replication slot ${slot}`}${(cfg.replication?.owned ?? recorded === null) ? " and Mason's publication" : ""}`;
106
+ return { message: `this project streams, and ${file === null ? "it has no sources file to say so" : `${shown} does not say so: no sync: at its top or on an entry`}`, code: "refused",
107
+ why: `${older ? `an older Mason recorded that this project streams every table (changes: replication in ${STATE_DIR}/config.json)` : `${STATE_DIR}/config.json records that this project streams`}; left unsaid, sync is off, which drops ${made}, and streaming again reads every table whole from your database`,
108
+ fix: file === null
109
+ ? `mount the project folder with its sources file (MASON_PROJECT), or make ${shown} there with kind: postgres, url: \${DATABASE_URL}, tables: and the tables it streams, and sync: replication${unnamed ? ` and publication: ${recorded}` : ""} to keep streaming; sync: off or sync: poll <interval> in it instead confirms dropping ${made}`
110
+ : `to keep streaming, write sync: replication${unnamed ? ` and publication: ${recorded}` : ""} at the top of ${shown}; to drop ${made}, confirm it with sync: off or sync: poll <interval> there`, next };
111
+ }
112
+ if (unnamed && file !== null && replicatedEntries(file).length > 0) {
113
+ return { message: `this project reads publication ${recorded}, and ${shown} does not name it`, code: "refused",
114
+ why: `${STATE_DIR}/config.json records ${recorded}, its owner's, named at an earlier start; with no publication: line and no PG_PUBLICATION, Mason would make and read a publication of its own on your database instead`,
115
+ fix: `to keep reading it, write publication: ${recorded} at the top of ${shown}; for a publication of Mason's own, write publication: null there`, next };
116
+ }
117
+ return null;
72
118
  }
73
119
  /**
74
120
  * Whether the developer's database can be read by replication: what refuses it (wal_level, the role — REPLICATION, a
@@ -81,7 +127,7 @@ export function readinessVerdict(facts, slot, slotFacts, repl) {
81
127
  // a standby takes no CREATE PUBLICATION (it is read-only): the publication must be the primary's, named
82
128
  if (facts.standby && repl.owned) {
83
129
  checks.push({ name: "publication", ok: false, detail: `this database is a read replica, where Mason cannot make its publication ${repl.publication} (CREATE ` +
84
- "PUBLICATION is refused on a standby). Create one on the primary for the tables you add, then name it: mason start --replication --publication <name>" });
130
+ "PUBLICATION is refused on a standby). Create one on the primary for the tables whose sync: is replication, then name it: publication: <name> at the top of the sources file" });
85
131
  }
86
132
  // a laptop's database has no cap on its slots by default: worth saying, never a reason to refuse a dev run
87
133
  return { refused: checks.filter((x) => x.ok === false && x.name !== "WAL cap"), warnings: checks.filter((x) => (x.name === "WAL cap" && x.ok === false) || x.warn === true) };
@@ -97,18 +143,51 @@ export async function replicationReadiness(source, repl) {
97
143
  c.pg.close();
98
144
  }
99
145
  }
146
+ /**
147
+ * A TABLE SET TO `sync: replication` WHILE THE RUN IS UP, in a run that replicated nothing when it started (so the
148
+ * database was never checked for it): the start's own checks, as the edit's refusal — what the database lacks, and the
149
+ * line that keeps the table polled — or null when it may replicate. `tables` are the entries' names (`orders`).
150
+ */
151
+ export function liveReadinessFailure(ready, tables, shown, database) {
152
+ const refused = "failure" in ready ? [{ name: "database", ok: false, detail: ready.failure }] : ready.refused;
153
+ if (!refused.length)
154
+ return null;
155
+ const one = tables.length === 1;
156
+ return { message: `${tables.join(", ")} cannot be kept current by logical replication from ${database}: ${refused.map((c) => c.name).join(", ")}`, code: "refused",
157
+ why: refused.map((c) => `${c.name}: ${c.detail}${c.sql ? ` (${c.sql})` : ""}`).join("\n"),
158
+ fix: `poll ${one ? "it" : "them"} instead: mason add ${tables[0]} --sync "poll 5m"${one ? "" : " (and each of the others)"}, or sync: poll 5m on ${one ? "its entry" : "their entries"} in ${shown}, ` +
159
+ "which needs none of this — or fix the database as said, then mason stop && mason start",
160
+ next: [[`mason add ${tables[0]} --sync "poll 5m"`, "polls it with SELECTs instead"]] };
161
+ }
100
162
  /** The statements that remove what replication mode made, for a person to run when Mason could not: the slot, and the
101
163
  * publication only when it is Mason's own (a named one is its owner's). */
102
164
  export function dropStatements(o) {
103
165
  return `SELECT pg_drop_replication_slot('${o.slot}') FROM pg_replication_slots WHERE slot_name = '${o.slot}' AND NOT active;` +
104
166
  (o.owned ? ` DROP PUBLICATION IF EXISTS ${o.publication};` : "");
105
167
  }
168
+ /**
169
+ * WHAT THE RECORD NAMES THAT THIS START NO LONGER READS, to drop before the record names the new one (each holds what
170
+ * Mason made on a database nothing would drop otherwise): a slot under another name (the sources file renamed) goes
171
+ * with Mason's own publication; Mason's own publication alone when the project now reads another (one named), on the
172
+ * same slot — a review finding, the own one was left on the database after the switch and after mason destroy.
173
+ */
174
+ export function staleReplication(was, now) {
175
+ if (was === null)
176
+ return null;
177
+ if (was.slot !== now.slot)
178
+ return { drop: was, what: "both", why: `this source's slot is ${now.slot} now` };
179
+ if (was.owned && (was.publication !== now.publication || !now.owned)) {
180
+ return { drop: was, what: "publication", why: `the replicated tables are read through publication ${now.publication} now${now.owned ? "" : ", its owner's"}` };
181
+ }
182
+ return null;
183
+ }
106
184
  /**
107
185
  * Drop what replication mode made on the developer's database — the slot, and the publication when it is Mason's own,
108
186
  * never one the operator named — and verify it is gone: what was dropped, in words. A slot a reader still holds is never
109
- * dropped (the run must be stopped first): that is a failure.
187
+ * dropped (the run must be stopped first): that is a failure. `what: "publication"` drops Mason's own publication alone
188
+ * (the slot is still read).
110
189
  */
111
- export async function dropOwnReplication(source, o, from = null) {
190
+ export async function dropOwnReplication(source, o, from = null, what = "both") {
112
191
  const c = await connectSource(source);
113
192
  if ("failure" in c)
114
193
  throw new Error(`could not connect to drop them: ${c.failure.message}`);
@@ -119,11 +198,11 @@ export async function dropOwnReplication(source, o, from = null) {
119
198
  if (from?.systemIdentifier) {
120
199
  const now = await systemIdentifier(pg);
121
200
  if (now !== null && now !== from.systemIdentifier) {
122
- throw new Error(`${source.database} on ${source.host} is another server than the copy came from (system identifier ${now}, the copy's ${from.systemIdentifier}), where the slot is: put that server's URL back in the sources file and run mason destroy again, or drop them there by hand`);
201
+ throw new Error(`${source.database} on ${source.host} is another server than the synced data came from (system identifier ${now}, the synced data's ${from.systemIdentifier}), where the slot is: put that server's URL back in the sources file and run mason destroy again, or drop them there by hand`);
123
202
  }
124
203
  }
125
204
  const dropped = [];
126
- const slot = await readSlotFacts(pg, o.slot);
205
+ const slot = what === "both" ? await readSlotFacts(pg, o.slot) : null;
127
206
  if (slot) {
128
207
  if (slot.active)
129
208
  throw new Error(`replication slot ${o.slot} is in use by a reader: stop it first`);
@@ -136,7 +215,7 @@ export async function dropOwnReplication(source, o, from = null) {
136
215
  dropped.push(`publication ${o.publication}`);
137
216
  }
138
217
  // verified, never assumed: a slot left behind holds the database's WAL for ever
139
- if (await readSlotFacts(pg, o.slot))
218
+ if (what === "both" && await readSlotFacts(pg, o.slot))
140
219
  throw new Error(`replication slot ${o.slot} is still there after pg_drop_replication_slot`);
141
220
  if (o.owned && await hasPub())
142
221
  throw new Error(`publication ${o.publication} is still there after DROP PUBLICATION`);
@@ -0,0 +1,134 @@
1
+ import { OUTCOMES } from "../api/request-log.js";
2
+ import { parseRequestCursor, requestCursor } from "../duck/requests.js";
3
+ import { didYouMean } from "../lib/did-you-mean.js";
4
+ /** How many requests a read returns unless it says. */
5
+ export const DEFAULT_REQUEST_LIMIT = 40;
6
+ export const MAX_REQUEST_LIMIT = 10_000;
7
+ /** A statement answered in this many milliseconds or more is slow: what mason status lists, and what the `slow` filter
8
+ * keeps (`--slow`, `slow=1`). One constant, here, where the filter is read; src/cli/run-server.ts re-exports it. */
9
+ export const SLOW_MS = 1000;
10
+ /** The longest text `search` takes: a fragment of a statement, never a statement pasted whole. */
11
+ export const MAX_SEARCH = 200;
12
+ /** How many sync events a read returns unless it says, and the most it may. */
13
+ export const DEFAULT_EVENT_LIMIT = 40;
14
+ export const MAX_EVENT_LIMIT = MAX_REQUEST_LIMIT;
15
+ const AGE = /^(\d+)(s|m|h|d)$/;
16
+ const AGE_MS = { s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000 };
17
+ /** a date, or a date and time with its zone (a time without one would be read in this machine's zone: refused) */
18
+ const ISO = /^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}(:\d{2}(\.\d{1,6})?)?(Z|[+-]\d{2}:?\d{2}))?$/;
19
+ const usage = (message, fix) => ({ message, code: "usage", ...(fix !== undefined ? { fix } : {}) });
20
+ export const QUERY_NAMES = (k) => k;
21
+ /**
22
+ * THE TIME GRAMMAR, ONE FOR EVERY ENTRANCE (`--since` / `--until` of mason logs and mason usage, and the run's `since` and
23
+ * `until` of /api/requests, /api/events, /api/usage and /api/logs): an ISO 8601 date or time with its zone, or an age
24
+ * before `now` (30s, 30m, 2h, 7d), as an ISO time; a time with no zone is refused, as is anything else.
25
+ */
26
+ export function timeOf(name, text, now) {
27
+ const age = AGE.exec(text.trim());
28
+ if (age !== null)
29
+ return new Date(now - Number(age[1]) * AGE_MS[age[2]]).toISOString();
30
+ const t = ISO.test(text.trim()) ? Date.parse(text.trim()) : NaN;
31
+ if (Number.isNaN(t)) {
32
+ return usage(`${name} ${JSON.stringify(text)} is not a time`, "an age (30m, 2h, 7d) or an ISO 8601 date or time with its zone (2026-10-28, 2026-10-28T09:00:00Z)");
33
+ }
34
+ return new Date(t).toISOString();
35
+ }
36
+ /** `-n` / `limit` of the sync's events: a whole number, 1 to the cap, said in the entrance's own spelling of the key. */
37
+ export function eventLimit(text, key) {
38
+ const limit = text === undefined ? DEFAULT_EVENT_LIMIT : Number(text);
39
+ if (!Number.isInteger(limit) || limit < 1 || limit > MAX_EVENT_LIMIT)
40
+ return usage(`${key} is a whole number of events, 1 to ${MAX_EVENT_LIMIT}`);
41
+ return limit;
42
+ }
43
+ /**
44
+ * The filters read, or the first thing wrong with them, its key spelled as the entrance spells it (`name`). `failed`
45
+ * is every outcome but ok, so it is never named beside `outcome`; `slow` keeps the ones that took SLOW_MS or more, and
46
+ * `search` the statements whose text has it (any case, at most MAX_SEARCH characters); `since` must come before `until`; `before` is a
47
+ * cursor a page handed on, passed back as it came.
48
+ */
49
+ export function requestFilter(f, now = Date.now(), name = QUERY_NAMES) {
50
+ const limit = f.limit === undefined ? DEFAULT_REQUEST_LIMIT : Number(f.limit);
51
+ if (!Number.isInteger(limit) || limit < 1 || limit > MAX_REQUEST_LIMIT)
52
+ return usage(`${name("limit")} is a whole number of requests, 1 to ${MAX_REQUEST_LIMIT}`);
53
+ const q = { limit };
54
+ if (f.outcome !== undefined) {
55
+ if (f.failed === true) {
56
+ return usage(`${name("failed")} is every outcome but ok: name it or ${name("outcome")}, never both`, `${name("outcome")} refused,failed,cancelled is ${name("failed")}`);
57
+ }
58
+ const names = f.outcome.split(",").map((o) => o.trim()).filter((o) => o !== "");
59
+ if (!names.length)
60
+ return usage(`${name("outcome")} names one or more of ${OUTCOMES.join(", ")}`);
61
+ for (const o of names) {
62
+ if (!OUTCOMES.includes(o))
63
+ return usage(`${name("outcome")} ${JSON.stringify(o)} is none of ${OUTCOMES.join(", ")}${didYouMean(o, OUTCOMES)}`);
64
+ }
65
+ q.outcomes = [...new Set(names)];
66
+ }
67
+ if (f.failed === true)
68
+ q.failed = true;
69
+ for (const k of ["since", "until"]) {
70
+ const text = f[k];
71
+ if (text === undefined)
72
+ continue;
73
+ const t = timeOf(name(k), text, now);
74
+ if (typeof t !== "string")
75
+ return t;
76
+ q[k] = t;
77
+ }
78
+ if (q.since !== undefined && q.until !== undefined && q.since >= q.until) {
79
+ return usage(`${name("since")} (${q.since}) is not before ${name("until")} (${q.until}): no request started in between`);
80
+ }
81
+ for (const k of ["user", "client"]) {
82
+ const text = f[k];
83
+ if (text === undefined)
84
+ continue;
85
+ if (text === "")
86
+ return usage(`${name(k)} names ${k === "user" ? "a mason user" : "a client's application_name"}: it is empty`);
87
+ q[k] = text;
88
+ }
89
+ if (f.slow === true)
90
+ q.minMs = SLOW_MS;
91
+ if (f.search !== undefined) {
92
+ if (f.search.trim() === "")
93
+ return usage(`${name("search")} is a piece of a statement's text: it is empty`);
94
+ if (f.search.length > MAX_SEARCH)
95
+ return usage(`${name("search")} is a piece of a statement's text, at most ${MAX_SEARCH} characters: it has ${f.search.length}`);
96
+ q.search = f.search;
97
+ }
98
+ if (f.before !== undefined) {
99
+ const c = parseRequestCursor(f.before);
100
+ if (c === null)
101
+ return usage(`${name("before")} ${JSON.stringify(f.before)} is not a position in the request log`, `pass back the before a page answered with, as it came`);
102
+ q.before = c;
103
+ }
104
+ return q;
105
+ }
106
+ /** The query string the run reads a RequestQuery from (requestFilter again, on its side): its times as resolved. */
107
+ export function requestFilterParams(q) {
108
+ const p = new URLSearchParams({ limit: String(q.limit) });
109
+ if (q.failed === true)
110
+ p.set("failed", "1");
111
+ if (q.outcomes !== undefined)
112
+ p.set("outcome", q.outcomes.join(","));
113
+ if (q.since !== undefined)
114
+ p.set("since", q.since);
115
+ if (q.until !== undefined)
116
+ p.set("until", q.until);
117
+ if (q.user !== undefined)
118
+ p.set("user", q.user);
119
+ if (q.client !== undefined)
120
+ p.set("client", q.client);
121
+ if (q.minMs !== undefined)
122
+ p.set("slow", "1");
123
+ if (q.search !== undefined)
124
+ p.set("search", q.search);
125
+ if (q.before !== undefined)
126
+ p.set("before", requestCursor(q.before.at, q.before.seq));
127
+ return p;
128
+ }
129
+ /** A query string's filters as text (`GET /api/requests`): what requestFilter reads. */
130
+ export function requestFilterText(p) {
131
+ const get = (k) => p.get(k) ?? undefined;
132
+ return { limit: get("limit"), outcome: get("outcome"), failed: p.get("failed") === "1", since: get("since"), until: get("until"),
133
+ user: get("user"), client: get("client"), slow: p.get("slow") === "1", search: get("search"), before: get("before") };
134
+ }