@caelo-cms/shared 0.10.22 → 0.10.23
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 +290 -214
- package/dist/ai-tools.d.ts.map +1 -1
- package/dist/ai-tools.js +341 -264
- package/dist/ai-tools.js.map +1 -1
- package/dist/auth-forms.d.ts.map +1 -1
- package/dist/auth-forms.js +4 -1
- package/dist/auth-forms.js.map +1 -1
- package/dist/base-css.d.ts +24 -0
- package/dist/base-css.d.ts.map +1 -0
- package/dist/base-css.js +28 -0
- package/dist/base-css.js.map +1 -0
- package/dist/build-page.d.ts +330 -0
- package/dist/build-page.d.ts.map +1 -0
- package/dist/build-page.js +282 -0
- package/dist/build-page.js.map +1 -0
- package/dist/content.d.ts +322 -9
- package/dist/content.d.ts.map +1 -1
- package/dist/content.js +354 -11
- package/dist/content.js.map +1 -1
- package/dist/css-gradient-scan.d.ts +14 -0
- package/dist/css-gradient-scan.d.ts.map +1 -0
- package/dist/css-gradient-scan.js +81 -0
- package/dist/css-gradient-scan.js.map +1 -0
- package/dist/css-var-scan.d.ts +56 -0
- package/dist/css-var-scan.d.ts.map +1 -0
- package/dist/css-var-scan.js +97 -0
- package/dist/css-var-scan.js.map +1 -0
- package/dist/design-manifest.d.ts +36 -0
- package/dist/design-manifest.d.ts.map +1 -0
- package/dist/design-manifest.js +90 -0
- package/dist/design-manifest.js.map +1 -0
- package/dist/fonts.d.ts +89 -0
- package/dist/fonts.d.ts.map +1 -0
- package/dist/fonts.js +241 -0
- package/dist/fonts.js.map +1 -0
- package/dist/genesis-inventory.d.ts +32 -0
- package/dist/genesis-inventory.d.ts.map +1 -0
- package/dist/genesis-inventory.js +186 -0
- package/dist/genesis-inventory.js.map +1 -0
- package/dist/genesis.d.ts +62 -0
- package/dist/genesis.d.ts.map +1 -0
- package/dist/genesis.js +78 -0
- package/dist/genesis.js.map +1 -0
- package/dist/i18n.d.ts +44 -1
- package/dist/i18n.d.ts.map +1 -1
- package/dist/i18n.js +72 -6
- package/dist/i18n.js.map +1 -1
- package/dist/index.d.ts +27 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +27 -0
- package/dist/index.js.map +1 -1
- package/dist/interactions.d.ts +23 -0
- package/dist/interactions.d.ts.map +1 -0
- package/dist/interactions.js +44 -0
- package/dist/interactions.js.map +1 -0
- package/dist/media.d.ts +101 -16
- package/dist/media.d.ts.map +1 -1
- package/dist/media.js +126 -15
- package/dist/media.js.map +1 -1
- package/dist/page-log.d.ts +94 -0
- package/dist/page-log.d.ts.map +1 -0
- package/dist/page-log.js +111 -0
- package/dist/page-log.js.map +1 -0
- package/dist/preview-compose.d.ts +79 -0
- package/dist/preview-compose.d.ts.map +1 -1
- package/dist/preview-compose.js +155 -25
- package/dist/preview-compose.js.map +1 -1
- package/dist/proposal-status.d.ts +40 -0
- package/dist/proposal-status.d.ts.map +1 -0
- package/dist/proposal-status.js +34 -0
- package/dist/proposal-status.js.map +1 -0
- package/dist/responsive-images.d.ts +64 -0
- package/dist/responsive-images.d.ts.map +1 -0
- package/dist/responsive-images.js +98 -0
- package/dist/responsive-images.js.map +1 -0
- package/dist/safe-keys.d.ts +9 -0
- package/dist/safe-keys.d.ts.map +1 -0
- package/dist/safe-keys.js +20 -0
- package/dist/safe-keys.js.map +1 -0
- package/dist/seo.d.ts +8 -0
- package/dist/seo.d.ts.map +1 -1
- package/dist/seo.js +3 -1
- package/dist/seo.js.map +1 -1
- package/dist/skills.d.ts +14 -68
- package/dist/skills.d.ts.map +1 -1
- package/dist/skills.js +19 -113
- package/dist/skills.js.map +1 -1
- package/dist/strip-cdata.d.ts +7 -0
- package/dist/strip-cdata.d.ts.map +1 -0
- package/dist/strip-cdata.js +48 -0
- package/dist/strip-cdata.js.map +1 -0
- package/dist/structured-sets.d.ts +6 -16
- package/dist/structured-sets.d.ts.map +1 -1
- package/dist/structured-sets.js +5 -16
- package/dist/structured-sets.js.map +1 -1
- package/dist/subagents.d.ts +105 -3
- package/dist/subagents.d.ts.map +1 -1
- package/dist/subagents.js +224 -41
- package/dist/subagents.js.map +1 -1
- package/dist/template-engine.d.ts +85 -0
- package/dist/template-engine.d.ts.map +1 -0
- package/dist/template-engine.js +403 -0
- package/dist/template-engine.js.map +1 -0
- package/dist/theme-importers/auto-detect.d.ts +26 -0
- package/dist/theme-importers/auto-detect.d.ts.map +1 -0
- package/dist/theme-importers/auto-detect.js +42 -0
- package/dist/theme-importers/auto-detect.js.map +1 -0
- package/dist/theme-importers/css-comments.d.ts +12 -0
- package/dist/theme-importers/css-comments.d.ts.map +1 -0
- package/dist/theme-importers/css-comments.js +15 -0
- package/dist/theme-importers/css-comments.js.map +1 -0
- package/dist/theme-importers/dtcg.d.ts +46 -0
- package/dist/theme-importers/dtcg.d.ts.map +1 -0
- package/dist/theme-importers/dtcg.js +111 -0
- package/dist/theme-importers/dtcg.js.map +1 -0
- package/dist/theme-importers/loose.d.ts +3 -0
- package/dist/theme-importers/loose.d.ts.map +1 -0
- package/dist/theme-importers/loose.js +76 -0
- package/dist/theme-importers/loose.js.map +1 -0
- package/dist/theme-importers/shadcn.d.ts +24 -0
- package/dist/theme-importers/shadcn.d.ts.map +1 -0
- package/dist/theme-importers/shadcn.js +135 -0
- package/dist/theme-importers/shadcn.js.map +1 -0
- package/dist/theme-importers/style-dictionary.d.ts +17 -0
- package/dist/theme-importers/style-dictionary.d.ts.map +1 -0
- package/dist/theme-importers/style-dictionary.js +125 -0
- package/dist/theme-importers/style-dictionary.js.map +1 -0
- package/dist/theme-importers/tailwind.d.ts +3 -0
- package/dist/theme-importers/tailwind.d.ts.map +1 -0
- package/dist/theme-importers/tailwind.js +218 -0
- package/dist/theme-importers/tailwind.js.map +1 -0
- package/dist/theme-literal-binding.d.ts +37 -0
- package/dist/theme-literal-binding.d.ts.map +1 -0
- package/dist/theme-literal-binding.js +138 -0
- package/dist/theme-literal-binding.js.map +1 -0
- package/dist/theme-normalize.d.ts +31 -0
- package/dist/theme-normalize.d.ts.map +1 -0
- package/dist/theme-normalize.js +587 -0
- package/dist/theme-normalize.js.map +1 -0
- package/dist/theme-ramp.d.ts +55 -0
- package/dist/theme-ramp.d.ts.map +1 -0
- package/dist/theme-ramp.js +149 -0
- package/dist/theme-ramp.js.map +1 -0
- package/dist/theme-render.d.ts +105 -0
- package/dist/theme-render.d.ts.map +1 -0
- package/dist/theme-render.js +441 -0
- package/dist/theme-render.js.map +1 -0
- package/dist/themes-errors.d.ts +109 -0
- package/dist/themes-errors.d.ts.map +1 -0
- package/dist/themes-errors.js +170 -0
- package/dist/themes-errors.js.map +1 -0
- package/dist/themes.d.ts +343 -0
- package/dist/themes.d.ts.map +1 -0
- package/dist/themes.js +697 -0
- package/dist/themes.js.map +1 -0
- package/dist/version.d.ts +7 -4
- package/dist/version.d.ts.map +1 -1
- package/dist/version.js +6 -3
- package/dist/version.js.map +1 -1
- package/package.json +10 -2
- package/src/__tests__/redos-hardening.test.ts +160 -0
- package/src/ai-tools-add-module-modes.test.ts +106 -0
- package/src/ai-tools-position.test.ts +134 -0
- package/src/ai-tools.test.ts +81 -0
- package/src/ai-tools.ts +1179 -0
- package/src/auth-forms.ts +36 -0
- package/src/base-css.ts +30 -0
- package/src/build-page.test.ts +228 -0
- package/src/build-page.ts +319 -0
- package/src/cap-failures.ts +67 -0
- package/src/content.test.ts +170 -0
- package/src/content.ts +620 -0
- package/src/context.ts +43 -0
- package/src/css-gradient-scan.ts +88 -0
- package/src/css-var-scan.test.ts +96 -0
- package/src/css-var-scan.ts +144 -0
- package/src/derive-module-type.test.ts +80 -0
- package/src/design-manifest.ts +93 -0
- package/src/fonts.test.ts +157 -0
- package/src/fonts.ts +296 -0
- package/src/genesis-inventory.test.ts +86 -0
- package/src/genesis-inventory.ts +215 -0
- package/src/genesis-sanitize.test.ts +35 -0
- package/src/genesis.ts +87 -0
- package/src/i18n.test.ts +274 -0
- package/src/i18n.ts +269 -0
- package/src/index.test.ts +10 -0
- package/src/index.ts +59 -0
- package/src/interactions.ts +48 -0
- package/src/logger.ts +147 -0
- package/src/media.test.ts +160 -0
- package/src/media.ts +355 -0
- package/src/page-log.test.ts +163 -0
- package/src/page-log.ts +124 -0
- package/src/preview-compose.test.ts +602 -0
- package/src/preview-compose.ts +709 -0
- package/src/preview-scanner.test.ts +96 -0
- package/src/preview-scanner.ts +214 -0
- package/src/proposal-status.test.ts +69 -0
- package/src/proposal-status.ts +40 -0
- package/src/responsive-images.test.ts +104 -0
- package/src/responsive-images.ts +151 -0
- package/src/result.ts +29 -0
- package/src/safe-keys.ts +21 -0
- package/src/seo.test.ts +234 -0
- package/src/seo.ts +261 -0
- package/src/skills.ts +48 -0
- package/src/snapshots.test.ts +80 -0
- package/src/snapshots.ts +81 -0
- package/src/strip-cdata.test.ts +41 -0
- package/src/strip-cdata.ts +50 -0
- package/src/structured-sets.ts +180 -0
- package/src/subagents.test.ts +262 -0
- package/src/subagents.ts +432 -0
- package/src/template-engine.test.ts +379 -0
- package/src/template-engine.ts +520 -0
- package/src/theme-gradient.test.ts +92 -0
- package/src/theme-importers/__tests__/proto-pollution.test.ts +54 -0
- package/src/theme-importers/auto-detect.ts +84 -0
- package/src/theme-importers/css-comments.ts +15 -0
- package/src/theme-importers/dtcg.ts +106 -0
- package/src/theme-importers/loose.ts +76 -0
- package/src/theme-importers/shadcn.ts +133 -0
- package/src/theme-importers/style-dictionary.ts +125 -0
- package/src/theme-importers/tailwind.ts +217 -0
- package/src/theme-literal-binding.test.ts +71 -0
- package/src/theme-literal-binding.ts +159 -0
- package/src/theme-motion.test.ts +115 -0
- package/src/theme-normalize-envelope.test.ts +43 -0
- package/src/theme-normalize-gradient.test.ts +135 -0
- package/src/theme-normalize.ts +661 -0
- package/src/theme-ramp.ts +187 -0
- package/src/theme-render-sanitize.test.ts +45 -0
- package/src/theme-render.test.ts +119 -0
- package/src/theme-render.ts +487 -0
- package/src/theme-shadow.test.ts +56 -0
- package/src/themes-errors.ts +199 -0
- package/src/themes.ts +842 -0
- package/src/translation.test.ts +160 -0
- package/src/translation.ts +295 -0
- package/src/version.ts +66 -0
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* P6.6 closing pass — Zod schemas for the auth-side forms (setup,
|
|
5
|
+
* login). Lives in @caelo-cms/shared so the SvelteKit route's inline
|
|
6
|
+
* client-side validation helper (`bindZodForm`) and the server-side
|
|
7
|
+
* `users.create_first_owner` / `auth.login` handlers can both
|
|
8
|
+
* consume the same source-of-truth.
|
|
9
|
+
*
|
|
10
|
+
* Kept separate from `content.ts` because auth shape is independent
|
|
11
|
+
* of content-layer evolution; bumping a min-password requirement here
|
|
12
|
+
* shouldn't churn the page schemas.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { z } from "zod";
|
|
16
|
+
|
|
17
|
+
export const setupFormSchema = z
|
|
18
|
+
.object({
|
|
19
|
+
displayName: z.string().min(1, "required").max(128),
|
|
20
|
+
email: z.string().email("must be a valid email").max(254),
|
|
21
|
+
// Client-side floor mirrors the server strength policy's length rule
|
|
22
|
+
// (validatePasswordStrength / MIN_PASSWORD_LENGTH); the server also checks
|
|
23
|
+
// common-list, sequences and personal-info and returns the real reason.
|
|
24
|
+
password: z.string().min(10, "at least 10 characters").max(256),
|
|
25
|
+
})
|
|
26
|
+
.strict();
|
|
27
|
+
|
|
28
|
+
export const loginFormSchema = z
|
|
29
|
+
.object({
|
|
30
|
+
email: z.string().email("must be a valid email").max(254),
|
|
31
|
+
password: z.string().min(1, "required").max(256),
|
|
32
|
+
})
|
|
33
|
+
.strict();
|
|
34
|
+
|
|
35
|
+
export type SetupFormInput = z.infer<typeof setupFormSchema>;
|
|
36
|
+
export type LoginFormInput = z.infer<typeof loginFormSchema>;
|
package/src/base-css.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* issue #151 (re-scoped, epic #149) — the INVISIBLE technical baseline.
|
|
5
|
+
*
|
|
6
|
+
* Deliberately carries ZERO visible design opinion: no type scale, no
|
|
7
|
+
* colors, no element styling — every visible default is compiled per
|
|
8
|
+
* site from that site's chosen design (#164), because a global look
|
|
9
|
+
* would homogenize Caelo sites (the operator explicitly rejected that).
|
|
10
|
+
*
|
|
11
|
+
* What remains is the technical floor every hand-built page and every
|
|
12
|
+
* Genesis draft silently assumes, and whose absence produces the
|
|
13
|
+
* "subtly broken" rendering class from the epic review:
|
|
14
|
+
*
|
|
15
|
+
* - border-box sizing (the universal expectation since ~2013);
|
|
16
|
+
* - no default body margin (the 8px UA gutter breaks full-bleed
|
|
17
|
+
* heroes on every design);
|
|
18
|
+
* - media elements can't overflow their container (mobile-first
|
|
19
|
+
* drafts assume it);
|
|
20
|
+
* - form controls inherit the page's font instead of UA chrome.
|
|
21
|
+
*
|
|
22
|
+
* Injected as `<style data-source="base">` between the theme vars and
|
|
23
|
+
* the aggregated module CSS, so any module rule overrides it trivially.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
export const BASE_TECHNICAL_CSS =
|
|
27
|
+
"*,*::before,*::after{box-sizing:border-box}" +
|
|
28
|
+
"body{margin:0}" +
|
|
29
|
+
"img,picture,video,canvas,svg{display:block;max-width:100%}" +
|
|
30
|
+
"input,button,textarea,select{font:inherit}";
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Issue #299 — input-schema tests for the bulk build path. The contract
|
|
5
|
+
* under test: invalid entries fail LOUD with the failing element's index
|
|
6
|
+
* in the Zod path (so the dispatcher's error names `modules[i]` /
|
|
7
|
+
* `instances[i]`), and the mode gates (page: pageId XOR slug+title;
|
|
8
|
+
* module: moduleId XOR displayName+html) reject ambiguous calls.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { describe, expect, it } from "bun:test";
|
|
12
|
+
import {
|
|
13
|
+
buildPageContentSchema,
|
|
14
|
+
buildPageInputSchema,
|
|
15
|
+
contentInstancesCreateManySchema,
|
|
16
|
+
pageModuleContentSetManySchema,
|
|
17
|
+
} from "./build-page.js";
|
|
18
|
+
|
|
19
|
+
// Zod 4's .uuid() enforces RFC 4122 version/variant bits — use real
|
|
20
|
+
// v4-shaped constants, not sequential zero-padded strings.
|
|
21
|
+
const UUID = "11111111-1111-4111-8111-111111111111";
|
|
22
|
+
const UUID2 = "22222222-2222-4222-8222-222222222222";
|
|
23
|
+
|
|
24
|
+
const mintModule = {
|
|
25
|
+
blockName: "content",
|
|
26
|
+
displayName: "Hero",
|
|
27
|
+
html: "<section><h1>{{hero_title}}</h1></section>",
|
|
28
|
+
description: "Homepage hero",
|
|
29
|
+
kind: "hero",
|
|
30
|
+
fields: [{ name: "hero_title", kind: "text", label: "Hero title" }],
|
|
31
|
+
} as const;
|
|
32
|
+
|
|
33
|
+
describe("buildPageInputSchema — page target modes", () => {
|
|
34
|
+
it("accepts create mode (slug + title) with defaults applied downstream", () => {
|
|
35
|
+
const r = buildPageInputSchema.safeParse({
|
|
36
|
+
page: { slug: "pricing", title: "Pricing" },
|
|
37
|
+
modules: [mintModule],
|
|
38
|
+
});
|
|
39
|
+
expect(r.success).toBe(true);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("accepts existing mode (pageId only)", () => {
|
|
43
|
+
const r = buildPageInputSchema.safeParse({
|
|
44
|
+
page: { pageId: UUID },
|
|
45
|
+
modules: [{ blockName: "content", moduleId: UUID2 }],
|
|
46
|
+
});
|
|
47
|
+
expect(r.success).toBe(true);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("rejects pageId mixed with create keys, naming the offending keys", () => {
|
|
51
|
+
const r = buildPageInputSchema.safeParse({
|
|
52
|
+
page: { pageId: UUID, slug: "pricing", title: "Pricing" },
|
|
53
|
+
modules: [mintModule],
|
|
54
|
+
});
|
|
55
|
+
expect(r.success).toBe(false);
|
|
56
|
+
if (r.success) return;
|
|
57
|
+
const issue = r.error.issues[0]!;
|
|
58
|
+
expect(issue.message).toContain("pageId targets an EXISTING page");
|
|
59
|
+
expect(issue.message).toContain("slug");
|
|
60
|
+
expect(issue.message).toContain("title");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("rejects a page target with neither pageId nor slug+title", () => {
|
|
64
|
+
const r = buildPageInputSchema.safeParse({
|
|
65
|
+
page: { slug: "pricing" },
|
|
66
|
+
modules: [mintModule],
|
|
67
|
+
});
|
|
68
|
+
expect(r.success).toBe(false);
|
|
69
|
+
if (r.success) return;
|
|
70
|
+
expect(r.error.issues[0]!.message).toContain("`slug` + `title`");
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
describe("buildPageInputSchema — module entry modes name the failing index", () => {
|
|
75
|
+
it("TOLERATES a moduleId entry carrying authoring keys (incl. structural) — placement-only, handler surfaces the ignored-authoring info", () => {
|
|
76
|
+
// §1A/§11 — a placement that CAN succeed must never fail over an extra
|
|
77
|
+
// field. moduleId + html + displayName is a valid placement; the handler
|
|
78
|
+
// (not the schema) reports which carried fields were not applied.
|
|
79
|
+
const r = buildPageInputSchema.safeParse({
|
|
80
|
+
page: { slug: "pricing", title: "Pricing" },
|
|
81
|
+
modules: [
|
|
82
|
+
mintModule,
|
|
83
|
+
{ blockName: "content", moduleId: UUID2, html: "<p>x</p>", displayName: "Dup" },
|
|
84
|
+
],
|
|
85
|
+
});
|
|
86
|
+
expect(r.success).toBe(true);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("TOLERATES a moduleId entry carrying only metadata (displayName) — the handler ignores it", () => {
|
|
90
|
+
const r = buildPageInputSchema.safeParse({
|
|
91
|
+
page: { slug: "pricing", title: "Pricing" },
|
|
92
|
+
modules: [{ blockName: "content", moduleId: UUID2, displayName: "Site Header" }],
|
|
93
|
+
});
|
|
94
|
+
expect(r.success).toBe(true);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("rejects an entry with neither moduleId nor displayName+html, at its index", () => {
|
|
98
|
+
const r = buildPageInputSchema.safeParse({
|
|
99
|
+
page: { slug: "pricing", title: "Pricing" },
|
|
100
|
+
modules: [mintModule, mintModule, { blockName: "content" }],
|
|
101
|
+
});
|
|
102
|
+
expect(r.success).toBe(false);
|
|
103
|
+
if (r.success) return;
|
|
104
|
+
const issue = r.error.issues.find((i) => i.message.includes("Pass either `moduleId`"));
|
|
105
|
+
expect(issue).toBeDefined();
|
|
106
|
+
expect(issue!.path[0]).toBe("modules");
|
|
107
|
+
expect(issue!.path[1]).toBe(2);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("list-shaped field kinds are representable (CLAUDE.md §1A — no numbered scalars)", () => {
|
|
111
|
+
const r = buildPageInputSchema.safeParse({
|
|
112
|
+
page: { slug: "p", title: "P" },
|
|
113
|
+
modules: [
|
|
114
|
+
{
|
|
115
|
+
blockName: "content",
|
|
116
|
+
displayName: "Nav",
|
|
117
|
+
html: "<nav>{{#nav_links}}<a href='{{href}}'>{{label}}</a>{{/nav_links}}</nav>",
|
|
118
|
+
fields: [
|
|
119
|
+
{ name: "nav_links", kind: "link-list", label: "Nav links" },
|
|
120
|
+
{ name: "tags", kind: "text-list", label: "Tags", min: 0, max: 12 },
|
|
121
|
+
],
|
|
122
|
+
},
|
|
123
|
+
],
|
|
124
|
+
});
|
|
125
|
+
expect(r.success).toBe(true);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("caps the batch at 40 modules", () => {
|
|
129
|
+
const r = buildPageInputSchema.safeParse({
|
|
130
|
+
page: { slug: "p", title: "P" },
|
|
131
|
+
modules: Array.from({ length: 41 }, () => mintModule),
|
|
132
|
+
});
|
|
133
|
+
expect(r.success).toBe(false);
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
describe("buildPageContentSchema — the three sources", () => {
|
|
138
|
+
it("inline requires values", () => {
|
|
139
|
+
expect(buildPageContentSchema.safeParse({ source: "inline" }).success).toBe(false);
|
|
140
|
+
expect(
|
|
141
|
+
buildPageContentSchema.safeParse({ source: "inline", values: { hero_title: "x" } }).success,
|
|
142
|
+
).toBe(true);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("shared requires purpose and defaults syncMode to synced", () => {
|
|
146
|
+
const missing = buildPageContentSchema.safeParse({ source: "shared", values: {} });
|
|
147
|
+
expect(missing.success).toBe(false);
|
|
148
|
+
const r = buildPageContentSchema.safeParse({
|
|
149
|
+
source: "shared",
|
|
150
|
+
purpose: "Footer CTA shared across product pages",
|
|
151
|
+
values: { cta_label: "Go" },
|
|
152
|
+
});
|
|
153
|
+
expect(r.success).toBe(true);
|
|
154
|
+
if (!r.success || r.data.source !== "shared") return;
|
|
155
|
+
expect(r.data.syncMode).toBe("synced");
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("existing requires contentInstanceId and defaults syncMode to synced", () => {
|
|
159
|
+
const r = buildPageContentSchema.safeParse({ source: "existing", contentInstanceId: UUID });
|
|
160
|
+
expect(r.success).toBe(true);
|
|
161
|
+
if (!r.success || r.data.source !== "existing") return;
|
|
162
|
+
expect(r.data.syncMode).toBe("synced");
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("rejects cross-variant keys (purpose on inline)", () => {
|
|
166
|
+
const r = buildPageContentSchema.safeParse({
|
|
167
|
+
source: "inline",
|
|
168
|
+
values: {},
|
|
169
|
+
purpose: "nope",
|
|
170
|
+
});
|
|
171
|
+
expect(r.success).toBe(false);
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
describe("contentInstancesCreateManySchema", () => {
|
|
176
|
+
it("accepts a batch of singular-shaped items", () => {
|
|
177
|
+
const r = contentInstancesCreateManySchema.safeParse({
|
|
178
|
+
instances: [
|
|
179
|
+
{ moduleId: UUID, values: { a: 1 } },
|
|
180
|
+
{ moduleId: UUID2, purpose: "shared cta", values: {} },
|
|
181
|
+
],
|
|
182
|
+
});
|
|
183
|
+
expect(r.success).toBe(true);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("names the failing item index in the Zod path", () => {
|
|
187
|
+
const r = contentInstancesCreateManySchema.safeParse({
|
|
188
|
+
instances: [{ moduleId: UUID }, { moduleId: "not-a-uuid" }],
|
|
189
|
+
});
|
|
190
|
+
expect(r.success).toBe(false);
|
|
191
|
+
if (r.success) return;
|
|
192
|
+
const issue = r.error.issues[0]!;
|
|
193
|
+
expect(issue.path[0]).toBe("instances");
|
|
194
|
+
expect(issue.path[1]).toBe(1);
|
|
195
|
+
expect(issue.path[2]).toBe("moduleId");
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("rejects an empty batch", () => {
|
|
199
|
+
expect(contentInstancesCreateManySchema.safeParse({ instances: [] }).success).toBe(false);
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
describe("pageModuleContentSetManySchema", () => {
|
|
204
|
+
it("accepts multi-page batches", () => {
|
|
205
|
+
const r = pageModuleContentSetManySchema.safeParse({
|
|
206
|
+
items: [
|
|
207
|
+
{ pageId: UUID, blockName: "content", position: 0, contentValues: { t: "x" } },
|
|
208
|
+
{ pageId: UUID2, blockName: "content", position: 3, contentValues: {} },
|
|
209
|
+
],
|
|
210
|
+
});
|
|
211
|
+
expect(r.success).toBe(true);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it("names the failing item index + field in the Zod path", () => {
|
|
215
|
+
const r = pageModuleContentSetManySchema.safeParse({
|
|
216
|
+
items: [
|
|
217
|
+
{ pageId: UUID, blockName: "content", position: 0, contentValues: {} },
|
|
218
|
+
{ pageId: UUID, blockName: "content", position: -1, contentValues: {} },
|
|
219
|
+
],
|
|
220
|
+
});
|
|
221
|
+
expect(r.success).toBe(false);
|
|
222
|
+
if (r.success) return;
|
|
223
|
+
const issue = r.error.issues[0]!;
|
|
224
|
+
expect(issue.path[0]).toBe("items");
|
|
225
|
+
expect(issue.path[1]).toBe(1);
|
|
226
|
+
expect(issue.path[2]).toBe("position");
|
|
227
|
+
});
|
|
228
|
+
});
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Issue #299 — bulk build-path schemas (CLAUDE.md §11 bulk-first).
|
|
5
|
+
*
|
|
6
|
+
* Run #15 telemetry showed the AI assembling ~14 pages through ~100
|
|
7
|
+
* singular round-trips (36× add_module_to_page, 29× set_page_module_content,
|
|
8
|
+
* 9× create_content_instance, 8× create_page) at 110K–556K input tokens
|
|
9
|
+
* per call. These schemas back the three ops that collapse that chain:
|
|
10
|
+
*
|
|
11
|
+
* - `pages.build_page` — ONE call: page (new or existing)
|
|
12
|
+
* + ordered modules + content instances + placements, one transaction.
|
|
13
|
+
* - `content_instances.create_many` — batch instance minting.
|
|
14
|
+
* - `page_module_content.set_many` — batch content fill on existing
|
|
15
|
+
* placements.
|
|
16
|
+
*
|
|
17
|
+
* All three are all-or-nothing: any validation error aborts the whole
|
|
18
|
+
* call with a message naming the failing element index (and field where
|
|
19
|
+
* one is involved), so partial failure is impossible (§11).
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { z } from "zod";
|
|
23
|
+
import {
|
|
24
|
+
contentInstanceCreateSchema,
|
|
25
|
+
localeSchema,
|
|
26
|
+
MODULE_CSS_MAX,
|
|
27
|
+
MODULE_HTML_MAX,
|
|
28
|
+
MODULE_JS_MAX,
|
|
29
|
+
moduleFieldSchema,
|
|
30
|
+
moduleKindSchema,
|
|
31
|
+
pageStatusSchema,
|
|
32
|
+
slugSchema,
|
|
33
|
+
syncModeSchema,
|
|
34
|
+
} from "./content.js";
|
|
35
|
+
|
|
36
|
+
/** Content values keyed by the module's declared field names. */
|
|
37
|
+
const contentValuesSchema = z.record(z.string(), z.unknown());
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Per-module content payload — a discriminated union mirroring the three
|
|
41
|
+
* existing content paths so build_page adds NO new semantics, only batching:
|
|
42
|
+
*
|
|
43
|
+
* - `inline` → mint a private (unsynced) content_instance carrying
|
|
44
|
+
* `values`, exactly what `set_page_module_content` produces for a
|
|
45
|
+
* fresh placement.
|
|
46
|
+
* - `shared` → mint a reusable content_instance (purpose required —
|
|
47
|
+
* same decision-support contract as `create_content_instance`) and
|
|
48
|
+
* bind this placement to it, `synced` by default.
|
|
49
|
+
* - `existing` → bind an already-minted content_instance (reuse-first
|
|
50
|
+
* per CLAUDE.md §1A) — the batched form of `set_placement_content`.
|
|
51
|
+
*
|
|
52
|
+
* Omitting `content` mints an empty unsynced instance, matching what
|
|
53
|
+
* `pages.set_modules` does for a net-new placement today.
|
|
54
|
+
*/
|
|
55
|
+
export const buildPageContentSchema = z.discriminatedUnion("source", [
|
|
56
|
+
z
|
|
57
|
+
.object({
|
|
58
|
+
source: z.literal("inline"),
|
|
59
|
+
values: contentValuesSchema,
|
|
60
|
+
})
|
|
61
|
+
.strict(),
|
|
62
|
+
z
|
|
63
|
+
.object({
|
|
64
|
+
source: z.literal("shared"),
|
|
65
|
+
values: contentValuesSchema.default({}),
|
|
66
|
+
/** Why this row exists as a shared instance — see CLAUDE.md §1A. */
|
|
67
|
+
purpose: z.string().min(1).max(1000),
|
|
68
|
+
slug: slugSchema.optional(),
|
|
69
|
+
displayName: z.string().min(1).max(128).optional(),
|
|
70
|
+
syncMode: syncModeSchema.default("synced"),
|
|
71
|
+
})
|
|
72
|
+
.strict(),
|
|
73
|
+
z
|
|
74
|
+
.object({
|
|
75
|
+
source: z.literal("existing"),
|
|
76
|
+
contentInstanceId: z.string().uuid(),
|
|
77
|
+
syncMode: syncModeSchema.default("synced"),
|
|
78
|
+
})
|
|
79
|
+
.strict(),
|
|
80
|
+
]);
|
|
81
|
+
export type BuildPageContent = z.infer<typeof buildPageContentSchema>;
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* One module entry in a build_page call. Two modes per entry, identical
|
|
85
|
+
* to `add_module_to_page` (issue #159):
|
|
86
|
+
*
|
|
87
|
+
* - **Mint mode** — `displayName` + `html` (+ `fields`, `description`,
|
|
88
|
+
* `kind`, `type`, `css`, `js`). The op creates the module through the
|
|
89
|
+
* same `modules.create` path (extractor fallback, type derivation,
|
|
90
|
+
* snapshot) the singular tool uses.
|
|
91
|
+
* - **Place mode** — `moduleId` of an existing module. Placement-only:
|
|
92
|
+
* extra authoring keys are TOLERATED (§1A/§11 — a placement that can
|
|
93
|
+
* succeed must not fail over an extra field) but NOT applied; the op
|
|
94
|
+
* surfaces an info naming any carried field whose value differs from the
|
|
95
|
+
* module's stored one, pointing at edit_module (§2 — no silent drop).
|
|
96
|
+
*
|
|
97
|
+
* The element-level superRefine reports issues WITH the element's array
|
|
98
|
+
* index in the Zod path, so a mode error fails as `modules[3]: …`.
|
|
99
|
+
*/
|
|
100
|
+
export const buildPageModuleSchema = z
|
|
101
|
+
.object({
|
|
102
|
+
/**
|
|
103
|
+
* Template block to place into — must exist on the page's template.
|
|
104
|
+
* OMIT for a DETACHED entry: the module + its content_instance are
|
|
105
|
+
* created but NOT placed on the page — used for nested-only modules
|
|
106
|
+
* that later entries embed via `{"$ref": "<ref>"}` in a
|
|
107
|
+
* module / module-list field value. A detached entry requires `ref`.
|
|
108
|
+
*/
|
|
109
|
+
blockName: slugSchema.optional(),
|
|
110
|
+
/**
|
|
111
|
+
* Local handle other entries in the SAME call can reference: a
|
|
112
|
+
* module/module-list field value of `{"$ref": "<ref>"}` resolves to
|
|
113
|
+
* this entry's `{moduleId, contentInstanceId}`. Entries resolve in
|
|
114
|
+
* array order, so referenced entries must come FIRST.
|
|
115
|
+
*/
|
|
116
|
+
ref: z
|
|
117
|
+
.string()
|
|
118
|
+
.regex(/^[a-z][a-z0-9_-]{0,31}$/, "ref must be a short lowercase handle")
|
|
119
|
+
.optional(),
|
|
120
|
+
/**
|
|
121
|
+
* Place mode: an existing module from `## Modules` (UUID), or a
|
|
122
|
+
* module minted EARLIER IN THIS CALL via `{"$ref": "<handle>"}` —
|
|
123
|
+
* the second-placement case (e.g. three feature cards reusing one
|
|
124
|
+
* card module). Live-edit run A showed the model writing
|
|
125
|
+
* `moduleId: "$feat1"` unprompted; the union makes that intent
|
|
126
|
+
* expressible instead of a validation dead-end.
|
|
127
|
+
*/
|
|
128
|
+
moduleId: z
|
|
129
|
+
.union([
|
|
130
|
+
z
|
|
131
|
+
.string()
|
|
132
|
+
.uuid(
|
|
133
|
+
'moduleId must be a UUID from ## Modules — to re-place a module minted earlier in THIS call, pass {"$ref": "<its ref>"} instead',
|
|
134
|
+
),
|
|
135
|
+
z.object({ $ref: z.string() }).strict(),
|
|
136
|
+
])
|
|
137
|
+
.optional(),
|
|
138
|
+
/** Mint mode: authoring surface, mirrors add_module_to_page. */
|
|
139
|
+
displayName: z.string().min(1).max(128).optional(),
|
|
140
|
+
description: z.string().max(1000).optional(),
|
|
141
|
+
kind: moduleKindSchema.optional(),
|
|
142
|
+
type: slugSchema.optional(),
|
|
143
|
+
html: z.string().min(1).max(MODULE_HTML_MAX).optional(),
|
|
144
|
+
css: z.string().max(MODULE_CSS_MAX).optional(),
|
|
145
|
+
js: z.string().max(MODULE_JS_MAX).optional(),
|
|
146
|
+
fields: z.array(moduleFieldSchema).max(64).optional(),
|
|
147
|
+
/** issue #164 slice 2 — opt-in mechanical token binding (tool layer). */
|
|
148
|
+
bindThemeLiterals: z.boolean().optional(),
|
|
149
|
+
content: buildPageContentSchema.optional(),
|
|
150
|
+
})
|
|
151
|
+
.strict()
|
|
152
|
+
.superRefine((entry, ctx) => {
|
|
153
|
+
if (entry.blockName === undefined && entry.ref === undefined) {
|
|
154
|
+
ctx.addIssue({
|
|
155
|
+
code: z.ZodIssueCode.custom,
|
|
156
|
+
message:
|
|
157
|
+
"An entry without `blockName` is a DETACHED (nested-only) module and requires `ref` " +
|
|
158
|
+
'so a later entry can embed it via {"$ref": "<ref>"} — otherwise it would be unreachable. ' +
|
|
159
|
+
"Pass `blockName` to place it on the page instead.",
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
// `moduleId` is PLACEMENT-ONLY: a placement that CAN succeed must never
|
|
163
|
+
// fail over an extra field (§1A/§11), so we do NOT reject a moduleId entry
|
|
164
|
+
// that also carries authoring fields. But those fields are NOT applied here
|
|
165
|
+
// (build_page places; it does not re-author a shared module). To avoid a
|
|
166
|
+
// SILENT dropped change (§2), the HANDLER surfaces an INFO in the result
|
|
167
|
+
// naming any ignored authoring field whose value DIFFERS from the module's
|
|
168
|
+
// stored one, and points at edit_module. The schema can't do that check (no
|
|
169
|
+
// DB), so it just allows the entry through.
|
|
170
|
+
if (entry.moduleId !== undefined) return;
|
|
171
|
+
if (entry.displayName === undefined || entry.html === undefined) {
|
|
172
|
+
ctx.addIssue({
|
|
173
|
+
code: z.ZodIssueCode.custom,
|
|
174
|
+
message:
|
|
175
|
+
"Pass either `moduleId` (place an existing module from ## Modules) " +
|
|
176
|
+
"or `displayName` + `html` (mint a new module).",
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
export type BuildPageModule = z.infer<typeof buildPageModuleSchema>;
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Target page — exactly one of two shapes:
|
|
184
|
+
*
|
|
185
|
+
* - `{ pageId }` → build onto an existing page
|
|
186
|
+
* (modules are APPENDED to the named blocks in listed order).
|
|
187
|
+
* - `{ slug, title, … }` → create the page first (same
|
|
188
|
+
* resolution rules as `pages.create`: templateId optional when
|
|
189
|
+
* site_defaults carries a default).
|
|
190
|
+
*/
|
|
191
|
+
export const buildPageTargetSchema = z
|
|
192
|
+
.object({
|
|
193
|
+
pageId: z.string().uuid().optional(),
|
|
194
|
+
slug: slugSchema.optional(),
|
|
195
|
+
title: z.string().min(1).max(256).optional(),
|
|
196
|
+
name: z.string().min(1).max(256).optional(),
|
|
197
|
+
locale: localeSchema.optional(),
|
|
198
|
+
templateId: z.string().uuid().optional(),
|
|
199
|
+
status: pageStatusSchema.optional(),
|
|
200
|
+
/**
|
|
201
|
+
* Migration linkage (issue #278 flow): the `import_pages` row this
|
|
202
|
+
* page rebuilds. The op stamps `import_pages.accepted_page_id` with
|
|
203
|
+
* the built page's id so fidelity / inventory / media-migration reads
|
|
204
|
+
* resolve via that pointer regardless of slug. It ALSO makes the
|
|
205
|
+
* build idempotent — a second build_page for the same importPageId
|
|
206
|
+
* REBUILDS the already-linked page instead of minting a duplicate.
|
|
207
|
+
* Compatible with either target shape (create OR existing pageId).
|
|
208
|
+
*/
|
|
209
|
+
importPageId: z.string().uuid().optional(),
|
|
210
|
+
})
|
|
211
|
+
.strict()
|
|
212
|
+
.superRefine((page, ctx) => {
|
|
213
|
+
const createKeys = (
|
|
214
|
+
["slug", "title", "name", "locale", "templateId", "status"] as const
|
|
215
|
+
).filter((k) => page[k] !== undefined);
|
|
216
|
+
if (page.pageId !== undefined) {
|
|
217
|
+
if (createKeys.length > 0) {
|
|
218
|
+
ctx.addIssue({
|
|
219
|
+
code: z.ZodIssueCode.custom,
|
|
220
|
+
message:
|
|
221
|
+
`pageId targets an EXISTING page — drop ${createKeys.join(", ")}. ` +
|
|
222
|
+
"To create a new page instead, omit pageId and pass slug + title.",
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
if (page.slug === undefined || page.title === undefined) {
|
|
228
|
+
ctx.addIssue({
|
|
229
|
+
code: z.ZodIssueCode.custom,
|
|
230
|
+
message:
|
|
231
|
+
"Pass either `pageId` (build onto an existing page) or `slug` + `title` (create the page in the same call).",
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
});
|
|
235
|
+
export type BuildPageTarget = z.infer<typeof buildPageTargetSchema>;
|
|
236
|
+
|
|
237
|
+
/** `pages.build_page` op input. */
|
|
238
|
+
export const buildPageInputSchema = z
|
|
239
|
+
.object({
|
|
240
|
+
page: buildPageTargetSchema,
|
|
241
|
+
// min 0: build_page is the SINGLE page-creation tool. An empty modules
|
|
242
|
+
// array creates an intentionally empty page shell (the case that used to
|
|
243
|
+
// need the now-removed create_page); a populated array builds the whole
|
|
244
|
+
// page in one transaction (the common case).
|
|
245
|
+
modules: z.array(buildPageModuleSchema).max(40),
|
|
246
|
+
})
|
|
247
|
+
.strict()
|
|
248
|
+
.superRefine((input, ctx) => {
|
|
249
|
+
// `ref` handles must be unique — a duplicate would silently shadow
|
|
250
|
+
// the earlier entry when a later {"$ref"} resolves.
|
|
251
|
+
const seen = new Set<string>();
|
|
252
|
+
for (const [i, entry] of input.modules.entries()) {
|
|
253
|
+
if (entry.ref === undefined) continue;
|
|
254
|
+
if (seen.has(entry.ref)) {
|
|
255
|
+
ctx.addIssue({
|
|
256
|
+
code: z.ZodIssueCode.custom,
|
|
257
|
+
path: ["modules", i, "ref"],
|
|
258
|
+
message: `duplicate ref "${entry.ref}" — each ref handle must be unique within the call`,
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
seen.add(entry.ref);
|
|
262
|
+
}
|
|
263
|
+
});
|
|
264
|
+
export type BuildPageInput = z.infer<typeof buildPageInputSchema>;
|
|
265
|
+
|
|
266
|
+
/** `pages.build_page` op output — one row per placed module, in input order. */
|
|
267
|
+
export const buildPagePlacementResultSchema = z.object({
|
|
268
|
+
blockName: z.string(),
|
|
269
|
+
position: z.number().int().nonnegative(),
|
|
270
|
+
moduleId: z.string(),
|
|
271
|
+
contentInstanceId: z.string(),
|
|
272
|
+
syncMode: syncModeSchema,
|
|
273
|
+
/** True when this call minted the module (mint mode). */
|
|
274
|
+
minted: z.boolean(),
|
|
275
|
+
});
|
|
276
|
+
export type BuildPagePlacementResult = z.infer<typeof buildPagePlacementResultSchema>;
|
|
277
|
+
|
|
278
|
+
// ─── content_instances.create_many ───────────────────────────────────
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Batch form of `content_instances.create`. Each item is the exact
|
|
282
|
+
* singular input; the handler runs the singular path per item inside
|
|
283
|
+
* ONE transaction (all-or-nothing — a failure at index i rolls back
|
|
284
|
+
* items 0..i-1 and the error names `instances[i]`).
|
|
285
|
+
*/
|
|
286
|
+
export const contentInstancesCreateManySchema = z
|
|
287
|
+
.object({
|
|
288
|
+
instances: z.array(contentInstanceCreateSchema).min(1).max(100),
|
|
289
|
+
})
|
|
290
|
+
.strict();
|
|
291
|
+
export type ContentInstancesCreateManyInput = z.infer<typeof contentInstancesCreateManySchema>;
|
|
292
|
+
|
|
293
|
+
// ─── page_module_content.set_many ────────────────────────────────────
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Batch form of `page_module_content.set` — a content-only pass over N
|
|
297
|
+
* existing placements (typically one page's worth, but cross-page items
|
|
298
|
+
* are fine). All-or-nothing in one transaction; failures name `items[i]`
|
|
299
|
+
* plus the placement coordinates and, for value-shape errors, the field.
|
|
300
|
+
*/
|
|
301
|
+
export const pageModuleContentSetManySchema = z
|
|
302
|
+
.object({
|
|
303
|
+
items: z
|
|
304
|
+
.array(
|
|
305
|
+
z
|
|
306
|
+
.object({
|
|
307
|
+
pageId: z.string().uuid(),
|
|
308
|
+
blockName: z.string().min(1).max(80),
|
|
309
|
+
position: z.number().int().nonnegative(),
|
|
310
|
+
/** Keyed by module field name. Fully replaces existing values. */
|
|
311
|
+
contentValues: contentValuesSchema,
|
|
312
|
+
})
|
|
313
|
+
.strict(),
|
|
314
|
+
)
|
|
315
|
+
.min(1)
|
|
316
|
+
.max(100),
|
|
317
|
+
})
|
|
318
|
+
.strict();
|
|
319
|
+
export type PageModuleContentSetManyInput = z.infer<typeof pageModuleContentSetManySchema>;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* P16 hardening — track consecutive cap-lookup failures so a flaky DB
|
|
5
|
+
* query doesn't silently disable AI cost enforcement.
|
|
6
|
+
*
|
|
7
|
+
* The plugin-host's `ctx.ai.complete` and chat-runner's daily-budget
|
|
8
|
+
* pre-flight both consult `ai_calls.aggregate_per_plugin` /
|
|
9
|
+
* `ai_budgets.status` before dispatching to the provider. Today a thrown
|
|
10
|
+
* error there is swallowed (so a working plugin doesn't break on a DB
|
|
11
|
+
* hiccup). That's the right default — but unbounded silent retries =
|
|
12
|
+
* "no enforcement at all under sustained DB pressure".
|
|
13
|
+
*
|
|
14
|
+
* The counter trips a per-key fail-closed mode after `LOOKUP_FAIL_THRESHOLD`
|
|
15
|
+
* consecutive misses; the next call is blocked with a structured error,
|
|
16
|
+
* the trip is reset on the first successful lookup. A success after one
|
|
17
|
+
* miss does NOT trip — only a sustained failure does.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const LOOKUP_FAIL_THRESHOLD = 3;
|
|
21
|
+
const counters = new Map<string, number>();
|
|
22
|
+
|
|
23
|
+
/** Total fail-closed trips since process start — surfaced in /security/costs. */
|
|
24
|
+
let totalTrips = 0;
|
|
25
|
+
|
|
26
|
+
export function recordCapLookupSuccess(key: string): void {
|
|
27
|
+
counters.delete(key);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Record a cap-lookup failure. Returns `true` when the threshold has been
|
|
32
|
+
* crossed and the caller MUST fail closed instead of swallowing the error.
|
|
33
|
+
*/
|
|
34
|
+
export function recordCapLookupFailure(key: string): boolean {
|
|
35
|
+
const next = (counters.get(key) ?? 0) + 1;
|
|
36
|
+
counters.set(key, next);
|
|
37
|
+
if (next === LOOKUP_FAIL_THRESHOLD) {
|
|
38
|
+
totalTrips++;
|
|
39
|
+
console.warn(
|
|
40
|
+
JSON.stringify({
|
|
41
|
+
level: "warn",
|
|
42
|
+
msg: "ai-cap-lookup fail-closed tripped",
|
|
43
|
+
key,
|
|
44
|
+
consecutiveFailures: next,
|
|
45
|
+
}),
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
return next >= LOOKUP_FAIL_THRESHOLD;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Snapshot for the cost dashboard. */
|
|
52
|
+
export function getCapLookupHealth(): {
|
|
53
|
+
trippedKeys: Array<{ key: string; consecutiveFailures: number }>;
|
|
54
|
+
totalTrips: number;
|
|
55
|
+
} {
|
|
56
|
+
const tripped: Array<{ key: string; consecutiveFailures: number }> = [];
|
|
57
|
+
for (const [key, count] of counters.entries()) {
|
|
58
|
+
if (count >= LOOKUP_FAIL_THRESHOLD) tripped.push({ key, consecutiveFailures: count });
|
|
59
|
+
}
|
|
60
|
+
return { trippedKeys: tripped, totalTrips };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Test-only reset. */
|
|
64
|
+
export function resetCapLookupCounters(): void {
|
|
65
|
+
counters.clear();
|
|
66
|
+
totalTrips = 0;
|
|
67
|
+
}
|