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