@caelo-cms/shared 0.11.5 → 0.11.7

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 (72) hide show
  1. package/dist/ai-tools.d.ts +206 -2
  2. package/dist/ai-tools.d.ts.map +1 -1
  3. package/dist/ai-tools.js +23 -9
  4. package/dist/ai-tools.js.map +1 -1
  5. package/dist/change-journal.d.ts +71 -0
  6. package/dist/change-journal.d.ts.map +1 -0
  7. package/dist/change-journal.js +252 -0
  8. package/dist/change-journal.js.map +1 -0
  9. package/dist/content.d.ts +214 -0
  10. package/dist/content.d.ts.map +1 -1
  11. package/dist/content.js +29 -11
  12. package/dist/content.js.map +1 -1
  13. package/dist/index.d.ts +3 -0
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +3 -0
  16. package/dist/index.js.map +1 -1
  17. package/dist/lists/fields.d.ts +5 -0
  18. package/dist/lists/fields.d.ts.map +1 -1
  19. package/dist/lists/fields.js +6 -1
  20. package/dist/lists/fields.js.map +1 -1
  21. package/dist/lists/list-type.d.ts.map +1 -1
  22. package/dist/lists/list-type.js +6 -3
  23. package/dist/lists/list-type.js.map +1 -1
  24. package/dist/lists/page-type.d.ts +1 -0
  25. package/dist/lists/page-type.d.ts.map +1 -1
  26. package/dist/lists/page-type.js +7 -0
  27. package/dist/lists/page-type.js.map +1 -1
  28. package/dist/live-events.d.ts +8 -4
  29. package/dist/live-events.d.ts.map +1 -1
  30. package/dist/live-events.js +18 -3
  31. package/dist/live-events.js.map +1 -1
  32. package/dist/media.d.ts +19 -0
  33. package/dist/media.d.ts.map +1 -1
  34. package/dist/media.js +16 -0
  35. package/dist/media.js.map +1 -1
  36. package/dist/phase-timer.d.ts +43 -2
  37. package/dist/phase-timer.d.ts.map +1 -1
  38. package/dist/phase-timer.js +170 -4
  39. package/dist/phase-timer.js.map +1 -1
  40. package/dist/pool-budget.d.ts +152 -0
  41. package/dist/pool-budget.d.ts.map +1 -0
  42. package/dist/pool-budget.js +151 -0
  43. package/dist/pool-budget.js.map +1 -0
  44. package/dist/stage-status.d.ts +89 -0
  45. package/dist/stage-status.d.ts.map +1 -0
  46. package/dist/stage-status.js +101 -0
  47. package/dist/stage-status.js.map +1 -0
  48. package/dist/themes-errors.js +1 -1
  49. package/dist/themes-errors.js.map +1 -1
  50. package/dist/version.d.ts +2 -2
  51. package/dist/version.js +1 -1
  52. package/package.json +1 -1
  53. package/src/ai-tools.ts +23 -8
  54. package/src/change-journal.ts +325 -0
  55. package/src/content.ts +32 -11
  56. package/src/index.ts +3 -0
  57. package/src/lists/fields.ts +7 -1
  58. package/src/lists/list-fixtures.test.ts +1 -0
  59. package/src/lists/list-type.ts +6 -2
  60. package/src/lists/lists.test.ts +1 -0
  61. package/src/lists/page-type.ts +7 -0
  62. package/src/live-events.ts +18 -3
  63. package/src/media.test.ts +31 -0
  64. package/src/media.ts +17 -0
  65. package/src/phase-timer.test.ts +37 -0
  66. package/src/phase-timer.ts +182 -4
  67. package/src/pool-budget.test.ts +111 -0
  68. package/src/pool-budget.ts +234 -0
  69. package/src/stage-status.test.ts +75 -0
  70. package/src/stage-status.ts +112 -0
  71. package/src/themes-errors.ts +1 -1
  72. package/src/version.ts +1 -1
@@ -45,4 +45,41 @@ describe("PhaseTimer", () => {
45
45
  expect(t.phases()).toHaveLength(1);
46
46
  for (const p of t.phases()) expect(phaseTimingSchema.parse(p)).toEqual(p);
47
47
  });
48
+
49
+ it("lapParts closes a step as the phases measured inside it plus the rest (#1271)", async () => {
50
+ const t = new PhaseTimer(clock(0, 1, 4, 5, 9, 20));
51
+ const parts = new Map<string, { ms: number }>();
52
+ await t.measure(parts, "compose", () => undefined);
53
+ await t.measure(parts, "compose", async () => undefined);
54
+ t.lapParts(parts, "batches");
55
+ expect(t.phases()).toEqual([
56
+ { phase: "compose", ms: 7 },
57
+ { phase: "batches", ms: 13 },
58
+ ]);
59
+ });
60
+
61
+ it("a memory timer records each step's peak RSS and stops sampling when closed (#1271)", async () => {
62
+ const t = PhaseTimer.withMemory();
63
+ try {
64
+ const hold: Uint8Array[] = [];
65
+ const parts = new Map<string, { ms: number; peakRssMb?: number }>();
66
+ await t.measure(parts, "alloc", async () => {
67
+ for (let i = 0; i < 16; i += 1) hold.push(new Uint8Array(4 * 1024 * 1024).fill(i + 1));
68
+ await Bun.sleep(60);
69
+ });
70
+ expect(parts.get("alloc")?.peakRssMb ?? 0).toBeGreaterThan(0);
71
+ t.lap("x");
72
+ expect(t.phases()[0]?.peakRssMb ?? 0).toBeGreaterThan(0);
73
+ expect(hold.length).toBe(16);
74
+ for (const p of t.phases()) expect(phaseTimingSchema.parse(p)).toEqual(p);
75
+ } finally {
76
+ t.close();
77
+ }
78
+ });
79
+
80
+ it("include keeps a step's peak RSS", () => {
81
+ const t = new PhaseTimer(clock(0, 1));
82
+ t.include("generator", [{ phase: "compose", ms: 3, peakRssMb: 120 }]);
83
+ expect(t.phases()).toEqual([{ phase: "generator.compose", ms: 3, peakRssMb: 120 }]);
84
+ });
48
85
  });
@@ -9,6 +9,15 @@
9
9
  * Laps instead of wrapped callbacks: `lap("x")` closes the step that ran
10
10
  * since the previous lap, so a long function gets one line per boundary
11
11
  * and keeps its control flow (early throws, shared locals) as it is.
12
+ *
13
+ * #1271 — with `{ memory: true }` every step also carries
14
+ * - the peak resident memory of the process while it ran (`peakRssMb`):
15
+ * sampled every {@link RSS_SAMPLE_MS} by one shared timer that runs only
16
+ * while a memory timer is open ({@link PhaseTimer.close}), plus a reading
17
+ * at every lap;
18
+ * - the CPU time the process spent in it (`cpuMs`, user + system, every
19
+ * thread of the process — zlib and the other runtime workers included).
20
+ * `cpuMs / ms` is the number of cores the step kept busy.
12
21
  */
13
22
 
14
23
  import { z } from "zod";
@@ -17,29 +26,176 @@ import { z } from "zod";
17
26
  export const phaseTimingSchema = z.object({
18
27
  phase: z.string().min(1),
19
28
  ms: z.number().int().nonnegative(),
29
+ /** #1271 — the process's peak resident memory during the step, in MiB. */
30
+ peakRssMb: z.number().int().nonnegative().optional(),
31
+ /** #1271 — CPU time (user + system, all threads) the process spent in the step, in ms. */
32
+ cpuMs: z.number().int().nonnegative().optional(),
20
33
  });
21
34
  export type PhaseTiming = z.infer<typeof phaseTimingSchema>;
22
35
 
36
+ /** How often the open memory timers sample the resident set. */
37
+ export const RSS_SAMPLE_MS = 25;
38
+
39
+ const MIB = 1024 * 1024;
40
+
41
+ /** Process CPU time so far (user + system), in ms; 0 where there is no process. */
42
+ function cpuNow(): number {
43
+ if (typeof process === "undefined" || typeof process.cpuUsage !== "function") return 0;
44
+ const u = process.cpuUsage();
45
+ return (u.user + u.system) / 1000;
46
+ }
47
+
48
+ function rssNow(): number {
49
+ return typeof process !== "undefined" && typeof process.memoryUsage === "function"
50
+ ? process.memoryUsage().rss
51
+ : 0;
52
+ }
53
+
54
+ /**
55
+ * The one sampler of this process: its timer runs while at least one memory
56
+ * timer is open, and every open timer sees each sample.
57
+ */
58
+ const sampler = (() => {
59
+ const listeners = new Set<(rss: number) => void>();
60
+ let timer: ReturnType<typeof setInterval> | null = null;
61
+ return {
62
+ add(fn: (rss: number) => void): void {
63
+ listeners.add(fn);
64
+ if (timer === null) {
65
+ timer = setInterval(() => {
66
+ const rss = rssNow();
67
+ for (const l of listeners) l(rss);
68
+ }, RSS_SAMPLE_MS);
69
+ // Never what keeps the process alive.
70
+ (timer as { unref?: () => void }).unref?.();
71
+ }
72
+ },
73
+ remove(fn: (rss: number) => void): void {
74
+ listeners.delete(fn);
75
+ if (listeners.size === 0 && timer !== null) {
76
+ clearInterval(timer);
77
+ timer = null;
78
+ }
79
+ },
80
+ };
81
+ })();
82
+
83
+ /** What {@link PhaseTimer.measure} adds up for one repeated step. */
84
+ export interface PartTiming {
85
+ ms: number;
86
+ peakRssMb?: number;
87
+ cpuMs?: number;
88
+ }
89
+
23
90
  export class PhaseTimer {
24
91
  readonly #now: () => number;
25
92
  readonly #phases: PhaseTiming[] = [];
26
93
  readonly #start: number;
94
+ readonly #listener: ((rss: number) => void) | null;
27
95
  #last: number;
96
+ #peak = 0;
97
+ #lastCpu = 0;
28
98
 
29
- /** @param now clock in milliseconds; injectable for tests. */
30
- constructor(now: () => number = () => performance.now()) {
99
+ /**
100
+ * @param now clock in milliseconds; injectable for tests.
101
+ * @param opts.memory record each step's peak RSS (see the file header);
102
+ * the timer must then be {@link close}d.
103
+ */
104
+ constructor(now: () => number = () => performance.now(), opts: { memory?: boolean } = {}) {
31
105
  this.#now = now;
32
106
  this.#start = now();
33
107
  this.#last = this.#start;
108
+ if (opts.memory) {
109
+ this.#peak = rssNow();
110
+ this.#lastCpu = cpuNow();
111
+ this.#listener = (rss) => {
112
+ if (rss > this.#peak) this.#peak = rss;
113
+ };
114
+ sampler.add(this.#listener);
115
+ } else {
116
+ this.#listener = null;
117
+ }
118
+ }
119
+
120
+ /** A timer that records peak RSS per step (`new PhaseTimer(undefined, { memory: true })`). */
121
+ static withMemory(): PhaseTimer {
122
+ return new PhaseTimer(undefined, { memory: true });
34
123
  }
35
124
 
36
125
  /** Close the step that ran since the previous lap (or construction) as `phase`. */
37
126
  lap(phase: string): void {
38
127
  const t = this.#now();
39
- this.#phases.push({ phase, ms: Math.round(t - this.#last) });
128
+ this.#phases.push({
129
+ phase,
130
+ ms: Math.round(t - this.#last),
131
+ ...this.#takePeak(),
132
+ ...this.#takeCpu(0),
133
+ });
40
134
  this.#last = t;
41
135
  }
42
136
 
137
+ /**
138
+ * Close the step since the previous lap as several phases measured
139
+ * inside it (`parts`, ms and peak each — e.g. the passes of every page
140
+ * batch summed), plus `rest` for whatever of the step they do not cover.
141
+ */
142
+ lapParts(parts: ReadonlyMap<string, PartTiming>, rest: string): void {
143
+ const t = this.#now();
144
+ const elapsed = t - this.#last;
145
+ let covered = 0;
146
+ let coveredCpu = 0;
147
+ for (const [phase, p] of parts) {
148
+ covered += p.ms;
149
+ coveredCpu += p.cpuMs ?? 0;
150
+ this.#phases.push({
151
+ phase,
152
+ ms: Math.round(p.ms),
153
+ ...(p.peakRssMb !== undefined ? { peakRssMb: p.peakRssMb } : {}),
154
+ ...(p.cpuMs !== undefined ? { cpuMs: Math.round(p.cpuMs) } : {}),
155
+ });
156
+ }
157
+ this.#phases.push({
158
+ phase: rest,
159
+ ms: Math.max(0, Math.round(elapsed - covered)),
160
+ ...this.#takePeak(),
161
+ ...this.#takeCpu(coveredCpu),
162
+ });
163
+ this.#last = t;
164
+ }
165
+
166
+ /**
167
+ * Run `fn` and add its duration (and, for a memory timer, its peak RSS)
168
+ * to `into[phase]` — for steps that repeat per batch, summed by
169
+ * {@link lapParts}.
170
+ */
171
+ async measure<T>(
172
+ into: Map<string, PartTiming>,
173
+ phase: string,
174
+ fn: () => Promise<T> | T,
175
+ ): Promise<T> {
176
+ const peakBefore = this.#peak;
177
+ if (this.#listener) this.#peak = rssNow();
178
+ const t0 = this.#now();
179
+ const cpu0 = this.#listener ? cpuNow() : 0;
180
+ try {
181
+ return await fn();
182
+ } finally {
183
+ const prev = into.get(phase) ?? { ms: 0 };
184
+ const ms = prev.ms + (this.#now() - t0);
185
+ if (this.#listener) {
186
+ const peak = Math.max(this.#peak, rssNow());
187
+ into.set(phase, {
188
+ ms,
189
+ peakRssMb: Math.max(prev.peakRssMb ?? 0, Math.round(peak / MIB)),
190
+ cpuMs: (prev.cpuMs ?? 0) + (cpuNow() - cpu0),
191
+ });
192
+ this.#peak = Math.max(peakBefore, peak);
193
+ } else {
194
+ into.set(phase, { ms });
195
+ }
196
+ }
197
+ }
198
+
43
199
  /**
44
200
  * Append steps timed elsewhere (the generator subprocess, a publisher)
45
201
  * as `<prefix>.<phase>`. They ran inside the current lap, so the lap that
@@ -47,7 +203,7 @@ export class PhaseTimer {
47
203
  * around them, e.g. spawning the subprocess).
48
204
  */
49
205
  include(prefix: string, phases: readonly PhaseTiming[]): void {
50
- for (const p of phases) this.#phases.push({ phase: `${prefix}.${p.phase}`, ms: p.ms });
206
+ for (const p of phases) this.#phases.push({ ...p, phase: `${prefix}.${p.phase}` });
51
207
  }
52
208
 
53
209
  /** Milliseconds since construction. */
@@ -59,4 +215,26 @@ export class PhaseTimer {
59
215
  phases(): PhaseTiming[] {
60
216
  return [...this.#phases];
61
217
  }
218
+
219
+ /** Stop sampling memory (a memory timer must be closed; idempotent). */
220
+ close(): void {
221
+ if (this.#listener) sampler.remove(this.#listener);
222
+ }
223
+
224
+ /** CPU time since the previous lap, less what parts already account for. */
225
+ #takeCpu(covered: number): { cpuMs?: number } {
226
+ if (!this.#listener) return {};
227
+ const now = cpuNow();
228
+ const spent = now - this.#lastCpu - covered;
229
+ this.#lastCpu = now;
230
+ return { cpuMs: Math.max(0, Math.round(spent)) };
231
+ }
232
+
233
+ /** The peak since the previous lap, then restart it from now. */
234
+ #takePeak(): { peakRssMb?: number } {
235
+ if (!this.#listener) return {};
236
+ const peak = Math.max(this.#peak, rssNow());
237
+ this.#peak = rssNow();
238
+ return { peakRssMb: Math.round(peak / MIB) };
239
+ }
62
240
  }
@@ -0,0 +1,111 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /** Issue #1254 — the connection budget formula (pool-budget.ts). */
4
+
5
+ import { describe, expect, it } from "bun:test";
6
+ import {
7
+ ADMIN_POOL_FLOOR,
8
+ ADMIN_PUBLIC_POOL_MAX,
9
+ computePoolBudget,
10
+ DB_POOL_MAX_ENV,
11
+ DB_PUBLIC_POOL_MAX_ENV,
12
+ GATEWAY_POOL_MAX,
13
+ GENERATOR_POOL_MAX,
14
+ LISTEN_CONNECTIONS_PER_ADMIN_INSTANCE,
15
+ type PoolBudgetInput,
16
+ poolBudgetEnv,
17
+ } from "./pool-budget.js";
18
+
19
+ const GCP_DEFAULT: PoolBudgetInput = {
20
+ maxConnections: 100,
21
+ adminMaxInstances: 5,
22
+ gatewayMaxInstances: 10,
23
+ concurrentBuilds: 2,
24
+ maintenanceReserve: 5,
25
+ migrationsReserve: 2,
26
+ jobsReserve: 4,
27
+ };
28
+
29
+ describe("computePoolBudget", () => {
30
+ it("splits what the reserves leave across the admin instances", () => {
31
+ const b = computePoolBudget(GCP_DEFAULT);
32
+ // 100 - (5 + 2 + 4 + 8 + 20 + 5 + 10 + 20) = 26 → floor(26 / 5) = 5, 1 left.
33
+ expect(b.adminPoolMax).toBe(5);
34
+ expect(b.unassigned).toBe(1);
35
+ expect(b.adminPublicPoolMax).toBe(ADMIN_PUBLIC_POOL_MAX);
36
+ expect(b.gatewayPoolMax).toBe(GATEWAY_POOL_MAX);
37
+ expect(b.generatorPoolMax).toBe(GENERATOR_POOL_MAX);
38
+ });
39
+
40
+ it("never hands out more than max_connections at full scale-out", () => {
41
+ for (const maxConnections of [60, 100, 250, 429]) {
42
+ for (const adminMaxInstances of [1, 3, 5]) {
43
+ const input = { ...GCP_DEFAULT, maxConnections, adminMaxInstances };
44
+ let b: ReturnType<typeof computePoolBudget>;
45
+ try {
46
+ b = computePoolBudget(input);
47
+ } catch {
48
+ continue;
49
+ }
50
+ const reserved = b.reserves.reduce((s, r) => s + r.connections, 0);
51
+ expect(reserved + b.adminPoolMax * adminMaxInstances + b.unassigned).toBe(maxConnections);
52
+ }
53
+ }
54
+ });
55
+
56
+ it("names a LISTEN reserve of one connection per admin instance (#1267)", () => {
57
+ const listen = computePoolBudget(GCP_DEFAULT).reserves.find((r) => r.name === "listen");
58
+ expect(LISTEN_CONNECTIONS_PER_ADMIN_INSTANCE).toBe(1);
59
+ expect(listen?.connections).toBe(GCP_DEFAULT.adminMaxInstances);
60
+ });
61
+
62
+ it("budgets the gateway and the generator from their instance counts", () => {
63
+ const r = new Map(computePoolBudget(GCP_DEFAULT).reserves.map((x) => [x.name, x.connections]));
64
+ expect(r.get("gateway")).toBe(10 * GATEWAY_POOL_MAX);
65
+ expect(r.get("generator")).toBe(2 * 2 * GENERATOR_POOL_MAX);
66
+ expect(r.get("admin-public")).toBe(5 * ADMIN_PUBLIC_POOL_MAX);
67
+ });
68
+
69
+ it("refuses a budget below the admin floor, naming the connections needed", () => {
70
+ // The pre-#1254 GCP shape: 100 gateway instances can never fit 100 slots.
71
+ expect(() => computePoolBudget({ ...GCP_DEFAULT, gatewayMaxInstances: 100 })).toThrow(
72
+ /Raise maxConnections to at least 364/,
73
+ );
74
+ expect(() => computePoolBudget({ ...GCP_DEFAULT, maxConnections: 50 })).toThrow(
75
+ new RegExp(`minimum of ${ADMIN_POOL_FLOOR}`),
76
+ );
77
+ });
78
+
79
+ it("accepts exactly the floor", () => {
80
+ // reserves 74 + 5 × 4 = 94.
81
+ expect(computePoolBudget({ ...GCP_DEFAULT, maxConnections: 94 }).adminPoolMax).toBe(4);
82
+ expect(() => computePoolBudget({ ...GCP_DEFAULT, maxConnections: 93 })).toThrow();
83
+ });
84
+
85
+ it("rejects malformed input instead of guessing", () => {
86
+ expect(() => computePoolBudget({ ...GCP_DEFAULT, adminMaxInstances: 0 })).toThrow(
87
+ /adminMaxInstances/,
88
+ );
89
+ expect(() => computePoolBudget({ ...GCP_DEFAULT, maxConnections: Number.NaN })).toThrow(
90
+ /maxConnections/,
91
+ );
92
+ expect(() => computePoolBudget({ ...GCP_DEFAULT, jobsReserve: -1 })).toThrow(/jobsReserve/);
93
+ });
94
+ });
95
+
96
+ describe("poolBudgetEnv", () => {
97
+ const b = computePoolBudget(GCP_DEFAULT);
98
+
99
+ it("sizes both admin pools", () => {
100
+ expect(poolBudgetEnv(b, "admin")).toEqual([
101
+ { name: DB_POOL_MAX_ENV, value: "5" },
102
+ { name: DB_PUBLIC_POOL_MAX_ENV, value: String(ADMIN_PUBLIC_POOL_MAX) },
103
+ ]);
104
+ });
105
+
106
+ it("sizes the gateway's only (cms_public) pool", () => {
107
+ expect(poolBudgetEnv(b, "gateway")).toEqual([
108
+ { name: DB_PUBLIC_POOL_MAX_ENV, value: String(GATEWAY_POOL_MAX) },
109
+ ]);
110
+ });
111
+ });
@@ -0,0 +1,234 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * Issue #1254 — the database connection budget of an install.
5
+ *
6
+ * Every Caelo process that talks to Postgres holds connection pools, and
7
+ * they all draw from ONE server-wide `max_connections` (cms_admin and
8
+ * cms_public live on the same server). Before #1254 every pool took Bun's
9
+ * default of 10, so the admin alone asked for `adminMaxInstances × 2 × 10`
10
+ * — exactly the 100 slots of a default install, with nothing left for the
11
+ * gateway, the static generator, migrations or the provider's own agents.
12
+ * Under load the last admin instance to scale up could not connect at all.
13
+ *
14
+ * The budget turns that around: provisioning states how many connections
15
+ * the server has and how many instances may run, every fixed consumer is
16
+ * named as a {@link PoolBudgetReserve}, and what is left is split across
17
+ * the admin instances' cms_admin pools — the pool the write path uses.
18
+ * The admin's cms_public pool is small and fixed: the admin only reads
19
+ * plugin data there and publishes the gateway projection.
20
+ *
21
+ * Why not simply raise the pool: a larger pool only moves the wait from
22
+ * the pool to the server. A pool sized past the budget fails with "too
23
+ * many clients" at the worst moment (scale-out under load) instead of
24
+ * waiting a bounded time for a free connection of its own pool
25
+ * (query-api pool-guard.ts). docs/dev/connection-budget.md has the long
26
+ * version.
27
+ *
28
+ * Pure module — no I/O. Used by the provisioning stacks (which set the
29
+ * resulting env on each service) and re-exported by `@caelo-cms/query-api`.
30
+ */
31
+
32
+ /** Env var sizing every pool of a `DatabaseAdapter` (both pools, unless the next one is set). */
33
+ export const DB_POOL_MAX_ENV = "CAELO_DB_POOL_MAX";
34
+
35
+ /**
36
+ * Env var sizing the cms_public pool of a `DatabaseAdapter` on its own. It
37
+ * wins over {@link DB_POOL_MAX_ENV} for that pool, so the admin can run a
38
+ * large cms_admin pool next to a small cms_public one.
39
+ */
40
+ export const DB_PUBLIC_POOL_MAX_ENV = "CAELO_DB_PUBLIC_POOL_MAX";
41
+
42
+ /** Smallest pool a `DatabaseAdapter` accepts: one connection self-deadlocks it. */
43
+ export const MIN_POOL_MAX = 2;
44
+
45
+ /**
46
+ * Smallest cms_admin pool the budget hands an admin instance. Below it a
47
+ * handful of concurrent editors and agents queue on every write; an install
48
+ * whose server cannot afford it must get a larger tier or fewer instances,
49
+ * so {@link computePoolBudget} refuses instead of shipping a starved pool.
50
+ */
51
+ export const ADMIN_POOL_FLOOR = 4;
52
+
53
+ /** The admin's cms_public pool: plugin-data reads and the gateway projection. */
54
+ export const ADMIN_PUBLIC_POOL_MAX = 4;
55
+
56
+ /** The gateway's only pool (public_role on cms_public), per instance. */
57
+ export const GATEWAY_POOL_MAX = 2;
58
+
59
+ /**
60
+ * Each pool of one static-generator subprocess. A build runs in ONE
61
+ * transaction (pool-guard.ts LONG_TRANSACTION_ALLOWLIST) and takes no
62
+ * second checkout, so the minimum pool ({@link MIN_POOL_MAX}) is enough.
63
+ */
64
+ export const GENERATOR_POOL_MAX = 2;
65
+
66
+ /**
67
+ * #1267 — the dedicated connection each admin instance keeps open for
68
+ * `LISTEN` on the change journal's wake-up channel. A LISTEN connection is
69
+ * held for the instance's lifetime and can never serve a query of the pool,
70
+ * so it is budgeted outside the pools, one per admin instance.
71
+ */
72
+ export const LISTEN_CONNECTIONS_PER_ADMIN_INSTANCE = 1;
73
+
74
+ /**
75
+ * The connection each gateway instance keeps open for `LISTEN
76
+ * caelo_gateway_settings` on cms_public (api-gateway server.ts): outside the
77
+ * gateway's pool, held for the instance's lifetime.
78
+ */
79
+ export const LISTEN_CONNECTIONS_PER_GATEWAY_INSTANCE = 1;
80
+
81
+ /** What the budget is computed from: the server and the scale-out limits. */
82
+ export interface PoolBudgetInput {
83
+ /** The server's `max_connections`, as provisioning sets it. */
84
+ readonly maxConnections: number;
85
+ /** Most admin instances that may run at once (Cloud Run `maxInstanceCount`, …). */
86
+ readonly adminMaxInstances: number;
87
+ /** Most gateway instances that may run at once. */
88
+ readonly gatewayMaxInstances: number;
89
+ /**
90
+ * Static-generator subprocesses that may run at once across the install
91
+ * (a Stage, a Publish live and a plugin-data build can overlap).
92
+ */
93
+ readonly concurrentBuilds: number;
94
+ /**
95
+ * Connections the server keeps for itself and the provider's agents:
96
+ * `superuser_reserved_connections`, Cloud SQL's `cloudsqladmin`, RDS's
97
+ * `rdsadmin`, Azure's replication and monitoring slots.
98
+ */
99
+ readonly maintenanceReserve: number;
100
+ /** Connections a migration run opens (one per database). */
101
+ readonly migrationsReserve: number;
102
+ /**
103
+ * Connections of the install's other jobs (operator-access sync,
104
+ * upgrader, converge). They run one query at a time.
105
+ */
106
+ readonly jobsReserve: number;
107
+ }
108
+
109
+ /** The named consumers the budget sets aside before it sizes the admin's cms_admin pool. */
110
+ export type PoolBudgetReserveName =
111
+ | "maintenance"
112
+ | "migrations"
113
+ | "jobs"
114
+ | "generator"
115
+ | "gateway"
116
+ /** #1267 — one LISTEN connection per admin instance ({@link LISTEN_CONNECTIONS_PER_ADMIN_INSTANCE}). */
117
+ | "listen"
118
+ /** The gateway's settings LISTEN, one per gateway instance ({@link LISTEN_CONNECTIONS_PER_GATEWAY_INSTANCE}). */
119
+ | "gateway-listen"
120
+ | "admin-public";
121
+
122
+ /** One consumer of the server's connections the budget sets aside. */
123
+ export interface PoolBudgetReserve {
124
+ readonly name: PoolBudgetReserveName;
125
+ readonly connections: number;
126
+ }
127
+
128
+ /** The computed budget: pool sizes per process, and where every connection goes. */
129
+ export interface PoolBudget {
130
+ /** cms_admin pool of each admin instance ({@link DB_POOL_MAX_ENV} on the admin). */
131
+ readonly adminPoolMax: number;
132
+ /** cms_public pool of each admin instance ({@link DB_PUBLIC_POOL_MAX_ENV} on the admin). */
133
+ readonly adminPublicPoolMax: number;
134
+ /** The gateway's cms_public pool ({@link DB_PUBLIC_POOL_MAX_ENV} on the gateway). */
135
+ readonly gatewayPoolMax: number;
136
+ /** Each pool of a generator subprocess (the admin sets it on the child). */
137
+ readonly generatorPoolMax: number;
138
+ readonly reserves: readonly PoolBudgetReserve[];
139
+ /** Connections no pool may use (the remainder of the per-instance split). */
140
+ readonly unassigned: number;
141
+ }
142
+
143
+ function positiveInteger(value: number, name: string, min: number): void {
144
+ if (!Number.isInteger(value) || value < min) {
145
+ throw new Error(`pool budget: ${name} must be an integer >= ${min} (got ${value})`);
146
+ }
147
+ }
148
+
149
+ /**
150
+ * Split `maxConnections` across every process of the install.
151
+ *
152
+ * `adminPoolMax = floor((maxConnections − Σ reserves) / adminMaxInstances)`,
153
+ * where the reserves are the fixed consumers of {@link PoolBudgetInput} plus
154
+ * `gateway = gatewayMaxInstances × GATEWAY_POOL_MAX`,
155
+ * `generator = concurrentBuilds × 2 × GENERATOR_POOL_MAX`,
156
+ * `listen = adminMaxInstances × LISTEN_CONNECTIONS_PER_ADMIN_INSTANCE`,
157
+ * `gateway-listen = gatewayMaxInstances × LISTEN_CONNECTIONS_PER_GATEWAY_INSTANCE` and
158
+ * `admin-public = adminMaxInstances × ADMIN_PUBLIC_POOL_MAX`.
159
+ *
160
+ * @throws Error naming what to change when the server cannot give every
161
+ * admin instance at least {@link ADMIN_POOL_FLOOR} cms_admin connections.
162
+ *
163
+ * @example
164
+ * computePoolBudget({ maxConnections: 100, adminMaxInstances: 5,
165
+ * gatewayMaxInstances: 10, concurrentBuilds: 2, maintenanceReserve: 5,
166
+ * migrationsReserve: 2, jobsReserve: 4 }).adminPoolMax // → 7
167
+ */
168
+ export function computePoolBudget(input: PoolBudgetInput): PoolBudget {
169
+ positiveInteger(input.maxConnections, "maxConnections", 1);
170
+ positiveInteger(input.adminMaxInstances, "adminMaxInstances", 1);
171
+ positiveInteger(input.gatewayMaxInstances, "gatewayMaxInstances", 0);
172
+ positiveInteger(input.concurrentBuilds, "concurrentBuilds", 0);
173
+ positiveInteger(input.maintenanceReserve, "maintenanceReserve", 0);
174
+ positiveInteger(input.migrationsReserve, "migrationsReserve", 0);
175
+ positiveInteger(input.jobsReserve, "jobsReserve", 0);
176
+
177
+ const reserves: PoolBudgetReserve[] = [
178
+ { name: "maintenance", connections: input.maintenanceReserve },
179
+ { name: "migrations", connections: input.migrationsReserve },
180
+ { name: "jobs", connections: input.jobsReserve },
181
+ { name: "generator", connections: input.concurrentBuilds * 2 * GENERATOR_POOL_MAX },
182
+ { name: "gateway", connections: input.gatewayMaxInstances * GATEWAY_POOL_MAX },
183
+ {
184
+ name: "listen",
185
+ connections: input.adminMaxInstances * LISTEN_CONNECTIONS_PER_ADMIN_INSTANCE,
186
+ },
187
+ {
188
+ name: "gateway-listen",
189
+ connections: input.gatewayMaxInstances * LISTEN_CONNECTIONS_PER_GATEWAY_INSTANCE,
190
+ },
191
+ { name: "admin-public", connections: input.adminMaxInstances * ADMIN_PUBLIC_POOL_MAX },
192
+ ];
193
+ const reserved = reserves.reduce((sum, r) => sum + r.connections, 0);
194
+ const forAdmin = input.maxConnections - reserved;
195
+ const adminPoolMax = Math.floor(forAdmin / input.adminMaxInstances);
196
+ if (adminPoolMax < ADMIN_POOL_FLOOR) {
197
+ const needed = reserved + ADMIN_POOL_FLOOR * input.adminMaxInstances;
198
+ const breakdown = reserves.map((r) => `${r.name} ${r.connections}`).join(", ");
199
+ throw new Error(
200
+ `pool budget: max_connections ${input.maxConnections} cannot give each of ${input.adminMaxInstances} admin instances the minimum of ${ADMIN_POOL_FLOOR} cms_admin connections ` +
201
+ `(reserved: ${breakdown}; ${needed} needed). Raise maxConnections to at least ${needed} (a larger database tier may be required), ` +
202
+ "or lower adminMaxInstances / gatewayMaxInstances.",
203
+ );
204
+ }
205
+ return {
206
+ adminPoolMax,
207
+ adminPublicPoolMax: ADMIN_PUBLIC_POOL_MAX,
208
+ gatewayPoolMax: GATEWAY_POOL_MAX,
209
+ generatorPoolMax: GENERATOR_POOL_MAX,
210
+ reserves,
211
+ unassigned: forAdmin - adminPoolMax * input.adminMaxInstances,
212
+ };
213
+ }
214
+
215
+ /** Which service's pool env {@link poolBudgetEnv} returns. */
216
+ export type PoolBudgetService = "admin" | "gateway";
217
+
218
+ /**
219
+ * The env that sizes `service`'s pools from `budget`, for the stacks to set.
220
+ * The admin gets both pools; the gateway, whose only pool is cms_public,
221
+ * gets just that one.
222
+ */
223
+ export function poolBudgetEnv(
224
+ budget: PoolBudget,
225
+ service: PoolBudgetService,
226
+ ): { readonly name: string; readonly value: string }[] {
227
+ if (service === "gateway") {
228
+ return [{ name: DB_PUBLIC_POOL_MAX_ENV, value: String(budget.gatewayPoolMax) }];
229
+ }
230
+ return [
231
+ { name: DB_POOL_MAX_ENV, value: String(budget.adminPoolMax) },
232
+ { name: DB_PUBLIC_POOL_MAX_ENV, value: String(budget.adminPublicPoolMax) },
233
+ ];
234
+ }
@@ -0,0 +1,75 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ import { describe, expect, it } from "bun:test";
4
+ import { describeStageRun, formatStageDuration, type StageStatusRun } from "./stage-status.js";
5
+
6
+ const base: StageStatusRun = {
7
+ runId: "00000000-0000-0000-0000-000000001288",
8
+ targetName: "staging",
9
+ status: "running",
10
+ phase: "building",
11
+ pagesDone: 812,
12
+ pagesTotal: 1904,
13
+ startedAt: "2026-11-01T07:00:00.000Z",
14
+ finishedAt: null,
15
+ elapsedMs: 60_000,
16
+ error: null,
17
+ failedPhase: null,
18
+ eta: { remainingMs: 100_000, basis: "this_run_rate", includesPublish: true },
19
+ etaUnavailable: null,
20
+ };
21
+
22
+ describe("formatStageDuration", () => {
23
+ it("reads like a person would say it", () => {
24
+ expect(formatStageDuration(45_000)).toBe("45 s");
25
+ expect(formatStageDuration(100_000)).toBe("1 min 40 s");
26
+ expect(formatStageDuration(120_000)).toBe("2 min");
27
+ expect(formatStageDuration(725_000)).toBe("12 min");
28
+ });
29
+ });
30
+
31
+ describe("describeStageRun", () => {
32
+ it("states progress and the estimate with its basis", () => {
33
+ expect(describeStageRun(base)).toBe(
34
+ "Building pages 812 of 1,904 — about 1 min 40 s left (from this run's pace)",
35
+ );
36
+ });
37
+
38
+ it("says when the estimate covers the pages only", () => {
39
+ expect(
40
+ describeStageRun({
41
+ ...base,
42
+ eta: { remainingMs: 30_000, basis: "this_run_rate", includesPublish: false },
43
+ }),
44
+ ).toContain("pages only — publishing time unknown");
45
+ });
46
+
47
+ it("names why there is no estimate instead of a number", () => {
48
+ expect(
49
+ describeStageRun({
50
+ ...base,
51
+ phase: "preparing",
52
+ pagesDone: null,
53
+ pagesTotal: null,
54
+ eta: null,
55
+ etaUnavailable: "no successful Stage has recorded its step times yet",
56
+ }),
57
+ ).toBe(
58
+ "Preparing the build (1 min so far) — no estimate: no successful Stage has recorded its step times yet",
59
+ );
60
+ });
61
+
62
+ it("gives a failed run's step and reason", () => {
63
+ expect(
64
+ describeStageRun({
65
+ ...base,
66
+ status: "failed",
67
+ phase: "failed",
68
+ elapsedMs: 94_736,
69
+ error: "122 render failure(s)",
70
+ failedPhase: "generator",
71
+ eta: null,
72
+ }),
73
+ ).toBe("Failed in generator after 1 min 35 s: 122 render failure(s)");
74
+ });
75
+ });