feat: close application architecture gaps
Quality / quality (ubuntu-latest) (push) Failing after 9m56s
Quality / quality (windows-latest) (push) Canceled after 0s

This commit is contained in:
2026-08-23 11:45:57 +05:30
parent d87b197224
commit 2e12656060
20 changed files with 548 additions and 23 deletions
+11 -5
View File
@@ -292,7 +292,7 @@
}, },
"packages/cli": { "packages/cli": {
"name": "@wrnexus/cli", "name": "@wrnexus/cli",
"version": "0.8.55", "version": "0.8.57",
"bin": { "bin": {
"wrnexus": "src/index.ts", "wrnexus": "src/index.ts",
}, },
@@ -320,7 +320,7 @@
}, },
"packages/compiler": { "packages/compiler": {
"name": "@wrnexus/compiler", "name": "@wrnexus/compiler",
"version": "0.8.17", "version": "0.8.19",
"dependencies": { "dependencies": {
"@wrnexus/csr": "workspace:*", "@wrnexus/csr": "workspace:*",
"@wrnexus/store": "workspace:*", "@wrnexus/store": "workspace:*",
@@ -342,7 +342,7 @@
}, },
"packages/csr": { "packages/csr": {
"name": "@wrnexus/csr", "name": "@wrnexus/csr",
"version": "0.8.29", "version": "0.8.31",
"dependencies": { "dependencies": {
"@wrnexus/core": "workspace:*", "@wrnexus/core": "workspace:*",
}, },
@@ -357,7 +357,7 @@
}, },
"packages/dev-server": { "packages/dev-server": {
"name": "@wrnexus/dev-server", "name": "@wrnexus/dev-server",
"version": "0.8.48", "version": "0.8.50",
"dependencies": { "dependencies": {
"@wrnexus/authz": "workspace:*", "@wrnexus/authz": "workspace:*",
"@wrnexus/cache": "workspace:*", "@wrnexus/cache": "workspace:*",
@@ -499,6 +499,10 @@
"wrnexus-mcp": "src/stdio.ts", "wrnexus-mcp": "src/stdio.ts",
}, },
}, },
"packages/metering": {
"name": "@wrnexus/metering",
"version": "0.8.2",
},
"packages/mobile": { "packages/mobile": {
"name": "@wrnexus/mobile", "name": "@wrnexus/mobile",
"version": "0.8.8", "version": "0.8.8",
@@ -666,7 +670,7 @@
}, },
"packages/typecheck": { "packages/typecheck": {
"name": "@wrnexus/typecheck", "name": "@wrnexus/typecheck",
"version": "0.8.11", "version": "0.8.13",
"dependencies": { "dependencies": {
"@wrnexus/syntax": "workspace:*", "@wrnexus/syntax": "workspace:*",
"typescript": "^6.0.3", "typescript": "^6.0.3",
@@ -1052,6 +1056,8 @@
"@wrnexus/mcp": ["@wrnexus/mcp@workspace:packages/mcp"], "@wrnexus/mcp": ["@wrnexus/mcp@workspace:packages/mcp"],
"@wrnexus/metering": ["@wrnexus/metering@workspace:packages/metering"],
"@wrnexus/mobile": ["@wrnexus/mobile@workspace:packages/mobile"], "@wrnexus/mobile": ["@wrnexus/mobile@workspace:packages/mobile"],
"@wrnexus/native": ["@wrnexus/native@workspace:packages/native"], "@wrnexus/native": ["@wrnexus/native@workspace:packages/native"],
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@wrnexus/cli", "name": "@wrnexus/cli",
"version": "0.8.55", "version": "0.8.57",
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"exports": { "exports": {
+2
View File
@@ -2,6 +2,7 @@ import { readFileSync } from "node:fs";
import { resolve } from "node:path"; import { resolve } from "node:path";
import { runBuild } from "./build.ts"; import { runBuild } from "./build.ts";
import { runTypecheck } from "./types.ts"; import { runTypecheck } from "./types.ts";
import { assertReachability } from "./reachability.ts";
async function runScript(root: string, name: string): Promise<void> { async function runScript(root: string, name: string): Promise<void> {
const child = Bun.spawn(["bun", "run", name], { const child = Bun.spawn(["bun", "run", name], {
@@ -20,6 +21,7 @@ export async function runCheck(appRoot: string): Promise<void> {
const manifest = JSON.parse(readFileSync(resolve(root, "package.json"), "utf8")) as { const manifest = JSON.parse(readFileSync(resolve(root, "package.json"), "utf8")) as {
scripts?: Record<string, string>; scripts?: Record<string, string>;
}; };
assertReachability(root);
await runBuild(root); await runBuild(root);
if (manifest.scripts?.typecheck) await runScript(root, "typecheck"); if (manifest.scripts?.typecheck) await runScript(root, "typecheck");
else if (!(await runTypecheck(root))) throw new Error("WRN-CHECK: application typecheck failed"); else if (!(await runTypecheck(root))) throw new Error("WRN-CHECK: application typecheck failed");
+80
View File
@@ -0,0 +1,80 @@
import { existsSync, readFileSync, readdirSync } from "node:fs";
import { dirname, extname, join, relative, resolve } from "node:path";
export interface ReachabilityIssue {
code: "WRN-REACH-MIDDLEWARE" | "WRN-REACH-QUEUE" | "WRN-REACH-WORKER";
file: string;
message: string;
}
function sourceFiles(directory: string): string[] {
if (!existsSync(directory)) return [];
const output: string[] = [];
for (const entry of readdirSync(directory, { withFileTypes: true })) {
const path = join(directory, entry.name);
if (entry.isDirectory()) output.push(...sourceFiles(path));
else if ([".ts", ".js", ".mts", ".mjs"].includes(extname(entry.name))) output.push(path);
}
return output;
}
/** Static registration audit for convention-discovered application modules. */
export function checkReachability(appRoot: string): ReachabilityIssue[] {
const root = resolve(appRoot);
const app = join(root, "app");
const all = sourceFiles(app).map((file) => ({ file, source: readFileSync(file, "utf8") }));
const issues: ReachabilityIssue[] = [];
for (const entry of all) {
const path = relative(root, entry.file).replace(/\\/g, "/");
if (path.startsWith("app/middleware/") && !/\bexport\s+default\b/.test(entry.source)) {
issues.push({
code: "WRN-REACH-MIDDLEWARE",
file: path,
message: "Middleware discovery requires a default export.",
});
}
if (
path.startsWith("app/queues/") &&
/\bdefineQueue\s*\(/.test(entry.source) &&
!/\bexport\s+default\b/.test(entry.source)
) {
issues.push({
code: "WRN-REACH-QUEUE",
file: path,
message: "Queue discovery requires the defined queue to be exported as default.",
});
}
if (
/\bdefineWorker\s*(?:<[^>]+>)?\s*\(/.test(entry.source) &&
!/\brunWorker\s*\(/.test(entry.source)
) {
const stem = entry.file.replace(/\.[^.]+$/, "");
const imported = all.some(
(candidate) =>
candidate.file !== entry.file &&
candidate.source
.replace(/\\/g, "/")
.includes(relative(dirname(candidate.file), stem).replace(/\\/g, "/")),
);
if (!imported) {
issues.push({
code: "WRN-REACH-WORKER",
file: path,
message: "Worker is defined but no application module imports it or calls runWorker().",
});
}
}
}
return issues;
}
export function assertReachability(appRoot: string): void {
const issues = checkReachability(appRoot);
if (!issues.length) return;
throw new Error(
`WRN-REACHABILITY: unreachable application exports\n${issues
.map((issue) => `- ${issue.code} ${issue.file}: ${issue.message}`)
.join("\n")}`,
);
}
+31
View File
@@ -0,0 +1,31 @@
import { expect, test } from "bun:test";
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { checkReachability } from "../src/reachability.ts";
test("reports convention modules and workers that cannot be reached", () => {
const root = mkdtempSync(join(tmpdir(), "wrn-reach-"));
mkdirSync(join(root, "app", "middleware"), { recursive: true });
mkdirSync(join(root, "app", "queues"), { recursive: true });
writeFileSync(join(root, "app", "middleware", "audit.ts"), "export const audit = () => {};\n");
writeFileSync(
join(root, "app", "queues", "mail.ts"),
"export const mail = defineQueue({}); export const worker = defineWorker({});\n",
);
expect(
checkReachability(root)
.map((issue) => issue.code)
.sort(),
).toEqual(["WRN-REACH-MIDDLEWARE", "WRN-REACH-QUEUE", "WRN-REACH-WORKER"]);
});
test("accepts discovered middleware and queues", () => {
const root = mkdtempSync(join(tmpdir(), "wrn-reach-"));
mkdirSync(join(root, "app", "middleware"), { recursive: true });
mkdirSync(join(root, "app", "queues"), { recursive: true });
writeFileSync(join(root, "app", "middleware", "audit.ts"), "export default () => {};\n");
writeFileSync(join(root, "app", "queues", "mail.ts"), "export default defineQueue({});\n");
expect(checkReachability(root)).toEqual([]);
});
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@wrnexus/compiler", "name": "@wrnexus/compiler",
"version": "0.8.17", "version": "0.8.19",
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"exports": { "exports": {
+44 -11
View File
@@ -283,6 +283,32 @@ function componentEventAttribute(name: string): string {
return `data-wrn-out-${name}`; return `data-wrn-out-${name}`;
} }
/** Expand bind:value/bind:checked into a reactive prop plus assignment handler. */
function expandBindings(attrs: Attr[]): Attr[] {
const output: Attr[] = [];
for (const attr of attrs) {
if (attr.event || !attr.name.startsWith("bind:")) {
output.push(attr);
continue;
}
const target = attr.name.slice("bind:".length);
if (target !== "value" && target !== "checked") {
throw new Error(`Unknown .wrn binding '${attr.name}'. Use bind:value or bind:checked.`);
}
const expression = wholeAttributeExpression(attr.value) ?? attr.value.trim();
if (!/^[A-Za-z_$][\w$]*$/.test(expression)) {
throw new Error(`${attr.name} requires a writable state name, received '${expression}'.`);
}
output.push({ name: target, value: `{${expression}}`, event: false });
output.push({
name: target === "checked" ? "change" : "input",
value: `${expression} = payload.${target}`,
event: true,
});
}
return output;
}
function reactiveAttrValue(raw: string, reactive: PageReactive): string | null { function reactiveAttrValue(raw: string, reactive: PageReactive): string | null {
let found = false; let found = false;
const value = raw.replace(/\{([^{}]+)\}/g, (whole, inner: string) => { const value = raw.replace(/\{([^{}]+)\}/g, (whole, inner: string) => {
@@ -305,6 +331,7 @@ function renderAttrs(
reactive: PageReactive | null = null, reactive: PageReactive | null = null,
dynamicExpressions?: string[], dynamicExpressions?: string[],
): string { ): string {
attrs = expandBindings(attrs);
let bindIndex = 0; let bindIndex = 0;
const rendered = attrs const rendered = attrs
.map((attr) => { .map((attr) => {
@@ -463,7 +490,7 @@ function renderLoopBody(node: ViewNode, locals: string[] = []): string {
const componentTag = isComponentTag(node.tag); const componentTag = isComponentTag(node.tag);
const attrs = node.attrs const attrs = expandBindings(node.attrs)
.filter((attr) => attr.name !== "data-component") .filter((attr) => attr.name !== "data-component")
.map((attr) => { .map((attr) => {
const name = attr.event const name = attr.event
@@ -805,7 +832,7 @@ function renderPageComponentInvocation(
const island = islandMarkerFor(node); const island = islandMarkerFor(node);
if (island) return island; if (island) return island;
const attrs = node.attrs const attrs = expandBindings(node.attrs)
.filter((attr) => attr.name !== "data-component") .filter((attr) => attr.name !== "data-component")
.map((attr) => renderPageComponentAttr(attr, loops)) .map((attr) => renderPageComponentAttr(attr, loops))
.join(""); .join("");
@@ -827,7 +854,7 @@ function renderNestedComponentInvocation(
let bindIndex = 0; let bindIndex = 0;
const attrs = node.attrs const attrs = expandBindings(node.attrs)
.filter((attr) => attr.name !== "data-component") .filter((attr) => attr.name !== "data-component")
.map((attr) => { .map((attr) => {
const spread = /^\{\.\.\.([A-Za-z_$][\w$]*)\}$/.exec(attr.name); const spread = /^\{\.\.\.([A-Za-z_$][\w$]*)\}$/.exec(attr.name);
@@ -2251,7 +2278,7 @@ function renderClientControlTemplate(nodes: ViewNode[]): string {
const componentTag = isComponentTag(node.tag); const componentTag = isComponentTag(node.tag);
let bindIndex = 0; let bindIndex = 0;
const attrs = node.attrs const attrs = expandBindings(node.attrs)
.map((attribute) => { .map((attribute) => {
const name = attribute.event const name = attribute.event
? componentTag ? componentTag
@@ -2293,24 +2320,27 @@ function encodeClientControl(value: unknown): string {
function renderComponentIfNode(node: IfNode, ctx: CompCtx): string { function renderComponentIfNode(node: IfNode, ctx: CompCtx): string {
let expression = "``"; let expression = "``";
const clientBranches: string[] = [];
for (let index = node.branches.length - 1; index >= 0; index--) { for (let index = node.branches.length - 1; index >= 0; index--) {
const branch = node.branches[index]!; const branch = node.branches[index]!;
const body = branch.body.map((child) => renderComponentNode(child, ctx)).join(""); const body = branch.body.map((child) => renderComponentNode(child, ctx)).join("");
const bodyExpression = "`" + body + "`"; const bodyExpression = "`" + body + "`";
clientBranches.unshift(
`{ cond: ${branch.cond === null ? "null" : JSON.stringify(branch.cond)}, body: ${bodyExpression} }`,
);
expression = expression =
branch.cond === null branch.cond === null
? bodyExpression ? bodyExpression
: `(${ctx.resolveExpr(branch.cond)}) ? ${bodyExpression} : ${expression}`; : `(${ctx.resolveExpr(branch.cond)}) ? ${bodyExpression} : ${expression}`;
} }
const definition = encodeClientControl( // Render all branches on the server into an inert, encoded definition.
node.branches.map((branch) => ({ // A branch first selected in the browser therefore contains the real nested
cond: branch.cond, // component HTML and scope metadata, not a data-component placeholder.
body: renderClientControlTemplate(branch.body), const definition = `\${__wrnexusEncodeControl([${clientBranches.join(",")}])}`;
})),
);
return `<template data-wrn-if="${definition}"></template>${"${" + expression + "}"}<template data-wrn-control-end></template>`; return `<template data-wrn-if="${definition}"></template>${"${" + expression + "}"}<template data-wrn-control-end></template>`;
} }
@@ -2489,7 +2519,7 @@ function renderComponentNode(node: ViewNode, ctx: CompCtx): string {
(attribute) => attribute.name === "data-component", (attribute) => attribute.name === "data-component",
); );
const attrs = node.attrs const attrs = expandBindings(node.attrs)
.filter((a) => a.name !== "class" && !a.name.startsWith("class:")) .filter((a) => a.name !== "class" && !a.name.startsWith("class:"))
.map((a) => { .map((a) => {
const spread = /^\{\.\.\.([A-Za-z_$][\w$]*)\}$/.exec(a.name); const spread = /^\{\.\.\.([A-Za-z_$][\w$]*)\}$/.exec(a.name);
@@ -3036,6 +3066,9 @@ function __wrnRaw(v: unknown): string {
} }
if (needsScope) { if (needsScope) {
out.push(`function __wrnexusEncodeControl(value: unknown): string {
return __WrnexusBuffer.from(JSON.stringify(value), "utf8").toString("base64");
}`);
out.push(`function __wrnexusSerializeScopeValue(value: unknown): string { out.push(`function __wrnexusSerializeScopeValue(value: unknown): string {
if (value === undefined) { if (value === undefined) {
return "undefined"; return "undefined";
@@ -211,6 +211,10 @@ function __wrnRaw(v: unknown): string {
return String(v == null ? "" : v); return String(v == null ? "" : v);
} }
function __wrnexusEncodeControl(value: unknown): string {
return __WrnexusBuffer.from(JSON.stringify(value), "utf8").toString("base64");
}
function __wrnexusSerializeScopeValue(value: unknown): string { function __wrnexusSerializeScopeValue(value: unknown): string {
if (value === undefined) { if (value === undefined) {
return "undefined"; return "undefined";
+19
View File
@@ -1070,6 +1070,25 @@ component Banner {
expect(output).toContain("visible"); expect(output).toContain("visible");
expect(output).toContain("Visible"); expect(output).toContain("Visible");
expect(output).toContain("Hidden"); expect(output).toContain("Hidden");
expect(output).toContain("__wrnexusEncodeControl");
});
test("bind directives compile to reactive values and assignment handlers", () => {
const output = generate(
parse(`page FormPage {
state name = ""
state active = false
view {
<Input bind:value={name} />
<input bind:value={name} />
<Switch bind:checked={active} />
}
}`),
);
expect(output).not.toContain("bind:value");
expect(output).not.toContain("bind:checked");
expect(output).toContain("name = payload.value");
expect(output).toContain("active = payload.checked");
}); });
test("if and each blocks emit browser control metadata while preserving SSR", () => { test("if and each blocks emit browser control metadata while preserving SSR", () => {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@wrnexus/csr", "name": "@wrnexus/csr",
"version": "0.8.29", "version": "0.8.31",
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"exports": { "exports": {
+24
View File
@@ -1173,6 +1173,7 @@ export const REACTIVE_RUNTIME = String.raw`
* lacks one of these does not break the rest. * lacks one of these does not break the rest.
*/ */
if (name === "toast") return toastApi; if (name === "toast") return toastApi;
if (name === "useFetch") return useFetch;
if (boundWindowGlobals[name] && typeof window[name] === "function") { if (boundWindowGlobals[name] && typeof window[name] === "function") {
return window[name].bind(window); return window[name].bind(window);
} }
@@ -4350,6 +4351,29 @@ export const REACTIVE_RUNTIME = String.raw`
}); });
} }
/**
* Public client transport for dynamic API paths.
*
* The apis block remains the typed, declarative choice for fixed routes. This
* helper covers paths assembled at runtime without exposing a private
* framework global to application code.
*/
function useFetch(path, methodOrOptions, input) {
var method = "GET";
var values = input;
if (typeof methodOrOptions === "string") {
method = methodOrOptions;
} else if (methodOrOptions && typeof methodOrOptions === "object") {
method = methodOrOptions.method || "GET";
values = method === "GET" || method === "HEAD"
? (methodOrOptions.query || methodOrOptions.params)
: (methodOrOptions.body === undefined ? methodOrOptions.data : methodOrOptions.body);
}
return wrnexusCallApi(path, method, values);
}
window.useFetch = useFetch;
// Compatibility for applications compiled before the public helper landed.
window.__wrnexusCallApi = wrnexusCallApi; window.__wrnexusCallApi = wrnexusCallApi;
function dispatchComponentEvent(root, name, detail) { function dispatchComponentEvent(root, name, detail) {
+15
View File
@@ -64,6 +64,21 @@ test("POST sends a JSON body", async () => {
); );
}); });
test("useFetch is the public dynamic-path transport", async () => {
const { calls, win } = harness({ status: 200, payload: { ok: true } });
const useFetch = (
win as unknown as {
useFetch: (path: string, options: { method: string; body: unknown }) => Promise<unknown>;
}
).useFetch;
await expect(
useFetch("/api/users/42/test", { method: "POST", body: { dryRun: true } }),
).resolves.toEqual({ ok: true });
expect(calls[0]!.url).toBe("/api/users/42/test");
expect(calls[0]!.init.body).toBe(JSON.stringify({ dryRun: true }));
});
test("a non-GET request carries the CSRF token from the cookie", async () => { test("a non-GET request carries the CSRF token from the cookie", async () => {
const { callApi, calls, win } = harness({ status: 200, payload: {} }); const { callApi, calls, win } = harness({ status: 200, payload: {} });
win.document.cookie = "wrn-csrf=token-123"; win.document.cookie = "wrn-csrf=token-123";
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@wrnexus/dev-server", "name": "@wrnexus/dev-server",
"version": "0.8.48", "version": "0.8.50",
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"exports": { "exports": {
+29
View File
@@ -1,4 +1,5 @@
import { brotliCompressSync, constants as zlibConstants } from "node:zlib"; import { brotliCompressSync, constants as zlibConstants } from "node:zlib";
import { Buffer } from "node:buffer";
/** /**
* Shared request runtime used by BOTH the dev server and the production server. * Shared request runtime used by BOTH the dev server and the production server.
* *
@@ -1667,6 +1668,34 @@ export function createHandlers(deps: RuntimeDeps): Handlers {
language?: string, language?: string,
depth = 0, depth = 0,
): Promise<string> { ): Promise<string> {
// Control definitions are inert base64 JSON. Resolve component mounts in
// their branch templates too, otherwise a component first selected by
// client state would materialize only an empty data-component placeholder.
const controlPattern = /data-wrn-(?:if|each)="([A-Za-z0-9+/=]+)"/g;
const replacements: Array<{ from: string; to: string }> = [];
for (const match of body.matchAll(controlPattern)) {
try {
const definition = JSON.parse(Buffer.from(match[1]!, "base64").toString("utf8")) as
Array<{ body?: string }> | { body?: string; empty?: string };
const entries = Array.isArray(definition) ? definition : [definition];
for (const entry of entries) {
if (typeof entry.body === "string" && entry.body.includes("data-component="))
entry.body = await renderComponents(entry.body, translate, language, depth + 1);
if (
"empty" in entry &&
typeof entry.empty === "string" &&
entry.empty.includes("data-component=")
)
entry.empty = await renderComponents(entry.empty, translate, language, depth + 1);
}
const encoded = Buffer.from(JSON.stringify(definition), "utf8").toString("base64");
replacements.push({ from: match[0], to: match[0].replace(match[1]!, encoded) });
} catch {
// The client reports malformed control metadata with source context.
}
}
for (const replacement of replacements) body = body.replace(replacement.from, replacement.to);
if (depth > 15 || router.components.length === 0 || !body.includes("data-component=")) { if (depth > 15 || router.components.length === 0 || !body.includes("data-component=")) {
return body; return body;
} }
+10
View File
@@ -0,0 +1,10 @@
{
"name": "@wrnexus/metering",
"version": "0.8.2",
"private": true,
"type": "module",
"main": "src/index.ts",
"exports": {
".": "./src/index.ts"
}
}
+144
View File
@@ -0,0 +1,144 @@
export interface EntitlementPlan {
code: string;
name: string;
features: readonly string[];
allowance?: number;
price?: number;
[key: string]: unknown;
}
export interface EntitlementsOptions<TPlan extends EntitlementPlan> {
plans: () => Promise<readonly TPlan[]> | readonly TPlan[];
subscriptionFor: (subjectId: string) => Promise<string | null | undefined>;
fallback: string;
}
/** Resolve plans, features, and allowances from one server-owned catalog. */
export function defineEntitlements<TPlan extends EntitlementPlan>(
options: EntitlementsOptions<TPlan>,
) {
const all = async () => [...(await options.plans())];
const planFor = async (subjectId: string): Promise<TPlan> => {
const plans = await all();
const requested = await options.subscriptionFor(subjectId);
const plan =
plans.find((entry) => entry.code === requested) ??
plans.find((entry) => entry.code === options.fallback);
if (!plan) throw new Error(`WRN-ENTITLEMENTS: fallback plan '${options.fallback}' is missing`);
return plan;
};
return {
plans: all,
planFor,
async enabled(subjectId: string, feature: string) {
return (await planFor(subjectId)).features.includes(feature);
},
async allowance(subjectId: string) {
return Number((await planFor(subjectId)).allowance ?? 0);
},
};
}
export interface MeterPack {
code: string;
units: number;
[key: string]: unknown;
}
/** Prototype-safe, immutable pack lookup; clients select a code, never an amount. */
export function definePacks<TPack extends MeterPack>(entries: readonly TPack[]) {
const packs = new Map<string, Readonly<TPack>>();
for (const entry of entries) {
if (!entry.code.trim()) throw new TypeError("meter pack code cannot be empty");
if (!Number.isSafeInteger(entry.units) || entry.units < 1)
throw new RangeError(`meter pack '${entry.code}' units must be a positive safe integer`);
if (packs.has(entry.code)) throw new TypeError(`duplicate meter pack '${entry.code}'`);
packs.set(entry.code, Object.freeze({ ...entry }));
}
return Object.freeze({
get: (code: string) => packs.get(code),
has: (code: string) => packs.has(code),
list: () => [...packs.values()],
});
}
export type MeterResult = { ok: true } | { ok: false; reason: string; fault?: true };
export type MeterKind = "grant" | "purchase" | "refund" | "adjust";
export interface MeterStore {
balance(subjectId: string): Promise<number>;
reserve(subjectId: string, units: number, reason: string, reference: string): Promise<boolean>;
write(
subjectId: string,
units: number,
kind: MeterKind,
reason: string,
reference: string,
): Promise<void>;
}
export interface MeterOptions {
store: MeterStore;
label?: string;
onFault?: (error: unknown, operation: string) => void;
}
/** Non-throwing metering facade with validated units and explicit storage faults. */
export function defineMeter(options: MeterOptions) {
const label = options.label ?? "units";
const valid = (units: number): MeterResult | null =>
Number.isSafeInteger(units) && units > 0
? null
: { ok: false, reason: `${label} must be a positive whole number` };
const fault = (error: unknown, operation: string): MeterResult => {
options.onFault?.(error, operation);
return {
ok: false,
reason: error instanceof Error ? error.message : String(error),
fault: true,
};
};
const add = async (
kind: MeterKind,
subjectId: string,
units: number,
reason: string,
reference = "",
): Promise<MeterResult> => {
const invalid = valid(units);
if (invalid) return invalid;
try {
await options.store.write(subjectId, units, kind, reason, reference);
return { ok: true };
} catch (error) {
return fault(error, kind);
}
};
return {
balance: options.store.balance,
async reserve(
subjectId: string,
units: number,
reason: string,
reference = "",
): Promise<MeterResult> {
const invalid = valid(units);
if (invalid) return invalid;
try {
return (await options.store.reserve(subjectId, units, reason, reference))
? { ok: true }
: { ok: false, reason: "insufficient balance" };
} catch (error) {
return fault(error, "reserve");
}
},
grant: (subjectId: string, units: number, reason: string, reference = "") =>
add("grant", subjectId, units, reason, reference),
purchase: (subjectId: string, units: number, reason: string, reference = "") =>
add("purchase", subjectId, units, reason, reference),
refund: (subjectId: string, units: number, reason: string, reference = "") =>
add("refund", subjectId, units, reason, reference),
adjust: (subjectId: string, units: number, reason: string, reference = "") =>
add("adjust", subjectId, units, reason, reference),
};
}
+42
View File
@@ -0,0 +1,42 @@
import { expect, test } from "bun:test";
import { defineEntitlements, defineMeter, definePacks } from "../src/index.ts";
test("resolves fallback entitlements and features", async () => {
const entitlements = defineEntitlements({
plans: () => [
{ code: "free", name: "Free", features: [], allowance: 10 },
{ code: "pro", name: "Pro", features: ["schedule"], allowance: 100 },
],
subscriptionFor: async () => "pro",
fallback: "free",
});
expect(await entitlements.enabled("u1", "schedule")).toBe(true);
expect(await entitlements.allowance("u1")).toBe(100);
});
test("packs are immutable and selected only by code", () => {
const packs = definePacks([{ code: "small", units: 100, label: "Small" }]);
expect(packs.get("constructor")).toBeUndefined();
expect(packs.get("small")?.units).toBe(100);
});
test("meter distinguishes refusals from storage faults", async () => {
const meter = defineMeter({
store: {
balance: async () => 5,
reserve: async (_subject, units) => units <= 5,
write: async () => {
throw new Error("offline");
},
},
});
expect(await meter.reserve("u1", 6, "send")).toEqual({
ok: false,
reason: "insufficient balance",
});
expect(await meter.grant("u1", 5, "plan")).toEqual({
ok: false,
reason: "offline",
fault: true,
});
});
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@wrnexus/typecheck", "name": "@wrnexus/typecheck",
"version": "0.8.11", "version": "0.8.13",
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"exports": { "exports": {
+61 -2
View File
@@ -307,6 +307,9 @@ export function virtualTypeScriptModule(
if (ast.dataApis.length) append(`declare const api: { ${apiType} };`); if (ast.dataApis.length) append(`declare const api: { ${apiType} };`);
append(`declare const props: Readonly<${ast.name}Props>;`); append(`declare const props: Readonly<${ast.name}Props>;`);
append("declare const refs: Record<string, Element | null>;"); append("declare const refs: Record<string, Element | null>;");
append(
"declare function useFetch(path: string, method?: string | { method?: string; query?: unknown; params?: unknown; body?: unknown; data?: unknown }, input?: unknown): Promise<any>;",
);
append( append(
ast.kind === "global-store" || ast.kind === "page-store" ast.kind === "global-store" || ast.kind === "page-store"
? storeContract(ast) ? storeContract(ast)
@@ -332,6 +335,59 @@ export function virtualTypeScriptModule(
for (const fn of functions) append(functionDeclaration(fn), fn.source); for (const fn of functions) append(functionDeclaration(fn), fn.source);
append("}"); append("}");
} }
const viewFunctions = new Map<string, RuntimeFunctionDecl["runtime"]>();
for (const fn of ast.runtimeFunctions) {
if (fn.runtime !== "server") viewFunctions.set(fn.name, fn.runtime);
}
for (const [name, runtime] of viewFunctions) {
append(`declare const ${name}: typeof ${runtimeNamespace(runtime)}.${name};`);
}
// View expressions execute in the client scope, but historically were not
// represented in the virtual TypeScript module. Validate them here so a
// misspelled state/function name cannot silently become `undefined` at
// runtime. Each/handler locals mirror the browser evaluator's bindings.
let bindingIndex = 0;
const appendViewBindings = (nodes: ViewNode[], locals: Set<string>): void => {
for (const node of nodes) {
if (node.type === "element") {
for (const attr of node.attrs) {
const expressions = attr.event
? [attr.value]
: [...attr.value.matchAll(/\{([^{}]+)\}/g)].map((match) => match[1]!.trim());
for (const expression of expressions) {
if (!expression.trim()) continue;
const declarations = [
...[...locals].map((name) => `declare const ${name}: any;`),
...(attr.event ? ["declare const payload: any;", "declare const event: Event;"] : []),
].join(" ");
append(
`namespace __wrn_view_${bindingIndex++} { ${declarations} void (${expression}); }`,
attr.value,
);
}
}
appendViewBindings(node.children, locals);
} else if (node.type === "if") {
for (const branch of node.branches) {
if (branch.cond)
append(
`namespace __wrn_view_${bindingIndex++} { void (${branch.cond}); }`,
branch.cond,
);
appendViewBindings(branch.body, locals);
}
} else if (node.type === "each") {
append(`namespace __wrn_view_${bindingIndex++} { void (${node.list}); }`, node.list);
const nested = new Set(locals);
nested.add(node.item);
if (node.index) nested.add(node.index);
appendViewBindings(node.body, nested);
appendViewBindings(node.empty, locals);
}
}
};
appendViewBindings(ast.view, new Set());
return { return {
ast, ast,
@@ -464,7 +520,9 @@ function componentUsageDiagnostics(
const shape = shapes.get(node.tag); const shape = shapes.get(node.tag);
if (!shape) return; if (!shape) return;
const attributes = new Map( const attributes = new Map(
node.attrs.filter((attr) => !attr.event).map((attr) => [attr.name, attr]), node.attrs
.filter((attr) => !attr.event)
.map((attr) => [attr.name.startsWith("bind:") ? attr.name.slice(5) : attr.name, attr]),
); );
const outputNames = new Set(shape.outputs.map((output) => output.name)); const outputNames = new Set(shape.outputs.map((output) => output.name));
const position = lineAt(source, `<${node.tag}`); const position = lineAt(source, `<${node.tag}`);
@@ -501,7 +559,8 @@ function componentUsageDiagnostics(
continue; continue;
} }
if (/^(?:class|id|style|slot|data-|aria-)/.test(attr.name) || attr.name === "attrs") continue; if (/^(?:class|id|style|slot|data-|aria-)/.test(attr.name) || attr.name === "attrs") continue;
const prop = known.get(attr.name); const propName = attr.name.startsWith("bind:") ? attr.name.slice(5) : attr.name;
const prop = known.get(propName);
if (!prop) { if (!prop) {
diagnostics.push({ diagnostics.push({
code: "WRN-COMPONENT-UNKNOWN-PROP", code: "WRN-COMPONENT-UNKNOWN-PROP",
+27
View File
@@ -85,3 +85,30 @@ test("provides the framework context to page state expressions", () => {
); );
expect(diagnostics.some((diagnostic) => diagnostic.code === "WRN-TYPE-2304")).toBe(false); expect(diagnostics.some((diagnostic) => diagnostic.code === "WRN-TYPE-2304")).toBe(false);
}); });
test("rejects an out-of-scope name in a view event handler", () => {
const root = app();
const diagnostics = checkWrnSource(
`page AccountPage {
functions { client function save() {} }
view { <button @click="savve(missingValue)"></button> }
}`,
{ appRoot: root, filePath: join(root, "app", "pages", "account.wrn") },
);
expect(diagnostics.some((diagnostic) => diagnostic.code === "WRN-TYPE-2304")).toBe(true);
});
test("accepts handler payload, loop locals, and the public useFetch helper", () => {
const root = app();
const diagnostics = checkWrnSource(
`page AccountPage {
state rows = [{ id: "1" }]
functions {
client async function save(id: string) { await useFetch("/api/rows/" + id, "POST", { id }) }
}
view { {#each rows as row}<button @click="save(row.id)"></button>{/each} }
}`,
{ appRoot: root, filePath: join(root, "app", "pages", "account.wrn") },
);
expect(diagnostics.filter((diagnostic) => diagnostic.code === "WRN-TYPE-2304")).toEqual([]);
});