UNPKG

trellis

Version:

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

499 lines (496 loc) 15.5 kB
import { listFormableTypes, readFormOverrides, resolveFormDescriptor } from "./chunk-VKZC4ZIY.js"; import { EntityConflictError } from "./chunk-LEGH72HW.js"; // src/client/sdk.ts function isRemote(opts) { return "url" in opts; } var TrellisDb = class _TrellisDb { opts; _pool = null; _poolPromise = null; _ws = null; /** In-flight connect — concurrent subscribe() shares one socket open. */ _wsPromise = null; _subCallbacks = /* @__PURE__ */ new Map(); _subOpts = /* @__PURE__ */ new Map(); constructor(opts) { this.opts = opts; } /** * Create a TrellisDb instance from `.trellis-db.json` in the given directory. */ static async fromConfig(dir = ".") { const { readConfig } = await import("./config-KMY6RRK3.js"); const config = readConfig(dir); if (!config) throw new Error( "No .trellis-db.json found. Run `trellis db init` first." ); if (config.mode === "remote" && config.url) { return new _TrellisDb({ url: config.url, apiKey: config.apiKey }); } if (config.dbPath) { return new _TrellisDb({ path: config.dbPath }); } throw new Error("Invalid .trellis-db.json: missing url or dbPath."); } // ------------------------------------------------------------------------- // CRUD // ------------------------------------------------------------------------- /** * Create a new entity. * Returns the entity ID (generated or caller-supplied via options.id). */ async create(type, attributes = {}, links, options) { if (isRemote(this.opts)) { const res = await this._fetch("POST", "/entities", { type, attributes, links, ...options?.id !== void 0 ? { id: options.id } : {} }); return res.id; } const pool = await this._getPool(); const tenantId = this.opts.tenantId ?? null; const kernel = pool.get(tenantId); let entityId; if (options?.id !== void 0) { const trimmed = options.id.trim(); if (!trimmed) { throw new Error("create options.id must be a non-empty string"); } if (kernel.getEntity(trimmed)) { throw new EntityConflictError(trimmed); } entityId = trimmed; } else { entityId = `${type.toLowerCase()}:${crypto.randomUUID()}`; } await kernel.createEntity(entityId, type, attributes, links); return entityId; } /** * Read an entity by ID. * Returns null if not found. */ async read(id) { if (isRemote(this.opts)) { try { return await this._fetch( "GET", `/entities/${encodeURIComponent(id)}` ); } catch (err) { if (err?.status === 404) return null; throw err; } } const pool = await this._getPool(); const tenantId = this.opts.tenantId ?? null; const kernel = pool.get(tenantId); const entity = kernel.getEntity(id); if (!entity) return null; return entityToPlain(entity); } /** * Update an entity's attributes (partial update). */ async update(id, attributes) { if (isRemote(this.opts)) { await this._fetch( "PUT", `/entities/${encodeURIComponent(id)}`, attributes ); return; } const pool = await this._getPool(); const tenantId = this.opts.tenantId ?? null; const kernel = pool.get(tenantId); await kernel.updateEntity(id, attributes); } /** * Delete an entity by ID. */ async delete(id) { if (isRemote(this.opts)) { await this._fetch("DELETE", `/entities/${encodeURIComponent(id)}`); return; } const pool = await this._getPool(); const tenantId = this.opts.tenantId ?? null; const kernel = pool.get(tenantId); await kernel.deleteEntity(id); } /** * List entities of a given type. */ async list(type, opts = {}) { if (isRemote(this.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}`); } const pool = await this._getPool(); const tenantId = this.opts.tenantId ?? null; const kernel = pool.get(tenantId); const entities = kernel.listEntities(type, opts.filters); const limit = opts.limit ?? 100; const offset = opts.offset ?? 0; const page = entities.slice(offset, offset + limit); return { data: page.map(entityToPlain), total: entities.length, limit, offset }; } // ------------------------------------------------------------------------- // Query // ------------------------------------------------------------------------- /** * Run an EQL-S query string. */ async query(eql) { if (isRemote(this.opts)) { return await this._fetch("POST", "/query", { query: eql }); } const { parseSimple } = await import("./query-P3PCUXMY.js"); const pool = await this._getPool(); const tenantId = this.opts.tenantId ?? null; const kernel = pool.get(tenantId); const parsed = parseSimple(eql); return kernel.query(parsed); } // ------------------------------------------------------------------------- // Schema registration // ------------------------------------------------------------------------- /** * Register a user/system-tier ontology schema with the kernel. * * Accepts a {@link SchemaDefinition} or anything carrying one (e.g. a * `defineType` handle: `client.registerType(NavItem)`). Local mode calls * `kernel.createOntology` directly; remote mode POSTs to `/ontologies`. */ async registerType(schema) { const def = "@id" in schema ? schema : schema.definition; if (isRemote(this.opts)) { try { await this._fetch("POST", "/ontologies", def); } catch (err) { if (err instanceof FetchError && err.status === 409) return; throw err; } return; } const pool = await this._getPool(); const tenantId = this.opts.tenantId ?? null; const kernel = pool.get(tenantId); const exists = kernel.listOntologies().some((ont) => ont["@id"] === def["@id"]); if (exists) { kernel.updateOntology(def["@id"], { fields: def.fields, label: def.label, version: def.version, subClassOf: def.subClassOf }); return; } try { kernel.createOntology(def); } catch (err) { const msg = err instanceof Error ? err.message : String(err); if (msg.includes("already exists")) { kernel.updateOntology(def["@id"], { fields: def.fields, label: def.label, version: def.version, subClassOf: def.subClassOf }); return; } throw err; } } // ------------------------------------------------------------------------- // Headless forms (schema-derived descriptors) // ------------------------------------------------------------------------- /** * Resolve the headless form descriptor for an entity type, layered with * any `trellis:Form` override entities in the graph. * * Local mode derives in-process; remote mode hits `GET /forms/:type`. * Returns `null` when the type has no registered schema. */ async formDescriptor(type, opts = {}) { const mode = opts.mode ?? "create"; if (isRemote(this.opts)) { try { return await this._fetch( "GET", `/forms/${encodeURIComponent(type)}?mode=${mode}` ); } catch (err) { if (err instanceof FetchError && err.status === 404) return null; throw err; } } const pool = await this._getPool(); const tenantId = this.opts.tenantId ?? null; const kernel = pool.get(tenantId); return resolveFormDescriptor(kernel.listOntologies(), type, { mode, overrides: readFormOverrides(kernel) }) ?? null; } /** List entity types with registered schemas (form derivable). */ async listForms() { if (isRemote(this.opts)) { return await this._fetch("GET", "/forms"); } const pool = await this._getPool(); const tenantId = this.opts.tenantId ?? null; const kernel = pool.get(tenantId); return listFormableTypes(kernel.listOntologies()); } // ------------------------------------------------------------------------- // 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 buffer = new Uint8Array(cleanBuf); if (isRemote(this.opts)) { 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(); } const hashBuf = await crypto.subtle.digest("SHA-256", buffer); const hash = `blob:${Array.from(new Uint8Array(hashBuf)).map((b) => b.toString(16).padStart(2, "0")).join("")}`; const pool = await this._getPool(); const tenantId = this.opts.tenantId ?? null; const kernel = pool.get(tenantId); const backend = kernel.getBackend(); if (!backend.hasBlob(hash)) backend.putBlob(hash, buffer); return { hash, size: buffer.length, contentType }; } /** * Download a file by its blob hash. */ async getFile(hash) { if (isRemote(this.opts)) { 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()); } const pool = await this._getPool(); const tenantId = this.opts.tenantId ?? null; const kernel = pool.get(tenantId); const backend = kernel.getBackend(); return backend.getBlob(hash) ?? null; } // ------------------------------------------------------------------------- // Auth helpers // ------------------------------------------------------------------------- async register(email, password, name) { if (!isRemote(this.opts)) throw new Error("register() requires remote mode"); return await this._fetch("POST", "/auth/register", { email, password, name }); } async login(email, password) { if (!isRemote(this.opts)) throw new Error("login() requires remote mode"); return await this._fetch("POST", "/auth/login", { email, password }); } /** * Set the active API key / JWT token for subsequent requests. */ setToken(token) { if (!isRemote(this.opts)) return; this.opts.apiKey = token; } // ------------------------------------------------------------------------- // Realtime // ------------------------------------------------------------------------- /** * Subscribe to a live EQL-S query. * Callback is fired immediately with the initial result, then on every update. * * Requires remote mode (WebSocket to server). */ subscribe(eql, callback, opts) { if (!isRemote(this.opts)) { throw new Error( "subscribe() requires remote mode (connect to a running server)" ); } const subId = `sub_${crypto.randomUUID()}`; this._subCallbacks.set(subId, callback); if (opts) this._subOpts.set(subId, opts); this._ensureWs().then((ws) => { ws.send( JSON.stringify({ type: "subscribe", id: subId, query: eql, ...opts?.entityType ? { entityType: opts.entityType } : {}, ...opts?.resolve ? { resolve: opts.resolve } : {} }) ); }); return { unsubscribe: () => { this._subCallbacks.delete(subId); this._subOpts.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 local kernel pool connections. */ close() { this._pool?.closeAll(); this._pool = null; this._poolPromise = null; this.disconnect(); } // ------------------------------------------------------------------------- // Private // ------------------------------------------------------------------------- async _getPool() { if (this._pool) return this._pool; if (!this._poolPromise) { this._poolPromise = import("./tenancy-26H7VV7D.js").then(({ TenantPool }) => { const path = this.opts.path; this._pool = new TenantPool(path); return this._pool; }); } return this._poolPromise; } async _fetch(method, path, body) { const opts = this.opts; const url = `${opts.url}${path}`; const res = await fetch(url, { method, headers: { "Content-Type": "application/json", ...opts.apiKey ? { Authorization: `Bearer ${opts.apiKey}` } : {} }, body: body !== void 0 ? JSON.stringify(body) : void 0 }); const data = await res.json(); if (!res.ok) throw new FetchError( res.status, data?.message ?? res.statusText, data ); return data; } _ensureWs() { if (this._ws?.readyState === WebSocket.OPEN) { return Promise.resolve(this._ws); } if (this._wsPromise) { return this._wsPromise; } const opts = this.opts; const wsUrl = opts.url.replace(/^https?/, 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; } }; function entityToPlain(entity) { const obj = { id: entity.id, type: entity.type }; for (const f of entity.facts) { if (f.a !== "type") obj[f.a] = f.v; } return obj; } var FetchError = class extends Error { constructor(status, message, body) { super(`HTTP ${status}: ${message}`); this.status = status; this.body = body; this.name = "FetchError"; } }; export { TrellisDb, FetchError };