@caelo-cms/shared 0.11.4 → 0.11.6

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 (66) 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 +251 -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 +2 -0
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +2 -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/themes-errors.js +1 -1
  45. package/dist/themes-errors.js.map +1 -1
  46. package/dist/version.d.ts +2 -2
  47. package/dist/version.js +1 -1
  48. package/package.json +1 -1
  49. package/src/ai-tools.ts +23 -8
  50. package/src/change-journal.ts +323 -0
  51. package/src/content.ts +32 -11
  52. package/src/index.ts +2 -0
  53. package/src/lists/fields.ts +7 -1
  54. package/src/lists/list-fixtures.test.ts +1 -0
  55. package/src/lists/list-type.ts +6 -2
  56. package/src/lists/lists.test.ts +1 -0
  57. package/src/lists/page-type.ts +7 -0
  58. package/src/live-events.ts +18 -3
  59. package/src/media.test.ts +31 -0
  60. package/src/media.ts +17 -0
  61. package/src/phase-timer.test.ts +37 -0
  62. package/src/phase-timer.ts +182 -4
  63. package/src/pool-budget.test.ts +111 -0
  64. package/src/pool-budget.ts +234 -0
  65. package/src/themes-errors.ts +1 -1
  66. package/src/version.ts +1 -1
@@ -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
+ }
@@ -189,7 +189,7 @@ export class TokenCategoryMismatch extends Error {
189
189
  readonly got: string;
190
190
  constructor(canonicalPath: string, expected: string, got: string) {
191
191
  super(
192
- `TokenCategoryMismatch: '${canonicalPath}' expects a ${expected} value, got a ${got} — re-send with a value matching ${expected}.`,
192
+ `TokenCategoryMismatch: '${canonicalPath}' expects a ${expected} value, got a ${got} — use a value matching ${expected}.`,
193
193
  );
194
194
  this.name = "TokenCategoryMismatch";
195
195
  this.canonicalPath = canonicalPath;
package/src/version.ts CHANGED
@@ -22,7 +22,7 @@
22
22
  * follow standard SemVer.
23
23
  */
24
24
 
25
- export const CAELO_VERSION = "0.11.4";
25
+ export const CAELO_VERSION = "0.11.6";
26
26
 
27
27
  /**
28
28
  * Deprecated alias for back-compat — early P17 work spelled this