UNPKG

trellis

Version:

Agentic State Engine — event-sourced causal graph with branching, decision traces, and realtime sync for AI-native applications

657 lines (640 loc) 19.4 kB
import "../chunk-2ESYSVXG.js"; // src/cms/internal.ts function typeKey(type) { return type.trim().toLowerCase(); } function entryFromFacts(entity, facts, links) { const fields = {}; let status = "draft"; let cmsStatusSeen = false; for (const f of facts) { if (f.a === "type") continue; if (f.a === "cms_status") { status = f.v === "published" ? "published" : "draft"; cmsStatusSeen = true; continue; } fields[f.a] = f.v; } if (!cmsStatusSeen) { if (fields.status === "published") status = "published"; else if (fields.status === "draft") status = "draft"; } if (links) { for (const link of links) { if (link.e1 !== entity.id) continue; if (!(link.a in fields)) fields[link.a] = link.e2; } } return { id: entity.id, type: entity.type, status, fields }; } function groupFactsByEntity(facts) { const map = /* @__PURE__ */ new Map(); for (const f of facts) { const list = map.get(f.e); if (list) list.push(f); else map.set(f.e, [f]); } return map; } function groupLinksBySource(links) { const map = /* @__PURE__ */ new Map(); for (const l of links) { const list = map.get(l.e1); if (list) list.push(l); else map.set(l.e1, [l]); } return map; } async function expandReferences(entries, expandKeys, fetchEntity) { const ids = /* @__PURE__ */ new Set(); for (const entry of entries) { for (const key of expandKeys) { const v = entry.fields[key]; if (typeof v === "string") ids.add(v); } } if (ids.size === 0) return entries; const resolved = /* @__PURE__ */ new Map(); await Promise.all( [...ids].map(async (id) => { try { resolved.set(id, await fetchEntity(id)); } catch { resolved.set(id, null); } }) ); return entries.map((entry) => { const next = { ...entry.fields }; for (const key of expandKeys) { const v = next[key]; if (typeof v === "string" && resolved.has(v)) { next[key] = resolved.get(v); } } return { ...entry, fields: next }; }); } function fingerprint(value) { return JSON.stringify(value); } // src/cms/formula.ts var OPS = /* @__PURE__ */ new Set(["+", "-", "*", "/", "(", ")"]); function num(value) { if (typeof value === "number") return Number.isFinite(value) ? value : void 0; if (typeof value !== "string" || value.trim() === "") return void 0; const parsed = Number(value); return Number.isFinite(parsed) ? parsed : void 0; } function tokenize(expr) { const tokens = []; let i = 0; while (i < expr.length) { const ch = expr[i]; if (/\s/.test(ch)) { i++; continue; } if (OPS.has(ch)) { tokens.push(ch); i++; continue; } if (/\d|\./.test(ch)) { let end = i + 1; while (end < expr.length && /\d|\./.test(expr[end])) end++; const value = Number(expr.slice(i, end)); if (!Number.isFinite(value)) return void 0; tokens.push(value); i = end; continue; } return void 0; } return tokens; } function parse(tokens) { let i = 0; const peek = () => tokens[i]; const take = () => tokens[i++]; const primary = () => { const token = take(); if (typeof token === "number") return token; if (token === "+") return primary(); if (token === "-") { const value2 = primary(); return value2 === void 0 ? void 0 : -value2; } if (token === "(") { const value2 = add(); if (take() !== ")") return void 0; return value2; } return void 0; }; const mul = () => { let left = primary(); while (peek() === "*" || peek() === "/") { const op = take(); const right = primary(); if (left === void 0 || right === void 0) return void 0; left = op === "*" ? left * right : left / right; } return left; }; const add = () => { let left = mul(); while (peek() === "+" || peek() === "-") { const op = take(); const right = mul(); if (left === void 0 || right === void 0) return void 0; left = op === "+" ? left + right : left - right; } return left; }; const value = add(); if (i !== tokens.length || value === void 0 || !Number.isFinite(value)) return void 0; return value; } function evaluateFormula(expr, fields) { const interpolated = expr.replace(/\{([^{}]+)\}/g, (match, key) => { const value = num(fields[key.trim()]); return value === void 0 ? match : String(value); }); if (interpolated.includes("{") || interpolated.includes("}")) return void 0; const tokens = tokenize(interpolated); return tokens ? parse(tokens) : void 0; } function keys(value) { const raw = value.replace(/^schema:/, "").trim(); const camel = raw.replace(/([a-z0-9])([A-Z])/g, "$1_$2"); const lower = raw.toLowerCase().replace(/\s+/g, "_"); const snake = camel.toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, ""); const compact = lower.replace(/[^a-z0-9]+/g, ""); return [...new Set([lower, snake, compact].filter(Boolean))]; } function parseFields(raw) { if (typeof raw !== "string") return []; try { const parsed = JSON.parse(raw); if (!Array.isArray(parsed)) return []; return parsed.filter((item) => { if (!item || typeof item !== "object") return false; const def = item; return typeof def.key === "string" && typeof def.type === "string"; }); } catch { return []; } } function schemaFields(facts, names) { const wanted = new Set(names.flatMap(keys)); const fact = facts.find( (item) => item.e.startsWith("schema:") && item.a === "props" && keys(item.e).some((key) => wanted.has(key)) ); return parseFields(fact?.v); } function applyFormulas(entry, defs) { const formulas = defs.filter( (def) => def.type === "formula" && typeof def.formula === "string" && def.formula.trim() ); if (formulas.length === 0) return entry; const fields = { ...entry.fields }; for (let pass = 0; pass < formulas.length; pass++) { for (const def of formulas) fields[def.key] = evaluateFormula(def.formula, fields); } return { ...entry, fields }; } // src/cms/client.ts var DEFAULT_BASE_PATH = "/trellis/store"; var DEFAULT_POLL_MS = 2e3; var MIN_POLL_MS = 500; var MAX_FACTS_PER_FETCH = 5e3; var MAX_ENTITIES_PER_FETCH = 1e3; var defaultEquals = (prev, next) => fingerprint(prev) === fingerprint(next); var CmsClient = class { url; basePath; directory; pollIntervalMs; fetchFn; apiKey; subscriptions = /* @__PURE__ */ new Map(); constructor(opts) { this.url = opts.url.replace(/\/+$/, ""); this.basePath = (opts.basePath ?? DEFAULT_BASE_PATH).replace(/\/+$/, ""); this.directory = opts.directory; this.pollIntervalMs = Math.max( MIN_POLL_MS, opts.pollIntervalMs ?? DEFAULT_POLL_MS ); this.fetchFn = opts.fetch ?? globalThis.fetch.bind(globalThis); this.apiKey = opts.apiKey; } collection(key) { return new CollectionRef(this, key); } entry(id) { return new EntryRef(this, id); } /** List CMS collections (TypeSchema entities marked cms=true). */ async collections() { const [entities, facts] = await Promise.all([ this._entities(), this._facts() ]); const factsByEntity = groupFactsByEntity(facts ?? []); const counts = /* @__PURE__ */ new Map(); for (const e of entities ?? []) { const k = typeKey(e.type); counts.set(k, (counts.get(k) ?? 0) + 1); } const out = /* @__PURE__ */ new Map(); for (const e of entities ?? []) { if (e.type !== "TypeSchema") continue; const efacts = factsByEntity.get(e.id) ?? []; const isCms = efacts.some((f) => f.a === "cms" && f.v === true); if (!isCms) continue; const name = e.id.replace(/^schema:/, ""); const k = typeKey(name); const labelFact = efacts.find((f) => f.a === "label"); const label = typeof labelFact?.v === "string" ? labelFact.v : name; out.set(k, { key: k, label, inferred: false, count: counts.get(k) ?? 0 }); } return [...out.values()].sort((a, b) => a.label.localeCompare(b.label)); } close() { for (const sub of this.subscriptions.values()) { clearInterval(sub.interval); sub.subscribers.clear(); } this.subscriptions.clear(); } /** * Shared polling subscription. Multiple subscribers to the same key share a * single timer and one HTTP request per poll cycle. New subscribers receive * the most recently fetched value immediately if one is cached. * * @internal */ _share(key, fetcher, callback, extras = {}) { let sub = this.subscriptions.get(key); if (!sub) { const fresh = { subscribers: /* @__PURE__ */ new Set(), interval: void 0, hasLast: false, fetcher }; const tick = async () => { try { const next = await fresh.fetcher(); fresh.last = next; fresh.hasLast = true; for (const item2 of fresh.subscribers) { if (!item2.hasLast || !item2.equals(item2.last, next)) { item2.last = next; item2.hasLast = true; item2.callback(next); } } } catch (err) { for (const item2 of fresh.subscribers) item2.onError?.(err); } }; fresh.interval = setInterval(tick, this.pollIntervalMs); this.subscriptions.set(key, fresh); sub = fresh; void tick(); } const item = { callback, equals: extras.equals ?? defaultEquals, onError: extras.onError, hasLast: false }; sub.subscribers.add(item); if (sub.hasLast) { item.last = sub.last; item.hasLast = true; callback(sub.last); } return () => { sub.subscribers.delete(item); if (sub.subscribers.size === 0) { clearInterval(sub.interval); this.subscriptions.delete(key); } }; } /** @internal */ async _get(path) { const u = new URL(`${this.basePath}${path}`, this.url); if (this.directory && !u.searchParams.has("directory")) { u.searchParams.set("directory", this.directory); } const res = await this.fetchFn(u.toString(), { headers: this.apiKey ? { Authorization: `Bearer ${this.apiKey}` } : {} }); if (res.status === 404) return void 0; if (!res.ok) throw new Error( `Trellis CMS request failed (${res.status}) ${u.pathname}` ); const kind = res.headers.get("content-type") ?? ""; const text = await res.text(); if (!kind.toLowerCase().includes("application/json")) { throw new Error( `Trellis CMS expected JSON (${res.status}) ${u.pathname}: ${text.slice(0, 120)}` ); } try { return JSON.parse(text); } catch (err) { throw new Error( `Trellis CMS invalid JSON (${res.status}) ${u.pathname}: ${text.slice(0, 120)}`, { cause: err } ); } } /** @internal */ async _entities() { const out = []; let offset = 0; while (true) { const page = await this._get( `/entities?limit=${MAX_ENTITIES_PER_FETCH}&offset=${offset}` ) ?? []; out.push(...page); if (page.length < MAX_ENTITIES_PER_FETCH) return out; offset += MAX_ENTITIES_PER_FETCH; } } async _facts() { const out = []; let offset = 0; while (true) { const page = await this._get( `/facts?limit=${MAX_FACTS_PER_FETCH}&offset=${offset}` ) ?? []; out.push(...page); if (page.length < MAX_FACTS_PER_FETCH) return out; offset += MAX_FACTS_PER_FETCH; } } /** @internal */ async _entryById(id, schemaFacts) { const detail = await this._get(`/entity/${encodeURIComponent(id)}`); if (!detail) return null; const typeFact = detail.facts.find((f) => f.a === "type"); const type = typeof typeFact?.v === "string" ? typeFact.v : "unknown"; const facts = schemaFacts ?? await this._facts() ?? []; return applyFormulas( entryFromFacts({ id: detail.id, type }, detail.facts, detail.links), schemaFields(facts, [type]) ); } }; var CollectionRef = class { constructor(client, key) { this.client = client; this.key = key; } async list(opts = {}) { const status = opts.status ?? "published"; const limit = opts.limit ?? 100; const wantsExpand = opts.expand && opts.expand.length > 0; const [allEntities, facts, links] = await Promise.all([ this.client._entities(), this.client._facts(), this.client._get(`/links`) ]); if (!allEntities) return []; const wantedKey = typeKey(this.key); const matching = allEntities.filter((e) => typeKey(e.type) === wantedKey); if (matching.length === 0) return []; const factsByEntity = groupFactsByEntity(facts ?? []); const linksBySource = groupLinksBySource(links ?? []); const defs = schemaFields(facts ?? [], [ this.key, ...matching.map((e) => e.type) ]); let entries = matching.map( (e) => applyFormulas( entryFromFacts( e, factsByEntity.get(e.id) ?? [], linksBySource.get(e.id) ?? [] ), defs ) ); if (status !== "all") { entries = entries.filter((e) => e.status === status); } entries = entries.slice(0, limit); if (wantsExpand) { entries = await expandReferences( entries, opts.expand, (id) => this.client._entryById(id, facts ?? []) ); } return entries; } async get(id, opts = {}) { const entry = await this.client._entryById(id); if (!entry) return null; if (opts.expand && opts.expand.length > 0) { const [expanded] = await expandReferences( [entry], opts.expand, (eid) => this.client._entryById(eid) ); return expanded; } return entry; } /** * Subscribe to changes. Currently implemented as polling; a future SSE-backed * upgrade will replace the transport without changing this API. * * Multiple subscribers to the same collection + opts share one polling timer * and one HTTP request per cycle. */ subscribe(callback, opts = {}) { const { onError, equals, ...listOpts } = opts; const key = `coll:${typeKey(this.key)}:${JSON.stringify(listOpts)}`; return this.client._share( key, () => this.list(listOpts), callback, { onError, equals } ); } async schema() { const facts = await this.client._facts(); return schemaFields(facts ?? [], [this.key]); } }; var EntryRef = class { constructor(client, id) { this.client = client; this.id = id; } async get(opts = {}) { const entry = await this.client._entryById(this.id); if (!entry) return null; if (opts.expand && opts.expand.length > 0) { const [expanded] = await expandReferences( [entry], opts.expand, (eid) => this.client._entryById(eid) ); return expanded; } return entry; } subscribe(callback, opts = {}) { const { onError, equals, ...getOpts } = opts; const key = `entry:${this.id}:${JSON.stringify(getOpts)}`; return this.client._share( key, () => this.get(getOpts), callback, { onError, equals } ); } }; function createCmsClient(opts) { return new CmsClient(opts); } // src/cms/scaffold.ts var DEFAULT_URL = "http://localhost:4096"; function expandLiteral(expand) { if (!expand || expand.length === 0) return ""; return ` expand: ${JSON.stringify(expand)},`; } function clientLiteral(url, directory) { if (!directory) return `createCmsClient({ url: "${url}" })`; return `createCmsClient({ url: "${url}", directory: ${JSON.stringify(directory)} })`; } function vanilla(opts) { const url = opts.url ?? DEFAULT_URL; const exp = expandLiteral(opts.expand); return `import { createCmsClient } from "trellis/cms"; const cms = ${clientLiteral(url, opts.directory)}; const collection = cms.collection("${opts.collection}"); // One-shot fetch (defaults to status: "published") const entries = await collection.list({${exp}}); console.log(entries); // Live updates \u2014 re-fires whenever the collection changes const off = collection.subscribe( (entries) => { console.log("Updated:", entries); }, {${exp} onError: (err) => console.error("CMS subscription failed", err), }, ); // off(); // call to stop receiving updates `; } function react(opts) { const url = opts.url ?? DEFAULT_URL; const exp = expandLiteral(opts.expand); return `import { useEffect, useState } from "react"; import { createCmsClient, type Entry } from "trellis/cms"; const cms = ${clientLiteral(url, opts.directory)}; export function use${pascal(opts.collection)}() { const [entries, setEntries] = useState<Entry[]>([]); useEffect(() => { const off = cms.collection("${opts.collection}").subscribe(setEntries, {${exp} onError: (err) => console.error("CMS subscription failed", err), }); return off; }, []); return entries; } // Usage: // const posts = use${pascal(opts.collection)}(); // return posts.map(p => <article key={p.id}>...</article>); `; } function solid(opts) { const url = opts.url ?? DEFAULT_URL; const exp = expandLiteral(opts.expand); return `import { createSignal, onCleanup } from "solid-js"; import { createCmsClient, type Entry } from "trellis/cms"; const cms = ${clientLiteral(url, opts.directory)}; export function create${pascal(opts.collection)}() { const [entries, setEntries] = createSignal<Entry[]>([]); const off = cms.collection("${opts.collection}").subscribe(setEntries, {${exp} onError: (err) => console.error("CMS subscription failed", err), }); onCleanup(off); return entries; } // Usage in a component: // const posts = create${pascal(opts.collection)}(); // return <For each={posts()}>{(p) => <article>...</article>}</For>; `; } function vue(opts) { const url = opts.url ?? DEFAULT_URL; const exp = expandLiteral(opts.expand); return `import { ref, onUnmounted } from "vue"; import { createCmsClient, type Entry } from "trellis/cms"; const cms = ${clientLiteral(url, opts.directory)}; export function use${pascal(opts.collection)}() { const entries = ref<Entry[]>([]); const off = cms.collection("${opts.collection}").subscribe((next) => { entries.value = next; }, {${exp} onError: (err) => console.error("CMS subscription failed", err), }); onUnmounted(off); return entries; } `; } function pascal(s) { return s.split(/[_\-\s]+/).filter(Boolean).map((w) => w[0].toUpperCase() + w.slice(1)).join(""); } function scaffoldConsumer(opts) { switch (opts.framework ?? "vanilla") { case "react": return react(opts); case "solid": return solid(opts); case "vue": return vue(opts); default: return vanilla(opts); } } function scaffoldFilename(opts) { const base = `cms-${opts.collection.replace(/[^a-z0-9]+/gi, "-").toLowerCase()}`; switch (opts.framework ?? "vanilla") { case "react": case "solid": return `${base}.ts`; case "vue": return `${base}.ts`; default: return `${base}.js`; } } export { CmsClient, CollectionRef, EntryRef, applyFormulas, createCmsClient, evaluateFormula, parseFields, scaffoldConsumer, scaffoldFilename };