@anysoftinc/anydb-sdk
Version:
AnyDB TypeScript SDK for querying and transacting with Datomic databases
432 lines (431 loc) • 15.7 kB
JavaScript
// 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);
}