UNPKG

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