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
JavaScript
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
};