@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
package/src/content.ts CHANGED
@@ -15,6 +15,7 @@ import { hasEscapedLineBreaks } from "./escaped-line-breaks.js";
15
15
  import { DEFAULT_LIST_TYPES, SITE_TEXTS_LIST_TYPE } from "./lists/default-types.js";
16
16
  import { listItemInputSchema } from "./lists/items.js";
17
17
  import { LIST_MAX_ITEMS, sourceDefSchema } from "./lists/list-type.js";
18
+ import { pageTypeDefSchema } from "./lists/page-type.js";
18
19
  import { listPaginationSchema } from "./lists/pagination.js";
19
20
  import { LIST_REF_RE } from "./lists/refs.js";
20
21
 
@@ -185,7 +186,7 @@ const noCdata = (value: string): boolean => !value.includes(CDATA_OPEN);
185
186
  const cdataMessage = (field: "css" | "js"): string =>
186
187
  `${field} contains an XML CDATA marker (${CDATA_OPEN}) — module ${field} is stored and served ` +
187
188
  `verbatim, so CDATA wrappers reach the browser unparsed and break rendering. ` +
188
- `Remove the wrapper and resend plain ${field === "css" ? "CSS" : "JavaScript"}.`;
189
+ `Remove the wrapper: pass plain ${field === "css" ? "CSS" : "JavaScript"}.`;
189
190
 
190
191
  const moduleCss = z
191
192
  .string()
@@ -624,17 +625,37 @@ export const pageTypeSlugSchema = z
624
625
  "page type slug: lowercase letters, digits, hyphens",
625
626
  );
626
627
 
628
+ /**
629
+ * #1276 — slugs a new page type is deliberately distinct from: the
630
+ * duplicate guard refuses a new type whose slug normalises to an existing
631
+ * one (`blogs` next to `blog`) unless the caller names it here.
632
+ */
633
+ export const pageTypeDistinctFromSchema = z.array(pageTypeSlugSchema).max(16);
634
+
635
+ /**
636
+ * #1276 — a page type created together with its first template
637
+ * (`templates.create` `newPageType`): the definition without `templates`
638
+ * and `defaultTemplate`, which are the new template.
639
+ */
640
+ export const newPageTypeSchema = pageTypeDefSchema
641
+ .omit({ templates: true, defaultTemplate: true })
642
+ .extend({ distinctFrom: pageTypeDistinctFromSchema.optional() })
643
+ .strict();
644
+ export type NewPageTypeInput = z.infer<typeof newPageTypeSchema>;
645
+
627
646
  export const templateCreateSchema = z
628
647
  .object({
629
648
  slug: slugSchema,
630
649
  displayName: displayNameSchema,
631
650
  /**
632
- * Epic #1107 — the page type (slug) this template serves; the template
633
- * is appended to that type's `templates`. Omit for the site's default
634
- * page type (`site_defaults.default_page_type_id`, stored data — not a
635
- * render-time fallback).
651
+ * Epic #1107 / #1276 — the page type this template serves. Exactly one
652
+ * of `pageType` (an existing type's slug: the template is appended to
653
+ * its `templates`) and `newPageType` (a new type created in the same
654
+ * transaction with this template as its `defaultTemplate`) is
655
+ * required; `templates.create` refuses neither or both.
636
656
  */
637
657
  pageType: pageTypeSlugSchema.optional(),
658
+ newPageType: newPageTypeSchema.optional(),
638
659
  html: z
639
660
  .string()
640
661
  .max(TEMPLATE_HTML_MAX, `html exceeds ${TEMPLATE_HTML_MAX} bytes`)
@@ -736,9 +757,9 @@ export const pageCreateSchema = z
736
757
  */
737
758
  shortTitle: pageShortTitleSchema.optional(),
738
759
  /**
739
- * P6.7.6 — optional. When omitted, the handler resolves to
740
- * `site_defaults.default_template_id` at create time. Stored data,
741
- * not a render-time fallback (CLAUDE.md §2 no-fallbacks).
760
+ * Optional: one of the page type's templates; omitted = the type's
761
+ * `defaultTemplate`. Without `pageType` it must belong to exactly one
762
+ * page type, which the page then gets (#1276).
742
763
  */
743
764
  templateId: z.string().uuid().optional(),
744
765
  /**
@@ -756,9 +777,9 @@ export const pageCreateSchema = z
756
777
  */
757
778
  parentPageId: z.string().uuid().nullable().optional(),
758
779
  /**
759
- * Epic #1107 — what the page IS (page type slug). Omit to take the type
760
- * whose `templates` contain the template; with neither `pageType` nor
761
- * `templateId`, the site's default page type and its default template.
780
+ * Epic #1107 — what the page IS (page type slug). Required unless
781
+ * `templateId` belongs to exactly one page type; there is no stored
782
+ * default type (#1276), so a create with neither is refused.
762
783
  */
763
784
  pageType: pageTypeSlugSchema.optional(),
764
785
  /** Property values, validated against the page type. */
package/src/index.ts CHANGED
@@ -9,6 +9,7 @@ export * from "./base-css.js";
9
9
  export * from "./bug-intake.js";
10
10
  export * from "./build-page.js";
11
11
  export * from "./cap-failures.js";
12
+ export * from "./change-journal.js";
12
13
  export * from "./cloud-shell-install.js";
13
14
  export * from "./content.js";
14
15
  export * from "./context.js";
@@ -55,6 +56,7 @@ export * from "./page-log.js";
55
56
  export * from "./phase-timer.js";
56
57
  export * from "./platform-updates.js";
57
58
  export * from "./plugin-preview.js";
59
+ export * from "./pool-budget.js";
58
60
  export * from "./preview-compose.js";
59
61
  export * from "./preview-scanner.js";
60
62
  export * from "./proposal-status.js";
@@ -56,6 +56,12 @@ export const LIST_RESERVED_NAMES: ReadonlySet<string> = new Set([
56
56
  /** Name prefixes reserved as a family (see {@link LIST_RESERVED_NAMES}). */
57
57
  export const LIST_RESERVED_PREFIXES = ["is_", "has_", "count_"] as const;
58
58
 
59
+ /**
60
+ * The reserved names as one clause for an error (#1277: the tool
61
+ * description no longer lists them, so the refusal does).
62
+ */
63
+ export const LIST_RESERVED_NAMES_CLAUSE = `reserved: ${[...LIST_RESERVED_NAMES].join(", ")}, ${LIST_RESERVED_PREFIXES.map((p) => `${p}*`).join(", ")}`;
64
+
59
65
  /** True when `name` is taken by the engine (see {@link LIST_RESERVED_NAMES}). */
60
66
  export function isReservedListName(name: string): boolean {
61
67
  return LIST_RESERVED_NAMES.has(name) || LIST_RESERVED_PREFIXES.some((p) => name.startsWith(p));
@@ -256,7 +262,7 @@ export function checkFieldDef(field: FieldDef, path: string): ListIssue[] {
256
262
  if (isReservedListName(field.name)) {
257
263
  issues.push({
258
264
  path,
259
- message: `field name "${field.name}" is reserved by the template engine — pick another name.`,
265
+ message: `field name "${field.name}" is reserved by the template engine (${LIST_RESERVED_NAMES_CLAUSE}) — pick another name.`,
260
266
  });
261
267
  }
262
268
  if (field.default !== undefined) {
@@ -262,6 +262,7 @@ export function fixtureData(
262
262
  slug: "news",
263
263
  name: "News",
264
264
  purpose: "News posts.",
265
+ collection: true,
265
266
  templates: [id(801)],
266
267
  defaultTemplate: id(801),
267
268
  properties: [
@@ -17,6 +17,7 @@ import {
17
17
  fieldDefSchema,
18
18
  fieldExtSchema,
19
19
  isReservedListName,
20
+ LIST_RESERVED_NAMES_CLAUSE,
20
21
  type ListIssue,
21
22
  listNameSchema,
22
23
  } from "./fields.js";
@@ -180,7 +181,10 @@ export function checkListTypeDef(def: ListTypeDef): ListIssue[] {
180
181
  for (const [kind, node] of Object.entries(def.nodes)) {
181
182
  const at = `nodes.${kind}`;
182
183
  if (isReservedListName(kind)) {
183
- issues.push({ path: at, message: `node kind "${kind}" is a reserved name — rename it.` });
184
+ issues.push({
185
+ path: at,
186
+ message: `node kind "${kind}" is a reserved name (${LIST_RESERVED_NAMES_CLAUSE}) — rename it.`,
187
+ });
184
188
  }
185
189
  const seen = new Set<string>();
186
190
  node.fields.forEach((f, i) => {
@@ -193,7 +197,7 @@ export function checkListTypeDef(def: ListTypeDef): ListIssue[] {
193
197
  if (isReservedListName(node.children.name)) {
194
198
  issues.push({
195
199
  path: `${at}.children.name`,
196
- message: `children name "${node.children.name}" is reserved — pick another loop name.`,
200
+ message: `children name "${node.children.name}" is reserved (${LIST_RESERVED_NAMES_CLAUSE}) — pick another loop name.`,
197
201
  });
198
202
  }
199
203
  if (seen.has(node.children.name)) {
@@ -295,6 +295,7 @@ describe("page types and addresses", () => {
295
295
  slug: "news",
296
296
  name: "News",
297
297
  purpose: "Dated announcements",
298
+ collection: true,
298
299
  templates: [PAGE],
299
300
  defaultTemplate: LIST,
300
301
  properties: [{ name: "teaser", label: "T", type: "module" }],
@@ -17,6 +17,13 @@ export const pageTypeDefSchema = z
17
17
  slug: z.string().regex(/^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/),
18
18
  name: z.string().min(1).max(200),
19
19
  purpose: z.string().min(1).max(1000),
20
+ /**
21
+ * A collection of many similar pages meant to be listed or queried
22
+ * (blog, news, products, jobs, docs) — true; standalone pages (home,
23
+ * about / team / contact, legal, landing, utility) — false. The
24
+ * manual-page-listing check looks only at collection types (0450).
25
+ */
26
+ collection: z.boolean(),
20
27
  /** Template ids a page of this type may bind to. */
21
28
  templates: z.array(z.string().uuid()).min(1).max(64),
22
29
  defaultTemplate: z.string().uuid(),
@@ -11,9 +11,13 @@
11
11
  * it is safe to hand to every signed-in tab. The read that follows goes
12
12
  * through the endpoint's own permission checks.
13
13
  *
14
- * Topics come from the names of operations that committed (the Query API's
15
- * `observeCommittedOperations`). Only WRITE operations are listed: a read
16
- * classified as a change would make every refresh announce another change.
14
+ * Operational topics come from the names of operations that committed (the
15
+ * Query API's `observeCommittedOperations`). Only WRITE operations are
16
+ * listed: a read classified as a change would make every refresh announce
17
+ * another change. Entity-derived topics (`draft`) come from the change
18
+ * journal instead (issue #1267): they fire exactly when a committed write
19
+ * changed a journaled row, on whichever instance it committed, and never
20
+ * for a write that changed nothing.
17
21
  */
18
22
 
19
23
  /** One surface an editor tab may need to re-read. */
@@ -30,6 +34,12 @@ export const LIVE_TOPICS = [
30
34
  "crawl",
31
35
  /** Deploy runs (Stage, Publish live, rollback). */
32
36
  "deploys",
37
+ /**
38
+ * Site content changed: a journaled row committed (a chat's or an MCP
39
+ * agent's write, a Stage, an undo, a settings change). From the change
40
+ * journal, never from an op name (#1267).
41
+ */
42
+ "draft",
33
43
  ] as const;
34
44
 
35
45
  export type LiveTopic = (typeof LIVE_TOPICS)[number];
@@ -70,6 +80,11 @@ const EXACT: Readonly<Record<string, readonly LiveTopic[]>> = {
70
80
  // An external agent's tool call: first sight of it lists it as running,
71
81
  // and it may have queued a card.
72
82
  "chat.touch_external_agent": ["agents", "pending"],
83
+ // The same presence row, written by Power-MCP's bookkeeping around each
84
+ // tool call (#1253): both mark the agent present (as the touch did), and
85
+ // the finish follows a card the tool may have queued.
86
+ "mcp.begin_tool_call": ["agents"],
87
+ "mcp.finish_tool_call": ["agents", "pending"],
73
88
  "chat.record_turn_approvals": ["pending"],
74
89
  "chat.decide_turn_approval": ["pending"],
75
90
  "chat.supersede_turn_approvals": ["pending"],
package/src/media.test.ts CHANGED
@@ -103,12 +103,43 @@ describe("media schemas", () => {
103
103
  height: 1080,
104
104
  sizeBytes: 1024,
105
105
  storageKey: `${"a".repeat(64)}/orig.jpg`,
106
+ digest: {
107
+ contentSha256: "b".repeat(64),
108
+ size: 1024,
109
+ gzipSha256: "c".repeat(64),
110
+ gzipSize: 1000,
111
+ profile: "gzip9-0123456789abcdef",
112
+ },
106
113
  },
107
114
  ],
108
115
  });
109
116
  expect(r.success).toBe(true);
110
117
  });
111
118
 
119
+ it("mediaUploadInputSchema requires each variant's published digests (#1271)", () => {
120
+ const r = mediaUploadInputSchema.safeParse({
121
+ sha256: "a".repeat(64),
122
+ originalName: "hero.jpg",
123
+ mime: "image/jpeg",
124
+ sizeBytes: 1024,
125
+ width: 1920,
126
+ height: 1080,
127
+ alt: "",
128
+ storageKey: `${"a".repeat(64)}/orig.jpg`,
129
+ variants: [
130
+ {
131
+ variant: "orig",
132
+ format: "jpeg",
133
+ width: 1920,
134
+ height: 1080,
135
+ sizeBytes: 1024,
136
+ storageKey: `${"a".repeat(64)}/orig.jpg`,
137
+ },
138
+ ],
139
+ });
140
+ expect(r.success).toBe(false);
141
+ });
142
+
112
143
  it("mediaUploadInputSchema rejects non-hex sha256", () => {
113
144
  const r = mediaUploadInputSchema.safeParse({
114
145
  sha256: "not-a-sha",
package/src/media.ts CHANGED
@@ -295,6 +295,21 @@ const mediaRefPattern =
295
295
 
296
296
  const sha256Schema = z.string().regex(/^[0-9a-f]{64}$/, "must be hex sha256");
297
297
 
298
+ /**
299
+ * #1271 — the published digests of one stored media file (the static
300
+ * generator's `published-digest.ts`), stored with its variant row so no
301
+ * build reads the file to name or upload it.
302
+ */
303
+ export const publishedDigestSchema = z
304
+ .object({
305
+ contentSha256: sha256Schema,
306
+ size: z.number().int().nonnegative(),
307
+ gzipSha256: sha256Schema,
308
+ gzipSize: z.number().int().nonnegative(),
309
+ profile: z.string().min(1).max(64),
310
+ })
311
+ .strict();
312
+
298
313
  /**
299
314
  * #531 — `library` assets are listed and placeable on pages; `reference`
300
315
  * assets exist only to guide image generation/editing: hidden from the
@@ -345,6 +360,8 @@ export const mediaUploadInputSchema = z
345
360
  height: z.number().int().positive().nullable(),
346
361
  sizeBytes: z.number().int().positive(),
347
362
  storageKey: z.string().min(1),
363
+ /** #1271 — taken from the bytes the writer stored (runMediaPipeline). */
364
+ digest: publishedDigestSchema,
348
365
  }),
349
366
  )
350
367
  .min(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
  }