trellis
Version:
Agentic State Engine — event-sourced causal graph with branching, decision traces, and realtime sync for AI-native applications
435 lines (432 loc) • 13.7 kB
JavaScript
// src/client/dev-registry.ts
function isDevEnvironment() {
if (typeof process !== "undefined" && process.env.NODE_ENV === "production") {
return false;
}
try {
const meta = import.meta;
if (meta.env?.PROD) return false;
if (meta.env?.DEV) return true;
} catch {
}
return typeof process === "undefined" || process.env.NODE_ENV !== "production";
}
function registerDevRegistry(registry) {
if (!isDevEnvironment()) return;
if (typeof globalThis.window === "undefined") return;
window.__TRELLIS_DEV__ = registry;
}
function getDevRegistry() {
if (typeof globalThis.window === "undefined") return null;
return window.__TRELLIS_DEV__ ?? null;
}
function clearDevRegistry() {
if (typeof globalThis.window === "undefined") return;
delete window.__TRELLIS_DEV__;
}
// src/client/sdk.browser.ts
function isRemote(opts) {
return "url" in opts;
}
function browserOnlyRemoteError(feature) {
return new Error(
`${feature} is not available in browser bundles. Use remote mode with \`new TrellisDb({ url })\`, or import \`trellis/client\` in Node.`
);
}
function randomId() {
if (typeof globalThis.crypto?.randomUUID === "function") {
return globalThis.crypto.randomUUID();
}
return `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 11)}`;
}
function needsXhrForBody() {
if (typeof globalThis.window !== "undefined" && window.__TRELLIS_NATIVE_HTTP__) {
return false;
}
if (typeof navigator === "undefined") return false;
const ua = navigator.userAgent;
if (/iPad|iPhone|iPod/i.test(ua)) return true;
if (navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1) return true;
if (/AppleWebKit/i.test(ua) && /KHTML, like Gecko\)/i.test(ua) && !/Safari\/|Chrome\/|Chromium\/|Edg\/|Version\//i.test(ua)) {
return true;
}
return false;
}
function xhrRequest(method, url, headers, body) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open(method, url);
for (const [key, value] of Object.entries(headers)) {
xhr.setRequestHeader(key, value);
}
xhr.onload = () => resolve({ status: xhr.status, text: xhr.responseText });
xhr.onerror = () => reject(new Error(`XHR network error on ${method} ${url}`));
xhr.send(body);
});
}
var TrellisDb = class {
opts;
_ws = null;
/** In-flight connect — concurrent subscribe() shares one socket open. */
_wsPromise = null;
_subCallbacks = /* @__PURE__ */ new Map();
constructor(opts) {
if (!isRemote(opts)) {
throw browserOnlyRemoteError("TrellisDb local mode");
}
this.opts = opts;
}
/**
* Browser bundles cannot read `.trellis-db.json`.
*/
static async fromConfig(_dir = ".") {
throw browserOnlyRemoteError("TrellisDb.fromConfig()");
}
// -------------------------------------------------------------------------
// CRUD
// -------------------------------------------------------------------------
/**
* Create a new entity.
* Returns the entity ID (generated or caller-supplied via options.id).
*/
async create(type, attributes = {}, links, options) {
const res = await this._fetch("POST", "/entities", {
type,
attributes,
links,
...options?.id !== void 0 ? { id: options.id } : {}
});
return res.id;
}
/**
* Read an entity by ID.
* Returns null if not found.
*/
async read(id) {
try {
return await this._fetch(
"GET",
`/entities/${encodeURIComponent(id)}`
);
} catch (err) {
if (err?.status === 404) return null;
throw err;
}
}
/**
* Update an entity's attributes (partial update).
*/
async update(id, attributes) {
await this._fetch(
"PUT",
`/entities/${encodeURIComponent(id)}`,
attributes
);
}
/**
* Delete an entity by ID.
*/
async delete(id) {
await this._fetch("DELETE", `/entities/${encodeURIComponent(id)}`);
}
/**
* List entities of a given type.
*/
async list(type, opts = {}) {
const params = new URLSearchParams();
if (type) params.set("type", type);
if (opts.limit) params.set("limit", String(opts.limit));
if (opts.offset) params.set("offset", String(opts.offset));
const qs = params.toString() ? `?${params}` : "";
return await this._fetch("GET", `/entities${qs}`);
}
// -------------------------------------------------------------------------
// Query
// -------------------------------------------------------------------------
/**
* Run an EQL-S query string.
*/
async query(eql) {
return await this._fetch("POST", "/query", {
query: eql
});
}
// -------------------------------------------------------------------------
// Schema registration
// -------------------------------------------------------------------------
/**
* Register a user/system-tier ontology schema with a remote Trellis server.
*/
async registerType(schema) {
const def = "@id" in schema ? schema : schema.definition;
const label = def?.label ?? (def?.["@id"] ? String(def["@id"]).replace(/^trellis:/, "") : "unknown");
if (!def?.["@id"]) {
throw new Error(`registerType(${label}): schema is missing a definition with @id`);
}
try {
await this._fetch("POST", "/ontologies", def);
} catch (err) {
if (err instanceof FetchError && err.status === 409) return;
if (err instanceof FetchError) {
const serverMsg = typeof err.body?.message === "string" ? String(err.body.message) : err.message;
throw new FetchError(err.status, serverMsg, err.body, {
...err.context,
operation: `registerType(${label})`
});
}
throw err;
}
}
// -------------------------------------------------------------------------
// File upload
// -------------------------------------------------------------------------
/**
* Upload a file to the blob store.
* Returns a content-addressed hash.
*/
async upload(data, contentType = "application/octet-stream") {
const raw = data instanceof ArrayBuffer ? new Uint8Array(data) : data;
const cleanBuf = raw.buffer.slice(
raw.byteOffset,
raw.byteOffset + raw.byteLength
);
const res = await fetch(`${this.opts.url}/upload`, {
method: "POST",
headers: {
"Content-Type": contentType,
...this.opts.apiKey ? { Authorization: `Bearer ${this.opts.apiKey}` } : {}
},
body: cleanBuf
});
if (!res.ok) throw new FetchError(res.status, await res.text());
return await res.json();
}
/**
* Download a file by its blob hash.
*/
async getFile(hash) {
const res = await fetch(
`${this.opts.url}/files/${encodeURIComponent(hash)}`,
{
headers: this.opts.apiKey ? { Authorization: `Bearer ${this.opts.apiKey}` } : {}
}
);
if (res.status === 404) return null;
if (!res.ok) throw new FetchError(res.status, await res.text());
return new Uint8Array(await res.arrayBuffer());
}
// -------------------------------------------------------------------------
// Auth helpers
// -------------------------------------------------------------------------
async register(email, password, name) {
return await this._fetch("POST", "/auth/register", {
email,
password,
name
});
}
async login(email, password) {
return await this._fetch("POST", "/auth/login", {
email,
password
});
}
/**
* Set the active API key / JWT token for subsequent requests.
*/
setToken(token) {
this.opts.apiKey = token;
}
// -------------------------------------------------------------------------
// Realtime
// -------------------------------------------------------------------------
/**
* Subscribe to a live EQL-S query.
* Callback is fired immediately with the initial result, then on every update.
*/
subscribe(eql, callback, opts) {
const subId = `sub_${randomId()}`;
this._subCallbacks.set(subId, callback);
this._ensureWs().then((ws) => {
ws.send(
JSON.stringify({
type: "subscribe",
id: subId,
query: eql,
...this.opts.tenantId ? { tenantId: this.opts.tenantId } : {},
...opts?.entityType ? { entityType: opts.entityType } : {},
...opts?.resolve ? { resolve: opts.resolve } : {}
})
);
});
return {
unsubscribe: () => {
this._subCallbacks.delete(subId);
this._ws?.send(JSON.stringify({ type: "unsubscribe", id: subId }));
}
};
}
/**
* Close the WebSocket connection.
*/
disconnect() {
const ws = this._ws;
this._ws = null;
this._wsPromise = null;
ws?.close();
}
/**
* Close open client resources.
*/
close() {
this.disconnect();
}
// -------------------------------------------------------------------------
// Private
// -------------------------------------------------------------------------
/**
* Tenant isolation, not authorization: when `tenantId` is supplied out-of-band
* (not bound to the JWT), the server trusts the query param. Safe for ephemeral
* showcase rooms with unguessable ids; real multi-tenant auth must bind the
* tenant to the auth token instead. Skipped when the JWT already carries it.
*/
_applyTenant(path) {
const tenantId = this.opts.tenantId;
if (!tenantId) return path;
const sep = path.includes("?") ? "&" : "?";
return `${path}${sep}tenantId=${encodeURIComponent(tenantId)}`;
}
async _fetch(method, path, body) {
const url = `${this.opts.url}${this._applyTenant(path)}`;
let jsonBody;
if (body !== void 0) {
jsonBody = JSON.stringify(body);
if (jsonBody === void 0) {
throw new Error(`Request body is not JSON-serializable (${method} ${path})`);
}
}
const headers = {
"Content-Type": "application/json",
...this.opts.apiKey ? { Authorization: `Bearer ${this.opts.apiKey}` } : {}
};
const transport = jsonBody !== void 0 && needsXhrForBody() ? "xhr" : "fetch";
headers["X-Trellis-Transport"] = transport;
const bodyBytes = jsonBody ? new TextEncoder().encode(jsonBody).byteLength : 0;
let status;
let text;
if (transport === "xhr" && jsonBody !== void 0) {
({ status, text } = await xhrRequest(method, url, headers, jsonBody));
} else {
const res = await fetch(url, {
method,
headers,
body: jsonBody
});
status = res.status;
text = await res.text();
}
let data = null;
if (text) {
try {
data = JSON.parse(text);
} catch {
data = { raw: text };
}
}
if (status < 200 || status >= 300) {
const server = data;
const message = typeof server?.message === "string" && server.message || typeof server?.error === "string" && server.error || "request failed";
const err = new FetchError(status, message, data, {
method,
path,
url,
requestBodyBytes: bodyBytes,
responseBytes: text.length,
transport
});
console.error("[trellis]", err.toString());
throw err;
}
return data;
}
_ensureWs() {
if (this._ws?.readyState === WebSocket.OPEN) {
return Promise.resolve(this._ws);
}
if (this._wsPromise) {
return this._wsPromise;
}
const wsUrl = this.opts.url.replace(
/^https?/,
this.opts.url.startsWith("https") ? "wss" : "ws"
) + "/realtime";
this._wsPromise = new Promise((resolve, reject) => {
const ws = new WebSocket(wsUrl);
ws.onopen = () => {
this._ws = ws;
resolve(ws);
};
ws.onerror = (e) => {
reject(e instanceof Error ? e : new Error("WebSocket error"));
};
ws.onmessage = (e) => {
try {
const msg = JSON.parse(e.data);
if (msg.type === "data" && this._subCallbacks.has(msg.id)) {
const meta = msg.resolved === true ? { resolved: true } : void 0;
this._subCallbacks.get(msg.id)(msg.result, msg.diff, meta);
}
} catch {
}
};
ws.onclose = () => {
if (this._ws === ws) {
this._ws = null;
}
};
}).finally(() => {
this._wsPromise = null;
});
return this._wsPromise;
}
};
var FetchError = class _FetchError extends Error {
constructor(status, message, body, context = {}) {
super(_FetchError.format(status, message, context));
this.status = status;
this.body = body;
this.name = "FetchError";
this.context = context;
}
context;
static format(status, message, ctx) {
const parts = [`HTTP ${status}`];
if (ctx.operation) parts.push(ctx.operation);
if (ctx.method && ctx.path) parts.push(`${ctx.method} ${ctx.path}`);
parts.push(message);
if (ctx.requestBodyBytes !== void 0) {
parts.push(`sent ${ctx.requestBodyBytes}B`);
}
if (ctx.responseBytes !== void 0) {
parts.push(`response ${ctx.responseBytes}B`);
}
if (ctx.transport) parts.push(ctx.transport);
return parts.join(" \xB7 ");
}
toString() {
const extra = [];
if (this.body && typeof this.body === "object") {
const b = this.body;
if (typeof b.path === "string") extra.push(`server ${b.method} ${b.path}`);
if (typeof b.bodyBytes === "number") extra.push(`server saw ${b.bodyBytes}B`);
}
return extra.length ? `${this.message} (${extra.join(", ")})` : this.message;
}
};
export {
registerDevRegistry,
getDevRegistry,
clearDevRegistry,
TrellisDb,
FetchError
};