@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.
- package/dist/ai-tools.d.ts +206 -2
- package/dist/ai-tools.d.ts.map +1 -1
- package/dist/ai-tools.js +23 -9
- package/dist/ai-tools.js.map +1 -1
- package/dist/change-journal.d.ts +71 -0
- package/dist/change-journal.d.ts.map +1 -0
- package/dist/change-journal.js +251 -0
- package/dist/change-journal.js.map +1 -0
- package/dist/content.d.ts +214 -0
- package/dist/content.d.ts.map +1 -1
- package/dist/content.js +29 -11
- package/dist/content.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/lists/fields.d.ts +5 -0
- package/dist/lists/fields.d.ts.map +1 -1
- package/dist/lists/fields.js +6 -1
- package/dist/lists/fields.js.map +1 -1
- package/dist/lists/list-type.d.ts.map +1 -1
- package/dist/lists/list-type.js +6 -3
- package/dist/lists/list-type.js.map +1 -1
- package/dist/lists/page-type.d.ts +1 -0
- package/dist/lists/page-type.d.ts.map +1 -1
- package/dist/lists/page-type.js +7 -0
- package/dist/lists/page-type.js.map +1 -1
- package/dist/live-events.d.ts +8 -4
- package/dist/live-events.d.ts.map +1 -1
- package/dist/live-events.js +18 -3
- package/dist/live-events.js.map +1 -1
- package/dist/media.d.ts +19 -0
- package/dist/media.d.ts.map +1 -1
- package/dist/media.js +16 -0
- package/dist/media.js.map +1 -1
- package/dist/phase-timer.d.ts +43 -2
- package/dist/phase-timer.d.ts.map +1 -1
- package/dist/phase-timer.js +170 -4
- package/dist/phase-timer.js.map +1 -1
- package/dist/pool-budget.d.ts +152 -0
- package/dist/pool-budget.d.ts.map +1 -0
- package/dist/pool-budget.js +151 -0
- package/dist/pool-budget.js.map +1 -0
- package/dist/themes-errors.js +1 -1
- package/dist/themes-errors.js.map +1 -1
- package/dist/version.d.ts +2 -2
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/src/ai-tools.ts +23 -8
- package/src/change-journal.ts +323 -0
- package/src/content.ts +32 -11
- package/src/index.ts +2 -0
- package/src/lists/fields.ts +7 -1
- package/src/lists/list-fixtures.test.ts +1 -0
- package/src/lists/list-type.ts +6 -2
- package/src/lists/lists.test.ts +1 -0
- package/src/lists/page-type.ts +7 -0
- package/src/live-events.ts +18 -3
- package/src/media.test.ts +31 -0
- package/src/media.ts +17 -0
- package/src/phase-timer.test.ts +37 -0
- package/src/phase-timer.ts +182 -4
- package/src/pool-budget.test.ts +111 -0
- package/src/pool-budget.ts +234 -0
- package/src/themes-errors.ts +1 -1
- 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
|
|
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
|
|
633
|
-
*
|
|
634
|
-
*
|
|
635
|
-
*
|
|
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
|
-
*
|
|
740
|
-
* `
|
|
741
|
-
*
|
|
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).
|
|
760
|
-
*
|
|
761
|
-
*
|
|
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";
|
package/src/lists/fields.ts
CHANGED
|
@@ -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) {
|
package/src/lists/list-type.ts
CHANGED
|
@@ -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({
|
|
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)) {
|
package/src/lists/lists.test.ts
CHANGED
|
@@ -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" }],
|
package/src/lists/page-type.ts
CHANGED
|
@@ -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(),
|
package/src/live-events.ts
CHANGED
|
@@ -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
|
-
*
|
|
15
|
-
* `observeCommittedOperations`). Only WRITE operations are
|
|
16
|
-
* classified as a change would make every refresh announce
|
|
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),
|
package/src/phase-timer.test.ts
CHANGED
|
@@ -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
|
});
|
package/src/phase-timer.ts
CHANGED
|
@@ -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
|
-
/**
|
|
30
|
-
|
|
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({
|
|
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}
|
|
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
|
}
|