UNPKG

@anysoftinc/anydb-sdk

Version:

AnyDB TypeScript SDK for querying and transacting with Datomic databases

432 lines (431 loc) 15.7 kB
// AnyDB TypeScript SDK v2 - strict, no-fallback client // - Explicit EDN symbolic types (Keyword, Symbol, UUID) // - Strict SymbolicQuery API // - Explicit DataScript selection via storage alias (no fallback) // - Keywordized JS<->EDN conversion for object keys import { parseEDNString as parseEdn } from "edn-data"; import { DataScriptBackend, shouldUseDataScript, isDataScriptAvailable, } from "./datascript-backend"; export const sym = (name) => ({ _type: "symbol", value: name }); export const uuid = (id) => ({ _type: "uuid", value: id }); export const kw = (name) => { if (typeof name !== "string" || !name) { throw new QueryInputError("kw() requires a non-empty string name"); } if (name.startsWith(":")) { throw new QueryInputError("kw() expects bare keyword without leading ':'", { name, }); } return { _type: "keyword", value: name, }; }; // ===== Errors ===== class AnyDBError extends Error { constructor(message, details) { super(message); this.details = details; this.name = this.constructor.name; } } export class AuthError extends AnyDBError { } export class QueryInputError extends AnyDBError { } export class SchemaError extends AnyDBError { } export class TransportError extends AnyDBError { } export class ServerError extends AnyDBError { } // ===== EDN Stringifier with keywordized key handling ===== export function stringifyEdn(obj) { // Handle symbolic types first if (typeof obj === "object" && obj !== null && "_type" in obj) { const typed = obj; switch (typed._type) { case "symbol": return typed.value; // ?e, ?id, etc. case "uuid": return `#uuid "${typed.value}"`; case "keyword": // Ensure leading colon when emitting return `:${typed.value}`; } } if (typeof obj === "string") { // Strings are always quoted; callers must use sym/kw for special values return `"${obj.replace(/"/g, '\\"')}"`; } if (typeof obj === "number") return String(obj); if (typeof obj === "boolean") return String(obj); if (obj === null || obj === undefined) return "nil"; if (obj instanceof Date) return `#inst "${obj.toISOString()}"`; if (Array.isArray(obj)) { return "[" + obj.map(stringifyEdn).join(" ") + "]"; } if (typeof obj === "object") { // Keywordize JS object keys by emitting them as EDN keywords const pairs = Object.entries(obj).map(([key, value]) => { if (key.startsWith(":")) { throw new QueryInputError("Object keys representing keywords must not include a leading ':'", { key }); } const ednKey = `:${key}`; return `${ednKey} ${stringifyEdn(value)}`; }); return "{" + pairs.join(" ") + "}"; } return String(obj); } // Normalize edn-data parsed value into JS with keywordized keys (no leading colon) function normalizeEdn(value) { if (value === null || value === undefined) return value; // Preserve native Date instances returned by the parser if (value instanceof Date) return value; // edn-data often wraps keywords/symbols/strings as { key: '...' } or { sym: '...' } if (typeof value === "object" && value) { if (typeof value.key === "string") { const key = value.key; // e.g., ":db/ident" return key.startsWith(":") ? key.slice(1) : key; } if (typeof value.sym === "string") { return value.sym; } if (value instanceof Set) { return Array.from(value, (v) => normalizeEdn(v)); } if (Array.isArray(value.set)) { return value.set.map((v) => normalizeEdn(v)); } if (Array.isArray(value.map)) { const out = {}; for (const [k, v] of value.map) { const kNorm = normalizeEdn(k); const kStr = typeof kNorm === "string" ? kNorm : String(kNorm); const bareKey = kStr.startsWith(":") ? kStr.slice(1) : kStr; out[bareKey] = normalizeEdn(v); } return out; } } if (Array.isArray(value)) return value.map((v) => normalizeEdn(v)); // Tagged values if (typeof value === "object" && value && value.tag === "inst" && typeof value.val === "string") { const d = new Date(value.val); return isNaN(d.getTime()) ? value.val : d; } if (typeof value === "object" && value && value.tag === "uuid" && typeof value.val === "string") { return { _type: "uuid", value: value.val }; } if (typeof value === "object") { const out = Array.isArray(value) ? [] : {}; for (const [k, v] of Object.entries(value)) { out[k] = normalizeEdn(v); } return out; } return value; } // ===== Validation ===== function isKeyword(v) { return typeof v === "object" && v !== null && v._type === "keyword"; } function isSymbol(v) { return typeof v === "object" && v !== null && v._type === "symbol"; } function validateSymbolicQuery(q) { if (!q || typeof q !== "object" || !Array.isArray(q.find) || !Array.isArray(q.where)) { throw new QueryInputError("Invalid query object: requires { find: [], where: [] }"); } // Enforce attribute positions to be Keyword in where clauses when pattern triples for (let i = 0; i < q.where.length; i++) { const clause = q.where[i]; if (Array.isArray(clause) && clause.length >= 3 && !Array.isArray(clause[0])) { const attr = clause[1]; if (!isKeyword(attr)) { throw new QueryInputError("Attribute position in where clause must be a Keyword", { clauseIndex: i, clause, }); } } } } // ===== Root HTTP Client ===== export class DatomicClient { constructor(config) { this.config = config; this.baseUrl = config.baseUrl.replace(/\/$/, ""); this.fetchImpl = (config.fetchImpl || globalThis.fetch); if (!this.fetchImpl) { throw new TransportError("fetch implementation is required in this environment"); } } async authHeader() { let token = this.config.authToken; if (!token && this.config.getAuthToken) { const s = this.config.getAuthToken(); token = typeof s === "string" ? s : await s; } if (!token) { throw new AuthError("Missing signing secret (provide authentication jwt or getAuthToken in client config)"); } return { Authorization: `Bearer ${token}` }; } async request(endpoint, body) { const url = `${this.baseUrl}${endpoint}`; const headers = { "Content-Type": "application/edn", Accept: "application/edn", ...(this.config.headers || {}), ...(await this.authHeader()), }; const res = await this.fetchImpl(url, { method: "POST", headers, body: body === undefined ? undefined : stringifyEdn(body), }); if (!res.ok) { let serverBody = undefined; try { const txt = await res.text(); serverBody = normalizeEdn(parseEdn(txt)); } catch (_) { // ignore parse errors } throw new ServerError(`HTTP ${res.status} ${res.statusText}`, { status: res.status, endpoint, serverBody, }); } const text = await res.text(); const parsed = parseEdn(text); return normalizeEdn(parsed); } // Low-level endpoints (path-style) async transact(storageAlias, dbName, txData) { // Basic schema validation: txData must be an array of entity maps or operation vectors if (!Array.isArray(txData)) { throw new SchemaError("txData must be an array of entities/ops"); } return this.request(`/data/${storageAlias}/${dbName}/`, { "tx-data": txData, }); } async querySymbolic(q, args = [], options) { validateSymbolicQuery(q); const body = { q: [ kw("find"), ...q.find, ...(q.in && q.in.length ? [kw("in"), ...q.in] : []), kw("where"), ...q.where, ], args, }; if (options?.limit !== undefined) body["limit"] = options.limit; if (options?.offset !== undefined) body["offset"] = options.offset; return this.request(`/api/query`, body); } subscribeToEvents(storageAlias, dbName, onEvent, onError) { const factory = this.config.eventSourceFactory || globalThis.EventSource; if (!factory) { throw new TransportError("EventSource not available. Provide eventSourceFactory in client config for Node."); } const url = `${this.baseUrl}/events/${storageAlias}/${dbName}`; const es = new factory(url); es.onmessage = onEvent; if (onError) es.onerror = onError; return es; } } // ===== Convenience wrapper for a specific database ===== export class AnyDBClient { constructor(client, storageAlias, dbName) { this.client = client; this.storageAlias = storageAlias; this.dbName = dbName; } // Expose identifiers for consumers that need deterministic naming getDbName() { return this.dbName; } getStorageAlias() { return this.storageAlias; } async info() { if (shouldUseDataScript(this.storageAlias)) { const backend = new DataScriptBackend(this.storageAlias, this.dbName); return backend.databaseInfo(); } // Exercise connectivity/auth via a trivial query; propagate errors to caller. const q = { find: [sym("?e")], where: [[sym("?e"), kw("db/id"), 0]] }; await this.query(q); return { "basis-t": -1, "db/alias": `${this.storageAlias}/${this.dbName}` }; } async transact(txData) { if (shouldUseDataScript(this.storageAlias)) { const backend = new DataScriptBackend(this.storageAlias, this.dbName); return backend.transact(txData); } return this.client.transact(this.storageAlias, this.dbName, txData); } async query(q, ...args) { if (shouldUseDataScript(this.storageAlias)) { const backend = new DataScriptBackend(this.storageAlias, this.dbName); validateSymbolicQuery(q); const ednQuery = [ kw("find"), ...q.find, ...(q.in && q.in.length ? [kw("in"), ...q.in] : []), kw("where"), ...q.where, ]; return backend.query(ednQuery, ...args); } // Server path: use /api/query with db descriptor as first arg const dbDescriptor = { "db/alias": `${this.storageAlias}/${this.dbName}`, }; return this.client.querySymbolic(q, [dbDescriptor, ...args]); } async entity(entityId, basisT = "-", options) { if (shouldUseDataScript(this.storageAlias)) { const backend = new DataScriptBackend(this.storageAlias, this.dbName); return backend.entity(entityId); } // Use the query API to retrieve entity via pull const q = { find: [[sym("pull"), sym("?e"), [kw("db/id")]]], where: [[sym("?e"), kw("db/id"), entityId]], }; const rows = await this.query(q); const first = Array.isArray(rows) ? rows[0]?.[0] : null; return first || { "db/id": entityId }; } async datoms(index, options = {}) { if (shouldUseDataScript(this.storageAlias)) { const backend = new DataScriptBackend(this.storageAlias, this.dbName); return backend.datoms(index, { e: options.e, a: options.a, v: options.v, limit: options.limit, offset: options.offset, }); } // Server exposes datoms via query; implement minimal AVET example const clauses = []; const e = options.e !== undefined ? options.e : sym("?e"); const a = options.a !== undefined ? kw(options.a) : sym("?a"); const v = options.v !== undefined ? options.v : sym("?v"); clauses.push([e, a, v]); const q = { find: [e, a, v], where: clauses }; const rows = await this.query(q); return rows.map((r) => ({ e: r[0], a: r[1], v: r[2], tx: 0, added: true })); } subscribeToEvents(onEvent, onError) { return this.client.subscribeToEvents(this.storageAlias, this.dbName, onEvent, onError); } } // ===== Factories ===== export function createDatomicClient(config) { return new DatomicClient(config); } export function createAnyDBClient(client, storageAlias, dbName) { return new AnyDBClient(client, storageAlias, dbName); } // Convenience export function pluckFirstColumn(rows) { if (!Array.isArray(rows)) return []; return rows.map((r) => (Array.isArray(r) ? r[0] : r)); } // Re-export DataScript helpers for consumers that need feature-detection export { shouldUseDataScript, isDataScriptAvailable }; export default DatomicClient; // ===== Schema helpers (idempotent ensure) ===== const __ensuredIdentsCache = new Map(); function getDbAliasCached(db) { return db.info().then((info) => info["db/alias"]); } function extractIdentName(entity) { const identVal = entity["db/ident"]; if (!identVal) return null; if (typeof identVal === "string") return identVal.startsWith(":") ? identVal.slice(1) : identVal; if (typeof identVal === "object" && identVal && identVal._type === "keyword") { return identVal.value; } return null; } export async function ensureAttributes(db, schemaEntities) { const alias = await getDbAliasCached(db); const cached = __ensuredIdentsCache.get(alias) || new Set(); // Compute desired idents const desired = schemaEntities .map(extractIdentName) .filter((v) => Boolean(v)); // Filter out those already ensured in this process const toCheck = desired.filter((n) => !cached.has(n)); if (toCheck.length === 0) return; // Determine which exist already in DB const existing = new Set(); for (const ident of toCheck) { try { const q = { find: [sym("?e")], where: [ [sym("?e"), kw("db/ident"), kw(ident)], ], }; const rows = await db.query(q); if (Array.isArray(rows) && rows.length > 0) existing.add(ident); } catch (_) { // ignore transient errors } } const missing = toCheck.filter((n) => !existing.has(n)); if (missing.length > 0) { const tx = schemaEntities.filter((e) => { const n = extractIdentName(e); return n != null && missing.includes(n); }); if (tx.length > 0) { await db.transact(tx); } } // Update cache const updated = new Set([...cached, ...toCheck]); __ensuredIdentsCache.set(alias, updated); }