refactor(core): update all functions to accept OcasStore (single param) #46
+244
-319
@@ -3,12 +3,10 @@
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import type { Hash, ListOptions, Store, VariableStore } from "@ocas/core";
|
||||
import type { Hash, ListOptions, OcasStore } from "@ocas/core";
|
||||
import {
|
||||
bootstrap,
|
||||
CasNodeNotFoundError,
|
||||
computeHash,
|
||||
createVariableStore,
|
||||
gc,
|
||||
getSchema,
|
||||
InvalidTagFormatError,
|
||||
@@ -24,7 +22,7 @@ import {
|
||||
walk,
|
||||
wrapEnvelope,
|
||||
} from "@ocas/core";
|
||||
import { openStore as openFsStore, prepareStore } from "@ocas/fs";
|
||||
import { openStore as openFsStore } from "@ocas/fs";
|
||||
|
||||
// ---- Argument parsing ----
|
||||
|
||||
@@ -34,7 +32,6 @@ type Flags = Record<string, string | boolean | string[]>;
|
||||
const VALUE_FLAGS = new Set([
|
||||
"home",
|
||||
"format",
|
||||
"var-db",
|
||||
"tag",
|
||||
"schema",
|
||||
"resolution",
|
||||
@@ -107,15 +104,11 @@ const storePath =
|
||||
: (process.env.OCAS_HOME ?? defaultStorePath);
|
||||
const compact = flags.json === true;
|
||||
|
||||
const defaultVarDbPath = join(storePath, "variables.db");
|
||||
const varDbPath =
|
||||
typeof flags["var-db"] === "string" ? flags["var-db"] : defaultVarDbPath;
|
||||
|
||||
// ---- Helpers ----
|
||||
|
||||
const inlineRender = flags.render === true || flags.r === true;
|
||||
|
||||
async function out(data: unknown, store?: Store): Promise<void> {
|
||||
async function out(data: unknown, store?: OcasStore): Promise<void> {
|
||||
if (
|
||||
inlineRender &&
|
||||
typeof data === "object" &&
|
||||
@@ -126,8 +119,6 @@ async function out(data: unknown, store?: Store): Promise<void> {
|
||||
const envelope = data as { type: string; value: unknown };
|
||||
const s = store ?? (await openStore());
|
||||
// renderDirect is synchronous; passing null options uses defaults.
|
||||
// varStore is intentionally omitted — inline render uses YAML fallback
|
||||
// only, custom templates require the full `ocas render` command.
|
||||
const output = renderDirect(envelope.type as Hash, envelope.value, s, null);
|
||||
process.stdout.write(`${output}\n`);
|
||||
return;
|
||||
@@ -165,24 +156,12 @@ async function readStdinJson(): Promise<unknown> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the filesystem-backed CAS store.
|
||||
* Automatically creates directory and bootstraps if needed.
|
||||
* If a varStore is provided, builtin schema variables are written to it during bootstrap.
|
||||
* Open the filesystem-backed OcasStore. Automatically creates directory and
|
||||
* bootstraps if needed.
|
||||
*/
|
||||
async function openStore(varStore?: VariableStore): Promise<Store> {
|
||||
async function openStore(): Promise<OcasStore> {
|
||||
const fullPath = resolve(storePath);
|
||||
return await openFsStore(fullPath, varStore);
|
||||
}
|
||||
|
||||
async function openStoreAndVarStore(): Promise<{
|
||||
store: Store;
|
||||
varStore: VariableStore;
|
||||
}> {
|
||||
const fullPath = resolve(storePath);
|
||||
const store = await prepareStore(fullPath);
|
||||
const varStore = createVariableStore(resolve(varDbPath), store);
|
||||
await bootstrap(store, varStore);
|
||||
return { store, varStore };
|
||||
return await openFsStore(fullPath);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -194,14 +173,14 @@ function isHash(input: string): boolean {
|
||||
|
||||
/**
|
||||
* Resolve a hash-or-name. If `input` already looks like a hash, return it as-is.
|
||||
* Otherwise, query the provided varStore for a variable with that exact name
|
||||
* and return the first match's value.
|
||||
* Otherwise, query the store's var sub-store for a variable with that exact
|
||||
* name and return the first match's value.
|
||||
*/
|
||||
function resolveHash(input: string, varStore: VariableStore): Hash {
|
||||
function resolveHash(input: string, store: OcasStore): Hash {
|
||||
if (isHash(input)) {
|
||||
return input as Hash;
|
||||
}
|
||||
const variants = varStore.list({ exactName: input });
|
||||
const variants = store.var.list({ exactName: input });
|
||||
const first = variants[0];
|
||||
if (!first) {
|
||||
die(`Error: Schema not found: ${input}`);
|
||||
@@ -309,165 +288,139 @@ async function cmdPut(args: string[]): Promise<void> {
|
||||
);
|
||||
if (isPipe && args[1])
|
||||
die("Cannot use --pipe/-p with a file argument. Use one or the other.");
|
||||
const { store, varStore } = await openStoreAndVarStore();
|
||||
try {
|
||||
const typeHash = resolveHash(typeHashOrName, varStore);
|
||||
const payload = isPipe
|
||||
? await readStdinJson()
|
||||
: readJsonFile(file as string);
|
||||
const store = await openStore();
|
||||
const typeHash = resolveHash(typeHashOrName, store);
|
||||
const payload = isPipe ? await readStdinJson() : readJsonFile(file as string);
|
||||
|
||||
// Schema nodes: use putSchema() which validates via isValidSchema() (recursive)
|
||||
// instead of ajv against meta-schema (which can't express recursive constraints)
|
||||
const metaHash = resolveHash("@ocas/schema", varStore);
|
||||
if (typeHash === metaHash) {
|
||||
try {
|
||||
const hash = await putSchema(store, payload as Record<string, unknown>);
|
||||
await out(await wrapEnvelope(store, "@ocas/output/put", hash), store);
|
||||
} catch (_e) {
|
||||
console.error(
|
||||
`Validation failed: payload in ${file} does not match schema ${typeHash}`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if schema exists
|
||||
const schema = getSchema(store, typeHash);
|
||||
if (schema === null) {
|
||||
console.error(`Schema not found: ${typeHash}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Validate payload against schema before storing
|
||||
const tempNode = { type: typeHash, payload, timestamp: Date.now() };
|
||||
if (!validate(store, tempNode)) {
|
||||
// Schema nodes: use putSchema() which validates via isValidSchema() (recursive)
|
||||
// instead of ajv against meta-schema (which can't express recursive constraints)
|
||||
const metaHash = resolveHash("@ocas/schema", store);
|
||||
if (typeHash === metaHash) {
|
||||
try {
|
||||
const hash = await putSchema(store, payload as Record<string, unknown>);
|
||||
await out(await wrapEnvelope(store, "@ocas/output/put", hash), store);
|
||||
} catch (_e) {
|
||||
console.error(
|
||||
`Validation failed: payload in ${file} does not match schema ${typeHash}`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const hash = await store.put(typeHash, payload);
|
||||
await out(await wrapEnvelope(store, "@ocas/output/put", hash), store);
|
||||
} finally {
|
||||
varStore.close();
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if schema exists
|
||||
const schema = getSchema(store, typeHash);
|
||||
if (schema === null) {
|
||||
console.error(`Schema not found: ${typeHash}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Validate payload against schema before storing
|
||||
const tempNode = { type: typeHash, payload, timestamp: Date.now() };
|
||||
if (!validate(store, tempNode)) {
|
||||
console.error(
|
||||
`Validation failed: payload in ${file} does not match schema ${typeHash}`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const hash = store.cas.put(typeHash, payload);
|
||||
await out(await wrapEnvelope(store, "@ocas/output/put", hash), store);
|
||||
}
|
||||
|
||||
async function cmdGet(args: string[]): Promise<void> {
|
||||
const input = args[0];
|
||||
if (!input) die("Usage: ocas get <hash-or-name>");
|
||||
const { store, varStore } = await openStoreAndVarStore();
|
||||
try {
|
||||
const hash = resolveHash(input, varStore);
|
||||
const node = store.get(hash);
|
||||
if (node === null) die(`Node not found: ${hash}`);
|
||||
await out(await wrapEnvelope(store, "@ocas/output/get", node), store);
|
||||
} finally {
|
||||
varStore.close();
|
||||
}
|
||||
const store = await openStore();
|
||||
const hash = resolveHash(input, store);
|
||||
const node = store.cas.get(hash);
|
||||
if (node === null) die(`Node not found: ${hash}`);
|
||||
await out(await wrapEnvelope(store, "@ocas/output/get", node), store);
|
||||
}
|
||||
|
||||
async function cmdHas(args: string[]): Promise<void> {
|
||||
const input = args[0];
|
||||
if (!input) die("Usage: ocas has <hash-or-name>");
|
||||
const { store, varStore } = await openStoreAndVarStore();
|
||||
try {
|
||||
const hash = resolveHash(input, varStore);
|
||||
await out(
|
||||
await wrapEnvelope(store, "@ocas/output/has", store.has(hash)),
|
||||
store,
|
||||
);
|
||||
} finally {
|
||||
varStore.close();
|
||||
}
|
||||
const store = await openStore();
|
||||
const hash = resolveHash(input, store);
|
||||
await out(
|
||||
await wrapEnvelope(store, "@ocas/output/has", store.cas.has(hash)),
|
||||
store,
|
||||
);
|
||||
}
|
||||
|
||||
async function cmdVerify(args: string[]): Promise<void> {
|
||||
const input = args[0];
|
||||
if (!input) die("Usage: ocas verify <hash-or-name>");
|
||||
const { store, varStore } = await openStoreAndVarStore();
|
||||
try {
|
||||
const hash = resolveHash(input, varStore);
|
||||
const node = store.get(hash);
|
||||
if (node === null) die(`Node not found: ${hash}`);
|
||||
const ok = await verify(hash, node);
|
||||
let status: string;
|
||||
if (!ok) {
|
||||
status = "corrupted";
|
||||
} else {
|
||||
status = validate(store, node) ? "ok" : "invalid";
|
||||
}
|
||||
await out(await wrapEnvelope(store, "@ocas/output/verify", status), store);
|
||||
} finally {
|
||||
varStore.close();
|
||||
const store = await openStore();
|
||||
const hash = resolveHash(input, store);
|
||||
const node = store.cas.get(hash);
|
||||
if (node === null) die(`Node not found: ${hash}`);
|
||||
const ok = await verify(hash, node);
|
||||
let status: string;
|
||||
if (!ok) {
|
||||
status = "corrupted";
|
||||
} else {
|
||||
status = validate(store, node) ? "ok" : "invalid";
|
||||
}
|
||||
await out(await wrapEnvelope(store, "@ocas/output/verify", status), store);
|
||||
}
|
||||
|
||||
async function cmdRefs(args: string[]): Promise<void> {
|
||||
const input = args[0];
|
||||
if (!input) die("Usage: ocas refs <hash-or-name>");
|
||||
const { store, varStore } = await openStoreAndVarStore();
|
||||
try {
|
||||
const hash = resolveHash(input, varStore);
|
||||
const node = store.get(hash);
|
||||
if (node === null) die(`Node not found: ${hash}`);
|
||||
const refHashes = refs(store, node);
|
||||
await out(await wrapEnvelope(store, "@ocas/output/refs", refHashes), store);
|
||||
} finally {
|
||||
varStore.close();
|
||||
}
|
||||
const store = await openStore();
|
||||
const hash = resolveHash(input, store);
|
||||
const node = store.cas.get(hash);
|
||||
if (node === null) die(`Node not found: ${hash}`);
|
||||
const refHashes = refs(store, node);
|
||||
await out(await wrapEnvelope(store, "@ocas/output/refs", refHashes), store);
|
||||
}
|
||||
|
||||
async function cmdWalk(args: string[]): Promise<void> {
|
||||
const input = args[0];
|
||||
if (!input) die("Usage: ocas walk <hash-or-name> [--format tree]");
|
||||
const { store, varStore } = await openStoreAndVarStore();
|
||||
try {
|
||||
const hash = resolveHash(input, varStore);
|
||||
const format = flags.format;
|
||||
const store = await openStore();
|
||||
const hash = resolveHash(input, store);
|
||||
const format = flags.format;
|
||||
|
||||
if (format === "tree") {
|
||||
const childMap = new Map<Hash, Hash[]>();
|
||||
walk(store, hash, (h, node) => {
|
||||
childMap.set(h, refs(store, node));
|
||||
});
|
||||
if (format === "tree") {
|
||||
const childMap = new Map<Hash, Hash[]>();
|
||||
walk(store, hash, (h, node) => {
|
||||
childMap.set(h, refs(store, node));
|
||||
});
|
||||
|
||||
const printed = new Set<Hash>();
|
||||
const lines: string[] = [];
|
||||
const printed = new Set<Hash>();
|
||||
const lines: string[] = [];
|
||||
|
||||
function printNode(h: Hash, prefix: string, isLast: boolean): void {
|
||||
const connector = prefix === "" ? "" : isLast ? "└── " : "├── ";
|
||||
if (printed.has(h)) {
|
||||
lines.push(`${prefix}${connector}${h} (seen)`);
|
||||
return;
|
||||
}
|
||||
printed.add(h);
|
||||
lines.push(`${prefix}${connector}${h}`);
|
||||
|
||||
const kids = childMap.get(h) ?? [];
|
||||
const childPrefix =
|
||||
prefix === "" ? "" : prefix + (isLast ? " " : "│ ");
|
||||
for (let i = 0; i < kids.length; i++) {
|
||||
printNode(kids[i] as Hash, childPrefix, i === kids.length - 1);
|
||||
}
|
||||
function printNode(h: Hash, prefix: string, isLast: boolean): void {
|
||||
const connector = prefix === "" ? "" : isLast ? "└── " : "├── ";
|
||||
if (printed.has(h)) {
|
||||
lines.push(`${prefix}${connector}${h} (seen)`);
|
||||
return;
|
||||
}
|
||||
printed.add(h);
|
||||
lines.push(`${prefix}${connector}${h}`);
|
||||
|
||||
printNode(hash, "", true);
|
||||
await out(
|
||||
await wrapEnvelope(store, "@ocas/output/walk", lines.join("\n")),
|
||||
store,
|
||||
);
|
||||
} else {
|
||||
const hashes: Hash[] = [];
|
||||
walk(store, hash, (h) => {
|
||||
hashes.push(h);
|
||||
});
|
||||
await out(await wrapEnvelope(store, "@ocas/output/walk", hashes), store);
|
||||
const kids = childMap.get(h) ?? [];
|
||||
const childPrefix =
|
||||
prefix === "" ? "" : prefix + (isLast ? " " : "│ ");
|
||||
for (let i = 0; i < kids.length; i++) {
|
||||
printNode(kids[i] as Hash, childPrefix, i === kids.length - 1);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
varStore.close();
|
||||
|
||||
printNode(hash, "", true);
|
||||
await out(
|
||||
await wrapEnvelope(store, "@ocas/output/walk", lines.join("\n")),
|
||||
store,
|
||||
);
|
||||
} else {
|
||||
const hashes: Hash[] = [];
|
||||
walk(store, hash, (h) => {
|
||||
hashes.push(h);
|
||||
});
|
||||
await out(await wrapEnvelope(store, "@ocas/output/walk", hashes), store);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -481,17 +434,11 @@ async function cmdHash(args: string[]): Promise<void> {
|
||||
);
|
||||
if (isPipe && args[1])
|
||||
die("Cannot use --pipe/-p with a file argument. Use one or the other.");
|
||||
const { store, varStore } = await openStoreAndVarStore();
|
||||
try {
|
||||
const typeHash = resolveHash(typeHashOrName, varStore);
|
||||
const payload = isPipe
|
||||
? await readStdinJson()
|
||||
: readJsonFile(file as string);
|
||||
const hash = await computeHash(typeHash, payload);
|
||||
await out(await wrapEnvelope(store, "@ocas/output/hash", hash), store);
|
||||
} finally {
|
||||
varStore.close();
|
||||
}
|
||||
const store = await openStore();
|
||||
const typeHash = resolveHash(typeHashOrName, store);
|
||||
const payload = isPipe ? await readStdinJson() : readJsonFile(file as string);
|
||||
const hash = await computeHash(typeHash, payload);
|
||||
await out(await wrapEnvelope(store, "@ocas/output/hash", hash), store);
|
||||
}
|
||||
|
||||
async function cmdRender(args: string[]): Promise<void> {
|
||||
@@ -508,7 +455,7 @@ async function cmdRender(args: string[]): Promise<void> {
|
||||
);
|
||||
}
|
||||
|
||||
const { store, varStore } = await openStoreAndVarStore();
|
||||
const store = await openStore();
|
||||
|
||||
// Parse numeric options
|
||||
const resolution =
|
||||
@@ -579,7 +526,6 @@ async function cmdRender(args: string[]): Promise<void> {
|
||||
...(resolution !== undefined && { resolution }),
|
||||
...(decay !== undefined && { decay }),
|
||||
...(epsilon !== undefined && { epsilon }),
|
||||
varStore,
|
||||
});
|
||||
process.stdout.write(`${output}\n`);
|
||||
} else {
|
||||
@@ -596,17 +542,15 @@ async function cmdRender(args: string[]): Promise<void> {
|
||||
process.stdout.write(`${output}\n`);
|
||||
}
|
||||
} else {
|
||||
const hash = resolveHash(input as string, varStore);
|
||||
const hash = resolveHash(input as string, store);
|
||||
const output = await renderAsync(store, hash, {
|
||||
...(resolution !== undefined && { resolution }),
|
||||
...(decay !== undefined && { decay }),
|
||||
...(epsilon !== undefined && { epsilon }),
|
||||
varStore,
|
||||
});
|
||||
// Output to stdout without JSON wrapping (raw output)
|
||||
process.stdout.write(`${output}\n`);
|
||||
}
|
||||
varStore.close();
|
||||
} catch (error) {
|
||||
if (error instanceof CasNodeNotFoundError) {
|
||||
die(`Error: Node not found: ${error.hash}`);
|
||||
@@ -634,7 +578,6 @@ async function cmdVarSet(args: string[]): Promise<void> {
|
||||
}
|
||||
|
||||
const store = await openStore();
|
||||
const varStore = createVariableStore(resolve(varDbPath), store);
|
||||
|
||||
try {
|
||||
// Parse tags/labels from --tag flags
|
||||
@@ -660,7 +603,7 @@ async function cmdVarSet(args: string[]): Promise<void> {
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const variable = varStore.set(name, value, options);
|
||||
const variable = store.var.set(name, value as Hash, options);
|
||||
await out(
|
||||
await wrapEnvelope(store, "@ocas/output/var-set", variable),
|
||||
store,
|
||||
@@ -674,8 +617,6 @@ async function cmdVarSet(args: string[]): Promise<void> {
|
||||
die(`Error: ${e.message}`);
|
||||
}
|
||||
throw e;
|
||||
} finally {
|
||||
varStore.close();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -687,21 +628,13 @@ async function cmdVarGet(args: string[]): Promise<void> {
|
||||
die("Usage: ocas var get <name> --schema <hash-or-name>");
|
||||
}
|
||||
|
||||
const { store, varStore } = await openStoreAndVarStore();
|
||||
|
||||
try {
|
||||
const schema = resolveHash(schemaInput, varStore);
|
||||
const variable = varStore.get(name, schema);
|
||||
if (variable === null) {
|
||||
die(`Error: Variable not found: name=${name}, schema=${schema}`);
|
||||
}
|
||||
await out(
|
||||
await wrapEnvelope(store, "@ocas/output/var-get", variable),
|
||||
store,
|
||||
);
|
||||
} finally {
|
||||
varStore.close();
|
||||
const store = await openStore();
|
||||
const schema = resolveHash(schemaInput, store);
|
||||
const variable = store.var.get(name, schema);
|
||||
if (variable === null) {
|
||||
die(`Error: Variable not found: name=${name}, schema=${schema}`);
|
||||
}
|
||||
await out(await wrapEnvelope(store, "@ocas/output/var-get", variable), store);
|
||||
}
|
||||
|
||||
async function cmdVarDelete(args: string[]): Promise<void> {
|
||||
@@ -718,20 +651,27 @@ async function cmdVarDelete(args: string[]): Promise<void> {
|
||||
);
|
||||
}
|
||||
|
||||
const { store, varStore } = await openStoreAndVarStore();
|
||||
const store = await openStore();
|
||||
|
||||
try {
|
||||
if (schemaInput !== undefined) {
|
||||
const schema = resolveHash(schemaInput, varStore);
|
||||
const schema = resolveHash(schemaInput, store);
|
||||
// Precise deletion: remove specific (name, schema) variant
|
||||
const variable = varStore.remove(name, schema);
|
||||
const variables = store.var.remove(name, schema);
|
||||
if (variables.length === 0) {
|
||||
throw new VariableNotFoundError(name, schema);
|
||||
}
|
||||
await out(
|
||||
await wrapEnvelope(store, "@ocas/output/var-delete", variable),
|
||||
await wrapEnvelope(
|
||||
store,
|
||||
"@ocas/output/var-delete",
|
||||
variables[0] as unknown,
|
||||
),
|
||||
store,
|
||||
);
|
||||
} else {
|
||||
// Batch deletion: remove all variants for this name
|
||||
const variables = varStore.remove(name);
|
||||
const variables = store.var.remove(name);
|
||||
await out(
|
||||
await wrapEnvelope(store, "@ocas/output/var-delete", variables),
|
||||
store,
|
||||
@@ -742,8 +682,6 @@ async function cmdVarDelete(args: string[]): Promise<void> {
|
||||
die(`Error: ${e.message}`);
|
||||
}
|
||||
throw e;
|
||||
} finally {
|
||||
varStore.close();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -760,16 +698,35 @@ async function cmdVarTag(args: string[]): Promise<void> {
|
||||
die("Usage: ocas var tag <name> --schema <hash-or-name> <operations...>");
|
||||
}
|
||||
|
||||
const { store, varStore } = await openStoreAndVarStore();
|
||||
const store = await openStore();
|
||||
|
||||
try {
|
||||
const schema = resolveHash(schemaInput, varStore);
|
||||
const schema = resolveHash(schemaInput, store);
|
||||
const { tags, labels, deleteNames } = parseTagsLabels(tagArgs);
|
||||
|
||||
const variable = varStore.tag(name, schema, {
|
||||
...(Object.keys(tags).length > 0 && { add: tags }),
|
||||
...(labels.length > 0 && { addLabels: labels }),
|
||||
...(deleteNames.length > 0 && { delete: deleteNames }),
|
||||
// VarStore.set with options replaces all tags/labels — to express
|
||||
// "add some / delete some / preserve the rest", merge against the current.
|
||||
const existing = store.var.get(name, schema);
|
||||
if (existing === null) {
|
||||
throw new VariableNotFoundError(name, schema);
|
||||
}
|
||||
const newTags: Record<string, string> = { ...existing.tags };
|
||||
const newLabels: string[] = [...existing.labels];
|
||||
for (const k of deleteNames) {
|
||||
delete newTags[k];
|
||||
const idx = newLabels.indexOf(k);
|
||||
if (idx !== -1) newLabels.splice(idx, 1);
|
||||
}
|
||||
for (const [k, v] of Object.entries(tags)) {
|
||||
newTags[k] = v;
|
||||
}
|
||||
for (const lb of labels) {
|
||||
if (!newLabels.includes(lb)) newLabels.push(lb);
|
||||
}
|
||||
|
||||
const variable = store.var.set(name, existing.value, {
|
||||
tags: newTags,
|
||||
labels: newLabels,
|
||||
});
|
||||
|
||||
await out(
|
||||
@@ -785,8 +742,6 @@ async function cmdVarTag(args: string[]): Promise<void> {
|
||||
die(`Error: ${e.message}`);
|
||||
}
|
||||
throw e;
|
||||
} finally {
|
||||
varStore.close();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -798,41 +753,37 @@ async function cmdVarHistory(args: string[]): Promise<void> {
|
||||
die("Usage: ocas var history <name> [--schema <hash-or-name>]");
|
||||
}
|
||||
|
||||
const { store, varStore } = await openStoreAndVarStore();
|
||||
|
||||
try {
|
||||
let schema: Hash;
|
||||
if (schemaInput !== undefined) {
|
||||
schema = resolveHash(schemaInput, varStore);
|
||||
} else {
|
||||
const variants = varStore.list({ exactName: name });
|
||||
if (variants.length === 0) {
|
||||
die(`Error: Variable not found: ${name}`);
|
||||
}
|
||||
if (variants.length > 1) {
|
||||
die(
|
||||
`Error: Multiple schema variants for "${name}"; use --schema to disambiguate`,
|
||||
);
|
||||
}
|
||||
schema = (variants[0] as { schema: string }).schema as Hash;
|
||||
const store = await openStore();
|
||||
let schema: Hash;
|
||||
if (schemaInput !== undefined) {
|
||||
schema = resolveHash(schemaInput, store);
|
||||
} else {
|
||||
const variants = store.var.list({ exactName: name });
|
||||
if (variants.length === 0) {
|
||||
die(`Error: Variable not found: ${name}`);
|
||||
}
|
||||
|
||||
const values = varStore.history(name, schema);
|
||||
if (values.length === 0) {
|
||||
die(`Error: Variable not found: name=${name}, schema=${schema}`);
|
||||
if (variants.length > 1) {
|
||||
die(
|
||||
`Error: Multiple schema variants for "${name}"; use --schema to disambiguate`,
|
||||
);
|
||||
}
|
||||
|
||||
await out(
|
||||
await wrapEnvelope(store, "@ocas/output/var-history", {
|
||||
name,
|
||||
schema,
|
||||
values,
|
||||
}),
|
||||
store,
|
||||
);
|
||||
} finally {
|
||||
varStore.close();
|
||||
schema = (variants[0] as { schema: string }).schema as Hash;
|
||||
}
|
||||
|
||||
const entries = store.var.history(name, schema);
|
||||
if (entries.length === 0) {
|
||||
die(`Error: Variable not found: name=${name}, schema=${schema}`);
|
||||
}
|
||||
|
||||
const values = entries.map((e) => e.value);
|
||||
await out(
|
||||
await wrapEnvelope(store, "@ocas/output/var-history", {
|
||||
name,
|
||||
schema,
|
||||
values,
|
||||
}),
|
||||
store,
|
||||
);
|
||||
}
|
||||
|
||||
async function cmdVarList(args: string[]): Promise<void> {
|
||||
@@ -841,13 +792,11 @@ async function cmdVarList(args: string[]): Promise<void> {
|
||||
const tagFlags = flags.tag;
|
||||
const listOpts = parseListOptions();
|
||||
|
||||
const { store, varStore } = await openStoreAndVarStore();
|
||||
const store = await openStore();
|
||||
|
||||
try {
|
||||
const schema =
|
||||
schemaInput !== undefined
|
||||
? resolveHash(schemaInput, varStore)
|
||||
: undefined;
|
||||
schemaInput !== undefined ? resolveHash(schemaInput, store) : undefined;
|
||||
// Parse tags/labels from --tag flags
|
||||
const tagArgs = Array.isArray(tagFlags)
|
||||
? tagFlags
|
||||
@@ -861,7 +810,7 @@ async function cmdVarList(args: string[]): Promise<void> {
|
||||
die("Error: Cannot use deletion syntax (:name) in var list filters");
|
||||
}
|
||||
|
||||
const variables = varStore.list({
|
||||
const variables = store.var.list({
|
||||
namePrefix,
|
||||
...(schema !== undefined ? { schema } : {}),
|
||||
...(Object.keys(tags).length > 0 ? { tags } : {}),
|
||||
@@ -877,8 +826,6 @@ async function cmdVarList(args: string[]): Promise<void> {
|
||||
die(`Error: ${e.message}`);
|
||||
}
|
||||
throw e;
|
||||
} finally {
|
||||
varStore.close();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -892,12 +839,12 @@ async function cmdTemplateSet(args: string[]): Promise<void> {
|
||||
);
|
||||
}
|
||||
|
||||
const { store, varStore } = await openStoreAndVarStore();
|
||||
const store = await openStore();
|
||||
|
||||
try {
|
||||
const schemaHash = resolveHash(schemaInput, varStore);
|
||||
const schemaHash = resolveHash(schemaInput, store);
|
||||
// Validate schema hash exists in CAS
|
||||
if (!store.has(schemaHash)) {
|
||||
if (!store.cas.has(schemaHash)) {
|
||||
die(`Error: Schema hash not found in CAS: ${schemaHash}`);
|
||||
}
|
||||
|
||||
@@ -931,12 +878,12 @@ async function cmdTemplateSet(args: string[]): Promise<void> {
|
||||
}
|
||||
|
||||
// Store content in CAS under @string schema
|
||||
const stringHash = resolveHash("@ocas/string", varStore);
|
||||
const contentHash = await store.put(stringHash, content);
|
||||
const stringHash = resolveHash("@ocas/string", store);
|
||||
const contentHash = store.cas.put(stringHash, content);
|
||||
|
||||
// Create variable binding: @ocas/template/text/<schema-hash>
|
||||
const varName = `@ocas/template/text/${schemaHash}`;
|
||||
varStore.set(varName, contentHash);
|
||||
store.var.set(varName, contentHash);
|
||||
|
||||
await out(
|
||||
await wrapEnvelope(store, "@ocas/output/template-set", {
|
||||
@@ -950,8 +897,6 @@ async function cmdTemplateSet(args: string[]): Promise<void> {
|
||||
die(`Error: ${e.message}`);
|
||||
}
|
||||
throw e;
|
||||
} finally {
|
||||
varStore.close();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -962,59 +907,49 @@ async function cmdTemplateGet(args: string[]): Promise<void> {
|
||||
die("Usage: ocas template get <schema-hash-or-name>");
|
||||
}
|
||||
|
||||
const { store, varStore } = await openStoreAndVarStore();
|
||||
const store = await openStore();
|
||||
const schemaHash = resolveHash(schemaInput, store);
|
||||
const varName = `@ocas/template/text/${schemaHash}`;
|
||||
const stringHash = resolveHash("@ocas/string", store);
|
||||
const variable = store.var.get(varName, stringHash);
|
||||
|
||||
try {
|
||||
const schemaHash = resolveHash(schemaInput, varStore);
|
||||
const varName = `@ocas/template/text/${schemaHash}`;
|
||||
const stringHash = resolveHash("@ocas/string", varStore);
|
||||
const variable = varStore.get(varName, stringHash);
|
||||
|
||||
if (variable === null) {
|
||||
die(`Error: Template not found for schema: ${schemaHash}`);
|
||||
}
|
||||
|
||||
// Get the content from CAS
|
||||
const node = store.get(variable.value);
|
||||
if (node === null) {
|
||||
die(`Error: Content not found in CAS: ${variable.value}`);
|
||||
}
|
||||
|
||||
await out(
|
||||
await wrapEnvelope(
|
||||
store,
|
||||
"@ocas/output/template-get",
|
||||
node.payload as string,
|
||||
),
|
||||
store,
|
||||
);
|
||||
} finally {
|
||||
varStore.close();
|
||||
if (variable === null) {
|
||||
die(`Error: Template not found for schema: ${schemaHash}`);
|
||||
}
|
||||
|
||||
// Get the content from CAS
|
||||
const node = store.cas.get(variable.value);
|
||||
if (node === null) {
|
||||
die(`Error: Content not found in CAS: ${variable.value}`);
|
||||
}
|
||||
|
||||
await out(
|
||||
await wrapEnvelope(
|
||||
store,
|
||||
"@ocas/output/template-get",
|
||||
node.payload as string,
|
||||
),
|
||||
store,
|
||||
);
|
||||
}
|
||||
|
||||
async function cmdTemplateList(_args: string[]): Promise<void> {
|
||||
const { store, varStore } = await openStoreAndVarStore();
|
||||
const store = await openStore();
|
||||
const stringHash = resolveHash("@ocas/string", store);
|
||||
const variables = store.var.list({
|
||||
namePrefix: "@ocas/template/text/",
|
||||
schema: stringHash,
|
||||
});
|
||||
|
||||
try {
|
||||
const stringHash = resolveHash("@ocas/string", varStore);
|
||||
const variables = varStore.list({
|
||||
namePrefix: "@ocas/template/text/",
|
||||
schema: stringHash,
|
||||
});
|
||||
const templates = variables.map((v) => ({
|
||||
schemaHash: v.name.replace("@ocas/template/text/", ""),
|
||||
contentHash: v.value,
|
||||
}));
|
||||
|
||||
const templates = variables.map((v) => ({
|
||||
schemaHash: v.name.replace("@ocas/template/text/", ""),
|
||||
contentHash: v.value,
|
||||
}));
|
||||
|
||||
await out(
|
||||
await wrapEnvelope(store, "@ocas/output/template-list", templates),
|
||||
store,
|
||||
);
|
||||
} finally {
|
||||
varStore.close();
|
||||
}
|
||||
await out(
|
||||
await wrapEnvelope(store, "@ocas/output/template-list", templates),
|
||||
store,
|
||||
);
|
||||
}
|
||||
|
||||
async function cmdTemplateDelete(args: string[]): Promise<void> {
|
||||
@@ -1024,13 +959,16 @@ async function cmdTemplateDelete(args: string[]): Promise<void> {
|
||||
die("Usage: ocas template delete <schema-hash-or-name>");
|
||||
}
|
||||
|
||||
const { store, varStore } = await openStoreAndVarStore();
|
||||
const store = await openStore();
|
||||
|
||||
try {
|
||||
const schemaHash = resolveHash(schemaInput, varStore);
|
||||
const schemaHash = resolveHash(schemaInput, store);
|
||||
const varName = `@ocas/template/text/${schemaHash}`;
|
||||
const stringHash = resolveHash("@ocas/string", varStore);
|
||||
varStore.remove(varName, stringHash);
|
||||
const stringHash = resolveHash("@ocas/string", store);
|
||||
const removed = store.var.remove(varName, stringHash);
|
||||
if (removed.length === 0) {
|
||||
throw new VariableNotFoundError(varName, stringHash);
|
||||
}
|
||||
|
||||
await out(
|
||||
await wrapEnvelope(store, "@ocas/output/template-delete", {
|
||||
@@ -1043,21 +981,13 @@ async function cmdTemplateDelete(args: string[]): Promise<void> {
|
||||
die(`Error: Template not found for schema: ${schemaInput}`);
|
||||
}
|
||||
throw e;
|
||||
} finally {
|
||||
varStore.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function cmdGc(_args: string[]): Promise<void> {
|
||||
const store = await openStore();
|
||||
const varStore = createVariableStore(varDbPath, store);
|
||||
|
||||
try {
|
||||
const stats = gc(store, varStore);
|
||||
await out(await wrapEnvelope(store, "@ocas/output/gc", stats), store);
|
||||
} finally {
|
||||
varStore.close();
|
||||
}
|
||||
const stats = gc(store);
|
||||
await out(await wrapEnvelope(store, "@ocas/output/gc", stats), store);
|
||||
}
|
||||
|
||||
async function cmdList(_args: string[]): Promise<void> {
|
||||
@@ -1065,20 +995,16 @@ async function cmdList(_args: string[]): Promise<void> {
|
||||
if (typeof typeFlag !== "string")
|
||||
die("Usage: ocas list --type <hash-or-name>");
|
||||
const opts = parseListOptions();
|
||||
const { store, varStore } = await openStoreAndVarStore();
|
||||
try {
|
||||
const typeHash = resolveHash(typeFlag, varStore);
|
||||
const entries = store.listByType(typeHash, opts);
|
||||
await out(await wrapEnvelope(store, "@ocas/output/list", entries), store);
|
||||
} finally {
|
||||
varStore.close();
|
||||
}
|
||||
const store = await openStore();
|
||||
const typeHash = resolveHash(typeFlag, store);
|
||||
const entries = store.cas.listByType(typeHash, opts);
|
||||
await out(await wrapEnvelope(store, "@ocas/output/list", entries), store);
|
||||
}
|
||||
|
||||
async function cmdListMeta(_args: string[]): Promise<void> {
|
||||
const opts = parseListOptions();
|
||||
const store = await openStore();
|
||||
const entries = store.listMeta(opts);
|
||||
const entries = store.cas.listMeta(opts);
|
||||
await out(
|
||||
await wrapEnvelope(store, "@ocas/output/list-meta", entries),
|
||||
store,
|
||||
@@ -1088,7 +1014,7 @@ async function cmdListMeta(_args: string[]): Promise<void> {
|
||||
async function cmdListSchema(_args: string[]): Promise<void> {
|
||||
const opts = parseListOptions();
|
||||
const store = await openStore();
|
||||
const entries = store.listSchemas(opts);
|
||||
const entries = store.cas.listSchemas(opts);
|
||||
await out(
|
||||
await wrapEnvelope(store, "@ocas/output/list-schema", entries),
|
||||
store,
|
||||
@@ -1132,7 +1058,6 @@ Commands:
|
||||
|
||||
Flags:
|
||||
--home <path> Store directory (default: $OCAS_HOME or ~/.ocas)
|
||||
--var-db <path> Variable database path (default: <store>/variables.db)
|
||||
--json Compact JSON output
|
||||
--render, -r Render output inline (equivalent to | ocas render -p)
|
||||
--schema <hash> Schema hash filter for var get/delete/tag/list
|
||||
|
||||
@@ -40,7 +40,6 @@ Commands:
|
||||
|
||||
Flags:
|
||||
--home <path> Store directory (default: $OCAS_HOME or ~/.ocas)
|
||||
--var-db <path> Variable database path (default: <store>/variables.db)
|
||||
--json Compact JSON output
|
||||
--render, -r Render output inline (equivalent to | ocas render -p)
|
||||
--schema <hash> Schema hash filter for var get/delete/tag/list
|
||||
@@ -86,52 +85,10 @@ exports[`Phase 3: Variable System 3.3 var list shows all variables 1`] = `
|
||||
"value": [
|
||||
{
|
||||
"labels": [],
|
||||
"name": "@ocas/schema",
|
||||
"schema": "CTS5P6RD8HMCS",
|
||||
"name": "@myapp/config",
|
||||
"schema": "FRBAB1BF0ZBCS",
|
||||
"tags": {},
|
||||
"value": "CTS5P6RD8HMCS",
|
||||
},
|
||||
{
|
||||
"labels": [],
|
||||
"name": "@ocas/string",
|
||||
"schema": "CTS5P6RD8HMCS",
|
||||
"tags": {},
|
||||
"value": "7VQ43ZSJTEWA7",
|
||||
},
|
||||
{
|
||||
"labels": [],
|
||||
"name": "@ocas/number",
|
||||
"schema": "CTS5P6RD8HMCS",
|
||||
"tags": {},
|
||||
"value": "BEAZQGKVXMZT8",
|
||||
},
|
||||
{
|
||||
"labels": [],
|
||||
"name": "@ocas/integer",
|
||||
"schema": "CTS5P6RD8HMCS",
|
||||
"tags": {},
|
||||
"value": "B26JM4PBHPAFK",
|
||||
},
|
||||
{
|
||||
"labels": [],
|
||||
"name": "@ocas/boolean",
|
||||
"schema": "CTS5P6RD8HMCS",
|
||||
"tags": {},
|
||||
"value": "1AVHCXEJVDCPP",
|
||||
},
|
||||
{
|
||||
"labels": [],
|
||||
"name": "@ocas/bool",
|
||||
"schema": "CTS5P6RD8HMCS",
|
||||
"tags": {},
|
||||
"value": "1AVHCXEJVDCPP",
|
||||
},
|
||||
{
|
||||
"labels": [],
|
||||
"name": "@ocas/object",
|
||||
"schema": "CTS5P6RD8HMCS",
|
||||
"tags": {},
|
||||
"value": "944RT37WX1PQ5",
|
||||
"value": "9W3MGR3184QYE",
|
||||
},
|
||||
{
|
||||
"labels": [],
|
||||
@@ -140,6 +97,27 @@ exports[`Phase 3: Variable System 3.3 var list shows all variables 1`] = `
|
||||
"tags": {},
|
||||
"value": "D45CW047XS17Y",
|
||||
},
|
||||
{
|
||||
"labels": [],
|
||||
"name": "@ocas/bool",
|
||||
"schema": "CTS5P6RD8HMCS",
|
||||
"tags": {},
|
||||
"value": "1AVHCXEJVDCPP",
|
||||
},
|
||||
{
|
||||
"labels": [],
|
||||
"name": "@ocas/boolean",
|
||||
"schema": "CTS5P6RD8HMCS",
|
||||
"tags": {},
|
||||
"value": "1AVHCXEJVDCPP",
|
||||
},
|
||||
{
|
||||
"labels": [],
|
||||
"name": "@ocas/integer",
|
||||
"schema": "CTS5P6RD8HMCS",
|
||||
"tags": {},
|
||||
"value": "B26JM4PBHPAFK",
|
||||
},
|
||||
{
|
||||
"labels": [],
|
||||
"name": "@ocas/null",
|
||||
@@ -149,10 +127,24 @@ exports[`Phase 3: Variable System 3.3 var list shows all variables 1`] = `
|
||||
},
|
||||
{
|
||||
"labels": [],
|
||||
"name": "@ocas/output/put",
|
||||
"name": "@ocas/number",
|
||||
"schema": "CTS5P6RD8HMCS",
|
||||
"tags": {},
|
||||
"value": "4ZHWK21APCFZ5",
|
||||
"value": "BEAZQGKVXMZT8",
|
||||
},
|
||||
{
|
||||
"labels": [],
|
||||
"name": "@ocas/object",
|
||||
"schema": "CTS5P6RD8HMCS",
|
||||
"tags": {},
|
||||
"value": "944RT37WX1PQ5",
|
||||
},
|
||||
{
|
||||
"labels": [],
|
||||
"name": "@ocas/output/gc",
|
||||
"schema": "CTS5P6RD8HMCS",
|
||||
"tags": {},
|
||||
"value": "7KHZTY010988K",
|
||||
},
|
||||
{
|
||||
"labels": [],
|
||||
@@ -175,27 +167,6 @@ exports[`Phase 3: Variable System 3.3 var list shows all variables 1`] = `
|
||||
"tags": {},
|
||||
"value": "1B24CBF95Q5G6",
|
||||
},
|
||||
{
|
||||
"labels": [],
|
||||
"name": "@ocas/output/verify",
|
||||
"schema": "CTS5P6RD8HMCS",
|
||||
"tags": {},
|
||||
"value": "52HEFB52BD0GF",
|
||||
},
|
||||
{
|
||||
"labels": [],
|
||||
"name": "@ocas/output/refs",
|
||||
"schema": "CTS5P6RD8HMCS",
|
||||
"tags": {},
|
||||
"value": "2TKP4RGBJ4V43",
|
||||
},
|
||||
{
|
||||
"labels": [],
|
||||
"name": "@ocas/output/walk",
|
||||
"schema": "CTS5P6RD8HMCS",
|
||||
"tags": {},
|
||||
"value": "4HG6MD3XG5H5C",
|
||||
},
|
||||
{
|
||||
"labels": [],
|
||||
"name": "@ocas/output/list",
|
||||
@@ -219,52 +190,24 @@ exports[`Phase 3: Variable System 3.3 var list shows all variables 1`] = `
|
||||
},
|
||||
{
|
||||
"labels": [],
|
||||
"name": "@ocas/output/var-set",
|
||||
"name": "@ocas/output/put",
|
||||
"schema": "CTS5P6RD8HMCS",
|
||||
"tags": {},
|
||||
"value": "0Q5EMYK4SYSS9",
|
||||
"value": "4ZHWK21APCFZ5",
|
||||
},
|
||||
{
|
||||
"labels": [],
|
||||
"name": "@ocas/output/var-get",
|
||||
"name": "@ocas/output/refs",
|
||||
"schema": "CTS5P6RD8HMCS",
|
||||
"tags": {},
|
||||
"value": "7C75FQT98KKQD",
|
||||
"value": "2TKP4RGBJ4V43",
|
||||
},
|
||||
{
|
||||
"labels": [],
|
||||
"name": "@ocas/output/var-delete",
|
||||
"name": "@ocas/output/template-delete",
|
||||
"schema": "CTS5P6RD8HMCS",
|
||||
"tags": {},
|
||||
"value": "C3MYPR5RGQFZT",
|
||||
},
|
||||
{
|
||||
"labels": [],
|
||||
"name": "@ocas/output/var-tag",
|
||||
"schema": "CTS5P6RD8HMCS",
|
||||
"tags": {},
|
||||
"value": "9103EYRMM949A",
|
||||
},
|
||||
{
|
||||
"labels": [],
|
||||
"name": "@ocas/output/var-list",
|
||||
"schema": "CTS5P6RD8HMCS",
|
||||
"tags": {},
|
||||
"value": "AF0XACGXHPMC1",
|
||||
},
|
||||
{
|
||||
"labels": [],
|
||||
"name": "@ocas/output/var-history",
|
||||
"schema": "CTS5P6RD8HMCS",
|
||||
"tags": {},
|
||||
"value": "EVZJS80TRFKE1",
|
||||
},
|
||||
{
|
||||
"labels": [],
|
||||
"name": "@ocas/output/template-set",
|
||||
"schema": "CTS5P6RD8HMCS",
|
||||
"tags": {},
|
||||
"value": "BJDHPAE4Q8TXM",
|
||||
"value": "BY7BGZJND3N7R",
|
||||
},
|
||||
{
|
||||
"labels": [],
|
||||
@@ -282,24 +225,80 @@ exports[`Phase 3: Variable System 3.3 var list shows all variables 1`] = `
|
||||
},
|
||||
{
|
||||
"labels": [],
|
||||
"name": "@ocas/output/template-delete",
|
||||
"name": "@ocas/output/template-set",
|
||||
"schema": "CTS5P6RD8HMCS",
|
||||
"tags": {},
|
||||
"value": "BY7BGZJND3N7R",
|
||||
"value": "BJDHPAE4Q8TXM",
|
||||
},
|
||||
{
|
||||
"labels": [],
|
||||
"name": "@ocas/output/gc",
|
||||
"name": "@ocas/output/var-delete",
|
||||
"schema": "CTS5P6RD8HMCS",
|
||||
"tags": {},
|
||||
"value": "7KHZTY010988K",
|
||||
"value": "C3MYPR5RGQFZT",
|
||||
},
|
||||
{
|
||||
"labels": [],
|
||||
"name": "@myapp/config",
|
||||
"schema": "FRBAB1BF0ZBCS",
|
||||
"name": "@ocas/output/var-get",
|
||||
"schema": "CTS5P6RD8HMCS",
|
||||
"tags": {},
|
||||
"value": "9W3MGR3184QYE",
|
||||
"value": "7C75FQT98KKQD",
|
||||
},
|
||||
{
|
||||
"labels": [],
|
||||
"name": "@ocas/output/var-history",
|
||||
"schema": "CTS5P6RD8HMCS",
|
||||
"tags": {},
|
||||
"value": "EVZJS80TRFKE1",
|
||||
},
|
||||
{
|
||||
"labels": [],
|
||||
"name": "@ocas/output/var-list",
|
||||
"schema": "CTS5P6RD8HMCS",
|
||||
"tags": {},
|
||||
"value": "AF0XACGXHPMC1",
|
||||
},
|
||||
{
|
||||
"labels": [],
|
||||
"name": "@ocas/output/var-set",
|
||||
"schema": "CTS5P6RD8HMCS",
|
||||
"tags": {},
|
||||
"value": "0Q5EMYK4SYSS9",
|
||||
},
|
||||
{
|
||||
"labels": [],
|
||||
"name": "@ocas/output/var-tag",
|
||||
"schema": "CTS5P6RD8HMCS",
|
||||
"tags": {},
|
||||
"value": "9103EYRMM949A",
|
||||
},
|
||||
{
|
||||
"labels": [],
|
||||
"name": "@ocas/output/verify",
|
||||
"schema": "CTS5P6RD8HMCS",
|
||||
"tags": {},
|
||||
"value": "52HEFB52BD0GF",
|
||||
},
|
||||
{
|
||||
"labels": [],
|
||||
"name": "@ocas/output/walk",
|
||||
"schema": "CTS5P6RD8HMCS",
|
||||
"tags": {},
|
||||
"value": "4HG6MD3XG5H5C",
|
||||
},
|
||||
{
|
||||
"labels": [],
|
||||
"name": "@ocas/schema",
|
||||
"schema": "CTS5P6RD8HMCS",
|
||||
"tags": {},
|
||||
"value": "CTS5P6RD8HMCS",
|
||||
},
|
||||
{
|
||||
"labels": [],
|
||||
"name": "@ocas/string",
|
||||
"schema": "CTS5P6RD8HMCS",
|
||||
"tags": {},
|
||||
"value": "7VQ43ZSJTEWA7",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
@@ -38,17 +38,16 @@ describe("ocas binary", () => {
|
||||
|
||||
describe("Phase 7: Edge Cases", () => {
|
||||
let tmpStore: string;
|
||||
let varDbPath: string;
|
||||
let typeHash: string;
|
||||
let nodeHash: string;
|
||||
|
||||
async function runCli(
|
||||
args: string[],
|
||||
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
|
||||
const proc = Bun.spawn(
|
||||
["bun", entrypoint, "--home", tmpStore, "--var-db", varDbPath, ...args],
|
||||
{ stdout: "pipe", stderr: "pipe" },
|
||||
);
|
||||
const proc = Bun.spawn(["bun", entrypoint, "--home", tmpStore, ...args], {
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
const exitCode = await proc.exited;
|
||||
const stdout = (await new Response(proc.stdout).text()).trim();
|
||||
const stderr = (await new Response(proc.stderr).text()).trim();
|
||||
@@ -57,7 +56,6 @@ describe("Phase 7: Edge Cases", () => {
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpStore = mkdtempSync(join(tmpdir(), "ocas-e2e-"));
|
||||
varDbPath = join(tmpStore, "variables.db");
|
||||
|
||||
const schemaFile = join(tmpStore, "test-schema.json");
|
||||
writeFileSync(
|
||||
@@ -134,16 +132,7 @@ describe("Phase 7: Edge Cases", () => {
|
||||
const fileAsStore = join(tmpStore, "not-a-directory");
|
||||
writeFileSync(fileAsStore, "test");
|
||||
const proc = Bun.spawn(
|
||||
[
|
||||
"bun",
|
||||
entrypoint,
|
||||
"--home",
|
||||
fileAsStore,
|
||||
"--var-db",
|
||||
varDbPath,
|
||||
"get",
|
||||
"AAAAAAAAAAAAA",
|
||||
],
|
||||
["bun", entrypoint, "--home", fileAsStore, "get", "AAAAAAAAAAAAA"],
|
||||
{ stdout: "pipe", stderr: "pipe" },
|
||||
);
|
||||
const exitCode = await proc.exited;
|
||||
@@ -157,17 +146,16 @@ describe("Phase 7: Edge Cases", () => {
|
||||
|
||||
describe("Phase 3: Variable System", () => {
|
||||
let tmpStore: string;
|
||||
let varDbPath: string;
|
||||
let typeHash: string;
|
||||
let nodeHash: string;
|
||||
|
||||
async function runCli(
|
||||
args: string[],
|
||||
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
|
||||
const proc = Bun.spawn(
|
||||
["bun", entrypoint, "--home", tmpStore, "--var-db", varDbPath, ...args],
|
||||
{ stdout: "pipe", stderr: "pipe" },
|
||||
);
|
||||
const proc = Bun.spawn(["bun", entrypoint, "--home", tmpStore, ...args], {
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
const exitCode = await proc.exited;
|
||||
const stdout = (await new Response(proc.stdout).text()).trim();
|
||||
const stderr = (await new Response(proc.stderr).text()).trim();
|
||||
@@ -176,7 +164,6 @@ describe("Phase 3: Variable System", () => {
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpStore = mkdtempSync(join(tmpdir(), "ocas-e2e-"));
|
||||
varDbPath = join(tmpStore, "variables.db");
|
||||
|
||||
const schemaFile = join(tmpStore, "test-schema.json");
|
||||
writeFileSync(
|
||||
@@ -233,7 +220,11 @@ describe("Phase 3: Variable System", () => {
|
||||
test("3.3 var list shows all variables", async () => {
|
||||
const { stdout, exitCode } = await runCli(["var", "list"]);
|
||||
expect(exitCode).toBe(0);
|
||||
expect(stripVolatile(stdout)).toMatchSnapshot();
|
||||
const stripped = stripVolatile(stdout) as { value: { name: string }[] };
|
||||
stripped.value.sort((a, b) =>
|
||||
a.name < b.name ? -1 : a.name > b.name ? 1 : 0,
|
||||
);
|
||||
expect(stripped).toMatchSnapshot();
|
||||
expect(stdout).toContain("@myapp/config");
|
||||
});
|
||||
|
||||
@@ -347,16 +338,15 @@ describe("Phase 3: Variable System", () => {
|
||||
|
||||
describe("Phase 4: Template System", () => {
|
||||
let tmpStore: string;
|
||||
let varDbPath: string;
|
||||
let typeHash: string;
|
||||
|
||||
async function runCli(
|
||||
args: string[],
|
||||
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
|
||||
const proc = Bun.spawn(
|
||||
["bun", entrypoint, "--home", tmpStore, "--var-db", varDbPath, ...args],
|
||||
{ stdout: "pipe", stderr: "pipe" },
|
||||
);
|
||||
const proc = Bun.spawn(["bun", entrypoint, "--home", tmpStore, ...args], {
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
const exitCode = await proc.exited;
|
||||
const stdout = (await new Response(proc.stdout).text()).trim();
|
||||
const stderr = (await new Response(proc.stderr).text()).trim();
|
||||
@@ -365,7 +355,6 @@ describe("Phase 4: Template System", () => {
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpStore = mkdtempSync(join(tmpdir(), "ocas-e2e-"));
|
||||
varDbPath = join(tmpStore, "variables.db");
|
||||
|
||||
const schemaFile = join(tmpStore, "test-schema.json");
|
||||
writeFileSync(
|
||||
|
||||
@@ -7,13 +7,11 @@ import { envValue } from "./helpers";
|
||||
const entrypoint = resolve(import.meta.dir, "../src/index.ts");
|
||||
|
||||
let tmpStore: string;
|
||||
let varDbPath: string;
|
||||
let typeHash: string;
|
||||
let nodeHash: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpStore = mkdtempSync(join(tmpdir(), "ocas-e2e-"));
|
||||
varDbPath = join(tmpStore, "variables.db");
|
||||
|
||||
const schemaFile = join(tmpStore, "test-schema.json");
|
||||
writeFileSync(
|
||||
@@ -49,10 +47,10 @@ afterAll(() => {
|
||||
async function runCli(
|
||||
args: string[],
|
||||
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
|
||||
const proc = Bun.spawn(
|
||||
["bun", entrypoint, "--home", tmpStore, "--var-db", varDbPath, ...args],
|
||||
{ stdout: "pipe", stderr: "pipe" },
|
||||
);
|
||||
const proc = Bun.spawn(["bun", entrypoint, "--home", tmpStore, ...args], {
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
const exitCode = await proc.exited;
|
||||
const stdout = (await new Response(proc.stdout).text()).trim();
|
||||
const stderr = (await new Response(proc.stderr).text()).trim();
|
||||
@@ -81,16 +79,7 @@ describe("Phase 6: GC", () => {
|
||||
expect(gcExit).toBe(0);
|
||||
|
||||
const proc = Bun.spawn(
|
||||
[
|
||||
"bun",
|
||||
entrypoint,
|
||||
"--home",
|
||||
tmpStore,
|
||||
"--var-db",
|
||||
varDbPath,
|
||||
"render",
|
||||
"--pipe",
|
||||
],
|
||||
["bun", entrypoint, "--home", tmpStore, "render", "--pipe"],
|
||||
{ stdin: "pipe", stdout: "pipe", stderr: "pipe" },
|
||||
);
|
||||
proc.stdin.write(gcOut);
|
||||
|
||||
@@ -122,16 +122,16 @@ describe("E4. list-schema vs list --type with multiple meta-schema versions", ()
|
||||
test("list-schema includes schemas typed by older meta-schemas; list --type @ocas/schema does not", async () => {
|
||||
// Set up two distinct meta-schemas via the library
|
||||
const store = await openFsStore(storePath);
|
||||
const m1 = await store[BOOTSTRAP_STORE]({
|
||||
const m1 = await store.cas[BOOTSTRAP_STORE]({
|
||||
type: "object",
|
||||
title: "meta-v1",
|
||||
});
|
||||
const m2 = await store[BOOTSTRAP_STORE]({
|
||||
const m2 = await store.cas[BOOTSTRAP_STORE]({
|
||||
type: "object",
|
||||
title: "meta-v2",
|
||||
});
|
||||
// schema typed by older meta M1
|
||||
const sM1 = await store.put(m1, { type: "string" });
|
||||
const sM1 = store.cas.put(m1, { type: "string" });
|
||||
expect(m1).not.toBe(m2);
|
||||
|
||||
// CLI: list-schema must include sM1
|
||||
|
||||
@@ -7,13 +7,11 @@ import { envValue } from "./helpers";
|
||||
const entrypoint = resolve(import.meta.dir, "../src/index.ts");
|
||||
|
||||
let tmpStore: string;
|
||||
let varDbPath: string;
|
||||
let typeHash: string;
|
||||
let _nodeHash: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpStore = mkdtempSync(join(tmpdir(), "ocas-e2e-"));
|
||||
varDbPath = join(tmpStore, "variables.db");
|
||||
|
||||
const schemaFile = join(tmpStore, "test-schema.json");
|
||||
writeFileSync(
|
||||
@@ -51,10 +49,10 @@ afterAll(() => {
|
||||
async function runCli(
|
||||
args: string[],
|
||||
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
|
||||
const proc = Bun.spawn(
|
||||
["bun", entrypoint, "--home", tmpStore, "--var-db", varDbPath, ...args],
|
||||
{ stdout: "pipe", stderr: "pipe" },
|
||||
);
|
||||
const proc = Bun.spawn(["bun", entrypoint, "--home", tmpStore, ...args], {
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
const exitCode = await proc.exited;
|
||||
const stdout = (await new Response(proc.stdout).text()).trim();
|
||||
const stderr = (await new Response(proc.stderr).text()).trim();
|
||||
@@ -65,10 +63,11 @@ async function runCliWithStdin(
|
||||
args: string[],
|
||||
stdin: string,
|
||||
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
|
||||
const proc = Bun.spawn(
|
||||
["bun", entrypoint, "--home", tmpStore, "--var-db", varDbPath, ...args],
|
||||
{ stdin: "pipe", stdout: "pipe", stderr: "pipe" },
|
||||
);
|
||||
const proc = Bun.spawn(["bun", entrypoint, "--home", tmpStore, ...args], {
|
||||
stdin: "pipe",
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
proc.stdin.write(stdin);
|
||||
proc.stdin.end();
|
||||
const exitCode = await proc.exited;
|
||||
|
||||
@@ -7,13 +7,11 @@ import { envValue, stripVolatile } from "./helpers";
|
||||
const entrypoint = resolve(import.meta.dir, "../src/index.ts");
|
||||
|
||||
let tmpStore: string;
|
||||
let varDbPath: string;
|
||||
let typeHash: string;
|
||||
let nodeHash: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpStore = mkdtempSync(join(tmpdir(), "ocas-e2e-"));
|
||||
varDbPath = join(tmpStore, "variables.db");
|
||||
|
||||
const schemaFile = join(tmpStore, "test-schema.json");
|
||||
writeFileSync(
|
||||
@@ -49,10 +47,10 @@ afterAll(() => {
|
||||
async function runCli(
|
||||
args: string[],
|
||||
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
|
||||
const proc = Bun.spawn(
|
||||
["bun", entrypoint, "--home", tmpStore, "--var-db", varDbPath, ...args],
|
||||
{ stdout: "pipe", stderr: "pipe" },
|
||||
);
|
||||
const proc = Bun.spawn(["bun", entrypoint, "--home", tmpStore, ...args], {
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
const exitCode = await proc.exited;
|
||||
const stdout = (await new Response(proc.stdout).text()).trim();
|
||||
const stderr = (await new Response(proc.stderr).text()).trim();
|
||||
|
||||
@@ -3,7 +3,7 @@ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import { bootstrap } from "@ocas/core";
|
||||
import { createFsStore } from "@ocas/fs";
|
||||
import { openStore as openFsStore } from "@ocas/fs";
|
||||
import { envValue, putSchemaFile, runCli, runCliWithStdin } from "./helpers";
|
||||
|
||||
const entrypoint = resolve(import.meta.dir, "../src/index.ts");
|
||||
@@ -54,17 +54,16 @@ describe("ocas render command", () => {
|
||||
|
||||
describe("Phase 5: Render", () => {
|
||||
let tmpStore: string;
|
||||
let varDbPath: string;
|
||||
let typeHash: string;
|
||||
let nodeHash: string;
|
||||
|
||||
async function runCliE2e(
|
||||
args: string[],
|
||||
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
|
||||
const proc = Bun.spawn(
|
||||
["bun", entrypoint, "--home", tmpStore, "--var-db", varDbPath, ...args],
|
||||
{ stdout: "pipe", stderr: "pipe" },
|
||||
);
|
||||
const proc = Bun.spawn(["bun", entrypoint, "--home", tmpStore, ...args], {
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
const exitCode = await proc.exited;
|
||||
const stdout = (await new Response(proc.stdout).text()).trim();
|
||||
const stderr = (await new Response(proc.stderr).text()).trim();
|
||||
@@ -75,10 +74,11 @@ describe("Phase 5: Render", () => {
|
||||
args: string[],
|
||||
stdin: string,
|
||||
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
|
||||
const proc = Bun.spawn(
|
||||
["bun", entrypoint, "--home", tmpStore, "--var-db", varDbPath, ...args],
|
||||
{ stdin: "pipe", stdout: "pipe", stderr: "pipe" },
|
||||
);
|
||||
const proc = Bun.spawn(["bun", entrypoint, "--home", tmpStore, ...args], {
|
||||
stdin: "pipe",
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
proc.stdin.write(stdin);
|
||||
proc.stdin.end();
|
||||
const exitCode = await proc.exited;
|
||||
@@ -89,7 +89,6 @@ describe("Phase 5: Render", () => {
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpStore = mkdtempSync(join(tmpdir(), "ocas-e2e-"));
|
||||
varDbPath = join(tmpStore, "variables.db");
|
||||
|
||||
const schemaFile = join(tmpStore, "test-schema.json");
|
||||
writeFileSync(
|
||||
@@ -422,7 +421,7 @@ describe("Suite 6: CLI Integration with Templates", () => {
|
||||
await runCli(["init"], tmpStore);
|
||||
|
||||
// Get @ocas/string type hash via bootstrap
|
||||
const store = createFsStore(tmpStore);
|
||||
const store = await openFsStore(tmpStore);
|
||||
const types = await bootstrap(store);
|
||||
const stringType = types["@ocas/string"];
|
||||
|
||||
@@ -457,7 +456,7 @@ describe("Suite 6: CLI Integration with Templates", () => {
|
||||
await runCli(["init"], tmpStore);
|
||||
|
||||
// Get @ocas/string type hash via bootstrap
|
||||
const store = createFsStore(tmpStore);
|
||||
const store = await openFsStore(tmpStore);
|
||||
const types = await bootstrap(store);
|
||||
const stringType = types["@ocas/string"];
|
||||
|
||||
|
||||
@@ -556,7 +556,6 @@ describe("Issue #50: Schema Validation in put", () => {
|
||||
// e2e Phase 2 tests
|
||||
describe("Phase 2: Schema Validation", () => {
|
||||
let tmpStore: string;
|
||||
let varDbPath: string;
|
||||
let typeHash: string;
|
||||
let _nodeHash: string;
|
||||
|
||||
@@ -564,7 +563,6 @@ describe("Phase 2: Schema Validation", () => {
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpStore = mkdtempSync(join(tmpdir(), "ocas-e2e-"));
|
||||
varDbPath = join(tmpStore, "variables.db");
|
||||
|
||||
const schemaFile = join(tmpStore, "test-schema.json");
|
||||
writeFileSync(
|
||||
@@ -587,17 +585,7 @@ describe("Phase 2: Schema Validation", () => {
|
||||
const nodeFile = join(tmpStore, "test-node.json");
|
||||
writeFileSync(nodeFile, JSON.stringify({ name: "Alice", age: 30 }));
|
||||
const proc = Bun.spawn(
|
||||
[
|
||||
"bun",
|
||||
entrypoint,
|
||||
"--home",
|
||||
tmpStore,
|
||||
"--var-db",
|
||||
varDbPath,
|
||||
"put",
|
||||
typeHash,
|
||||
nodeFile,
|
||||
],
|
||||
["bun", entrypoint, "--home", tmpStore, "put", typeHash, nodeFile],
|
||||
{ stdout: "pipe", stderr: "pipe" },
|
||||
);
|
||||
await proc.exited;
|
||||
@@ -613,17 +601,7 @@ describe("Phase 2: Schema Validation", () => {
|
||||
const badFile = join(tmpStore, "bad-node.json");
|
||||
writeFileSync(badFile, JSON.stringify({ name: 123 }));
|
||||
const proc = Bun.spawn(
|
||||
[
|
||||
"bun",
|
||||
entrypoint,
|
||||
"--home",
|
||||
tmpStore,
|
||||
"--var-db",
|
||||
varDbPath,
|
||||
"put",
|
||||
typeHash,
|
||||
badFile,
|
||||
],
|
||||
["bun", entrypoint, "--home", tmpStore, "put", typeHash, badFile],
|
||||
{ stdout: "pipe", stderr: "pipe" },
|
||||
);
|
||||
const exitCode = await proc.exited;
|
||||
@@ -638,17 +616,7 @@ describe("Phase 2: Schema Validation", () => {
|
||||
test("2.3 put against non-existent schema hash fails", async () => {
|
||||
const nodeFile = join(tmpStore, "test-node.json");
|
||||
const proc = Bun.spawn(
|
||||
[
|
||||
"bun",
|
||||
entrypoint,
|
||||
"--home",
|
||||
tmpStore,
|
||||
"--var-db",
|
||||
varDbPath,
|
||||
"put",
|
||||
"AAAAAAAAAAAAA",
|
||||
nodeFile,
|
||||
],
|
||||
["bun", entrypoint, "--home", tmpStore, "put", "AAAAAAAAAAAAA", nodeFile],
|
||||
{ stdout: "pipe", stderr: "pipe" },
|
||||
);
|
||||
const exitCode = await proc.exited;
|
||||
|
||||
@@ -2,15 +2,14 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
||||
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { Hash, Store } from "@ocas/core";
|
||||
import type { Hash, OcasStore } from "@ocas/core";
|
||||
import { bootstrap } from "@ocas/core";
|
||||
import { createFsStore } from "@ocas/fs";
|
||||
import { openStore as openFsStore } from "@ocas/fs";
|
||||
|
||||
// ---- Test helpers ----
|
||||
|
||||
let testDir: string;
|
||||
let storePath: string;
|
||||
let varDbPath: string;
|
||||
let cliPath: string;
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -20,7 +19,6 @@ beforeEach(() => {
|
||||
`ocas-test-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||
);
|
||||
storePath = join(testDir, "store");
|
||||
varDbPath = join(testDir, "variables.db");
|
||||
cliPath = join(import.meta.dir, "../src/index.ts");
|
||||
|
||||
mkdirSync(testDir, { recursive: true });
|
||||
@@ -45,16 +43,7 @@ async function runCli(...args: string[]): Promise<{
|
||||
exitCode: number;
|
||||
}> {
|
||||
const proc = Bun.spawn(
|
||||
[
|
||||
"bun",
|
||||
"run",
|
||||
cliPath,
|
||||
"--home",
|
||||
storePath,
|
||||
"--var-db",
|
||||
varDbPath,
|
||||
...args,
|
||||
],
|
||||
["bun", "run", cliPath, "--home", storePath, ...args],
|
||||
{
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
@@ -78,7 +67,7 @@ async function runCli(...args: string[]): Promise<{
|
||||
/**
|
||||
* Get bootstrap @ocas/string type hash
|
||||
*/
|
||||
async function getStringHash(store: Store): Promise<Hash> {
|
||||
async function getStringHash(store: OcasStore): Promise<Hash> {
|
||||
const builtinSchemas = await bootstrap(store);
|
||||
return builtinSchemas["@ocas/string"] ?? "";
|
||||
}
|
||||
@@ -87,7 +76,7 @@ async function getStringHash(store: Store): Promise<Hash> {
|
||||
|
||||
describe("template set", () => {
|
||||
test("set template from file", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const store = await openFsStore(storePath);
|
||||
const stringHash = await getStringHash(store);
|
||||
|
||||
const templateFile = join(testDir, "template.txt");
|
||||
@@ -110,7 +99,7 @@ describe("template set", () => {
|
||||
});
|
||||
|
||||
test("set template with --inline flag", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const store = await openFsStore(storePath);
|
||||
const stringHash = await getStringHash(store);
|
||||
|
||||
const { stdout, exitCode } = await runCli(
|
||||
@@ -130,7 +119,7 @@ describe("template set", () => {
|
||||
});
|
||||
|
||||
test("update existing template (idempotent)", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const store = await openFsStore(storePath);
|
||||
const stringHash = await getStringHash(store);
|
||||
|
||||
const templateFile = join(testDir, "template.txt");
|
||||
@@ -159,7 +148,7 @@ describe("template set", () => {
|
||||
});
|
||||
|
||||
test("error when file not found", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const store = await openFsStore(storePath);
|
||||
const stringHash = await getStringHash(store);
|
||||
|
||||
const { stderr, exitCode } = await runCli(
|
||||
@@ -189,7 +178,7 @@ describe("template set", () => {
|
||||
});
|
||||
|
||||
test("error when both file and --inline provided", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const store = await openFsStore(storePath);
|
||||
const stringHash = await getStringHash(store);
|
||||
|
||||
const templateFile = join(testDir, "template.txt");
|
||||
@@ -209,7 +198,7 @@ describe("template set", () => {
|
||||
});
|
||||
|
||||
test("support multi-line templates", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const store = await openFsStore(storePath);
|
||||
const stringHash = await getStringHash(store);
|
||||
|
||||
const multilineContent = "Line 1\nLine 2\nLine 3";
|
||||
@@ -229,7 +218,7 @@ describe("template set", () => {
|
||||
});
|
||||
|
||||
test("support empty templates", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const store = await openFsStore(storePath);
|
||||
const stringHash = await getStringHash(store);
|
||||
|
||||
const { stdout, exitCode } = await runCli(
|
||||
@@ -247,7 +236,7 @@ describe("template set", () => {
|
||||
});
|
||||
|
||||
test("error when neither file nor --inline provided", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const store = await openFsStore(storePath);
|
||||
const stringHash = await getStringHash(store);
|
||||
|
||||
const { stderr, exitCode } = await runCli("template", "set", stringHash);
|
||||
@@ -257,7 +246,7 @@ describe("template set", () => {
|
||||
});
|
||||
|
||||
test("support templates with special characters", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const store = await openFsStore(storePath);
|
||||
const stringHash = await getStringHash(store);
|
||||
|
||||
const specialContent = "Template with {{var}} and $env and @ref";
|
||||
@@ -279,7 +268,7 @@ describe("template set", () => {
|
||||
|
||||
describe("template get", () => {
|
||||
test("retrieve template as envelope value", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const store = await openFsStore(storePath);
|
||||
const stringHash = await getStringHash(store);
|
||||
|
||||
const content = "Hello {{name}}!";
|
||||
@@ -299,7 +288,7 @@ describe("template get", () => {
|
||||
});
|
||||
|
||||
test("error when template not found", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const store = await openFsStore(storePath);
|
||||
const stringHash = await getStringHash(store);
|
||||
|
||||
const { stderr, exitCode } = await runCli("template", "get", stringHash);
|
||||
@@ -310,7 +299,7 @@ describe("template get", () => {
|
||||
});
|
||||
|
||||
test("preserve exact whitespace", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const store = await openFsStore(storePath);
|
||||
const stringHash = await getStringHash(store);
|
||||
|
||||
// The envelope's value preserves exact whitespace (JSON-escaped),
|
||||
@@ -324,7 +313,7 @@ describe("template get", () => {
|
||||
});
|
||||
|
||||
test("support multi-line templates", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const store = await openFsStore(storePath);
|
||||
const stringHash = await getStringHash(store);
|
||||
|
||||
const multiline = "Line 1\nLine 2\nLine 3";
|
||||
@@ -338,7 +327,7 @@ describe("template get", () => {
|
||||
|
||||
describe("template list", () => {
|
||||
test("list all templates", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const store = await openFsStore(storePath);
|
||||
const stringHash = await getStringHash(store);
|
||||
|
||||
// Create multiple templates
|
||||
@@ -361,7 +350,7 @@ describe("template list", () => {
|
||||
});
|
||||
|
||||
test("entry contentHash matches set result", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const store = await openFsStore(storePath);
|
||||
const stringHash = await getStringHash(store);
|
||||
|
||||
const { stdout: setOut } = await runCli(
|
||||
@@ -397,14 +386,14 @@ describe("template list", () => {
|
||||
});
|
||||
|
||||
test("exclude non-template variables", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const store = await openFsStore(storePath);
|
||||
const stringHash = await getStringHash(store);
|
||||
|
||||
// Create a template
|
||||
await runCli("template", "set", stringHash, "--inline", "Template");
|
||||
|
||||
// Create a regular variable (not under @ocas/template/text/)
|
||||
const hash = await store.put(stringHash, "regular var content");
|
||||
const hash = store.cas.put(stringHash, "regular var content");
|
||||
await runCli("var", "set", "regular/var", hash);
|
||||
|
||||
const { stdout } = await runCli("template", "list");
|
||||
@@ -417,7 +406,7 @@ describe("template list", () => {
|
||||
});
|
||||
|
||||
test("output JSON envelope with array value", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const store = await openFsStore(storePath);
|
||||
const stringHash = await getStringHash(store);
|
||||
|
||||
await runCli("template", "set", stringHash, "--inline", "Test");
|
||||
@@ -434,7 +423,7 @@ describe("template list", () => {
|
||||
|
||||
describe("template delete", () => {
|
||||
test("delete template variable binding", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const store = await openFsStore(storePath);
|
||||
const stringHash = await getStringHash(store);
|
||||
|
||||
await runCli("template", "set", stringHash, "--inline", "Template");
|
||||
@@ -463,7 +452,7 @@ describe("template delete", () => {
|
||||
});
|
||||
|
||||
test("error when template not found", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const store = await openFsStore(storePath);
|
||||
const stringHash = await getStringHash(store);
|
||||
|
||||
const { stderr, exitCode } = await runCli("template", "delete", stringHash);
|
||||
@@ -474,7 +463,7 @@ describe("template delete", () => {
|
||||
});
|
||||
|
||||
test("deletion does not affect other templates", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const store = await openFsStore(storePath);
|
||||
const stringHash = await getStringHash(store);
|
||||
|
||||
// Create two templates
|
||||
@@ -497,7 +486,7 @@ describe("template delete", () => {
|
||||
});
|
||||
|
||||
test("CAS content remains after variable deletion", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const store = await openFsStore(storePath);
|
||||
const stringHash = await getStringHash(store);
|
||||
|
||||
await runCli("template", "set", stringHash, "--inline", "Content");
|
||||
@@ -521,7 +510,7 @@ describe("template delete", () => {
|
||||
});
|
||||
|
||||
test("deletion is non-idempotent (second delete fails)", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const store = await openFsStore(storePath);
|
||||
const stringHash = await getStringHash(store);
|
||||
|
||||
await runCli("template", "set", stringHash, "--inline", "Template");
|
||||
@@ -546,7 +535,7 @@ describe("template delete", () => {
|
||||
|
||||
describe("template integration", () => {
|
||||
test("end-to-end workflow: set→get→list→delete", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const store = await openFsStore(storePath);
|
||||
const stringHash = await getStringHash(store);
|
||||
|
||||
const content = "Integration test template";
|
||||
@@ -593,7 +582,7 @@ describe("template integration", () => {
|
||||
});
|
||||
|
||||
test("templates compatible with generic var commands", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const store = await openFsStore(storePath);
|
||||
const stringHash = await getStringHash(store);
|
||||
|
||||
// Set via template command
|
||||
@@ -607,7 +596,7 @@ describe("template integration", () => {
|
||||
});
|
||||
|
||||
test("multiple templates for different schemas", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const store = await openFsStore(storePath);
|
||||
const stringHash = await getStringHash(store);
|
||||
|
||||
// Create templates for different schemas
|
||||
|
||||
@@ -2,13 +2,12 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
||||
import { mkdirSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { Hash, Store } from "@ocas/core";
|
||||
import type { Hash, OcasStore } from "@ocas/core";
|
||||
import { bootstrap } from "@ocas/core";
|
||||
import { createFsStore } from "@ocas/fs";
|
||||
import { openStore as openFsStore } from "@ocas/fs";
|
||||
|
||||
let testDir: string;
|
||||
let storePath: string;
|
||||
let varDbPath: string;
|
||||
let cliPath: string;
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -17,7 +16,6 @@ beforeEach(() => {
|
||||
`ocas-history-test-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||
);
|
||||
storePath = join(testDir, "store");
|
||||
varDbPath = join(testDir, "variables.db");
|
||||
cliPath = join(import.meta.dir, "../src/index.ts");
|
||||
|
||||
mkdirSync(testDir, { recursive: true });
|
||||
@@ -38,16 +36,7 @@ async function runCli(...args: string[]): Promise<{
|
||||
exitCode: number;
|
||||
}> {
|
||||
const proc = Bun.spawn(
|
||||
[
|
||||
"bun",
|
||||
"run",
|
||||
cliPath,
|
||||
"--home",
|
||||
storePath,
|
||||
"--var-db",
|
||||
varDbPath,
|
||||
...args,
|
||||
],
|
||||
["bun", "run", cliPath, "--home", storePath, ...args],
|
||||
{
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
@@ -72,12 +61,12 @@ async function setupSchemaAndValues(): Promise<{
|
||||
schema: Hash;
|
||||
values: Hash[];
|
||||
}> {
|
||||
const store: Store = createFsStore(storePath);
|
||||
const store: OcasStore = await openFsStore(storePath);
|
||||
const aliases = await bootstrap(store);
|
||||
const numberHash = aliases["@ocas/number"] as Hash;
|
||||
const values: Hash[] = [];
|
||||
for (let i = 0; i < 4; i++) {
|
||||
values.push((await store.put(numberHash, i)) as Hash);
|
||||
values.push(store.cas.put(numberHash, i) as Hash);
|
||||
}
|
||||
return { schema: numberHash, values };
|
||||
}
|
||||
|
||||
@@ -2,15 +2,14 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
||||
import { mkdirSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { Hash, Store } from "@ocas/core";
|
||||
import type { Hash, OcasStore } from "@ocas/core";
|
||||
import { bootstrap, putSchema } from "@ocas/core";
|
||||
import { createFsStore } from "@ocas/fs";
|
||||
import { openStore as openFsStore } from "@ocas/fs";
|
||||
|
||||
// ---- Test helpers ----
|
||||
|
||||
let testDir: string;
|
||||
let storePath: string;
|
||||
let varDbPath: string;
|
||||
let cliPath: string;
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -20,7 +19,6 @@ beforeEach(() => {
|
||||
`ocas-test-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||
);
|
||||
storePath = join(testDir, "store");
|
||||
varDbPath = join(testDir, "variables.db");
|
||||
cliPath = join(import.meta.dir, "../src/index.ts");
|
||||
|
||||
mkdirSync(testDir, { recursive: true });
|
||||
@@ -45,16 +43,7 @@ async function runCli(...args: string[]): Promise<{
|
||||
exitCode: number;
|
||||
}> {
|
||||
const proc = Bun.spawn(
|
||||
[
|
||||
"bun",
|
||||
"run",
|
||||
cliPath,
|
||||
"--home",
|
||||
storePath,
|
||||
"--var-db",
|
||||
varDbPath,
|
||||
...args,
|
||||
],
|
||||
["bun", "run", cliPath, "--home", storePath, ...args],
|
||||
{
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
@@ -79,17 +68,17 @@ async function runCli(...args: string[]): Promise<{
|
||||
* Create a test CAS node and return its hash
|
||||
*/
|
||||
async function createTestNode(
|
||||
store: Store,
|
||||
store: OcasStore,
|
||||
typeHash: Hash,
|
||||
payload: unknown,
|
||||
): Promise<Hash> {
|
||||
return await store.put(typeHash, payload);
|
||||
return store.cas.put(typeHash, payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get bootstrap type hash
|
||||
*/
|
||||
async function getBootstrapHash(store: Store): Promise<Hash> {
|
||||
async function getBootstrapHash(store: OcasStore): Promise<Hash> {
|
||||
const builtinSchemas = await bootstrap(store);
|
||||
return builtinSchemas["@ocas/schema"] ?? "";
|
||||
}
|
||||
@@ -98,7 +87,7 @@ async function getBootstrapHash(store: Store): Promise<Hash> {
|
||||
|
||||
describe("var set", () => {
|
||||
test("create new variable without tags/labels", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const store = await openFsStore(storePath);
|
||||
const typeHash = await getBootstrapHash(store);
|
||||
const hash = await createTestNode(store, typeHash, { test: "data" });
|
||||
|
||||
@@ -125,7 +114,7 @@ describe("var set", () => {
|
||||
});
|
||||
|
||||
test("create with tags", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const store = await openFsStore(storePath);
|
||||
const typeHash = await getBootstrapHash(store);
|
||||
const hash = await createTestNode(store, typeHash, { test: "data" });
|
||||
|
||||
@@ -148,7 +137,7 @@ describe("var set", () => {
|
||||
});
|
||||
|
||||
test("create with labels", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const store = await openFsStore(storePath);
|
||||
const typeHash = await getBootstrapHash(store);
|
||||
const hash = await createTestNode(store, typeHash, { test: "data" });
|
||||
|
||||
@@ -171,7 +160,7 @@ describe("var set", () => {
|
||||
});
|
||||
|
||||
test("update existing variable (same schema)", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const store = await openFsStore(storePath);
|
||||
const typeHash = await getBootstrapHash(store);
|
||||
const hash1 = await createTestNode(store, typeHash, { test: "data1" });
|
||||
const hash2 = await createTestNode(store, typeHash, { test: "data2" });
|
||||
@@ -198,7 +187,7 @@ describe("var set", () => {
|
||||
});
|
||||
|
||||
test("create variant with different schema", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const store = await openFsStore(storePath);
|
||||
const typeHash1 = await getBootstrapHash(store);
|
||||
const typeHash2 = await putSchema(store, { title: "Test", type: "object" });
|
||||
const hash1 = await createTestNode(store, typeHash1, { test: "data1" });
|
||||
@@ -227,7 +216,7 @@ describe("var set", () => {
|
||||
});
|
||||
|
||||
test("update with new tags replaces old tags", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const store = await openFsStore(storePath);
|
||||
const typeHash = await getBootstrapHash(store);
|
||||
const hash = await createTestNode(store, typeHash, { test: "data" });
|
||||
|
||||
@@ -264,7 +253,7 @@ describe("var set", () => {
|
||||
});
|
||||
|
||||
test("error on invalid name format", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const store = await openFsStore(storePath);
|
||||
const typeHash = await getBootstrapHash(store);
|
||||
const hash = await createTestNode(store, typeHash, { test: "data" });
|
||||
|
||||
@@ -290,7 +279,7 @@ describe("var set", () => {
|
||||
});
|
||||
|
||||
test("error on tag/label name conflict", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const store = await openFsStore(storePath);
|
||||
const typeHash = await getBootstrapHash(store);
|
||||
const hash = await createTestNode(store, typeHash, { test: "data" });
|
||||
|
||||
@@ -312,7 +301,7 @@ describe("var set", () => {
|
||||
|
||||
describe("var get", () => {
|
||||
test("retrieve existing variable by name + schema", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const store = await openFsStore(storePath);
|
||||
const typeHash = await getBootstrapHash(store);
|
||||
const hash = await createTestNode(store, typeHash, { test: "data" });
|
||||
|
||||
@@ -336,7 +325,7 @@ describe("var get", () => {
|
||||
});
|
||||
|
||||
test("error when variable not found", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const store = await openFsStore(storePath);
|
||||
const typeHash = await getBootstrapHash(store);
|
||||
|
||||
const { stderr, exitCode } = await runCli(
|
||||
@@ -363,7 +352,7 @@ describe("var get", () => {
|
||||
});
|
||||
|
||||
test("distinguish variants by schema", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const store = await openFsStore(storePath);
|
||||
const typeHash1 = await getBootstrapHash(store);
|
||||
const typeHash2 = await putSchema(store, { title: "Test", type: "object" });
|
||||
const hash1 = await createTestNode(store, typeHash1, { test: "data1" });
|
||||
@@ -401,7 +390,7 @@ describe("var get", () => {
|
||||
|
||||
describe("var delete", () => {
|
||||
test("remove all schema variants", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const store = await openFsStore(storePath);
|
||||
const typeHash1 = await getBootstrapHash(store);
|
||||
const typeHash2 = await putSchema(store, { title: "Test", type: "object" });
|
||||
const hash1 = await createTestNode(store, typeHash1, { test: "data1" });
|
||||
@@ -441,7 +430,7 @@ describe("var delete", () => {
|
||||
});
|
||||
|
||||
test("remove specific variant by schema", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const store = await openFsStore(storePath);
|
||||
const typeHash1 = await getBootstrapHash(store);
|
||||
const typeHash2 = await putSchema(store, { title: "Test", type: "object" });
|
||||
const hash1 = await createTestNode(store, typeHash1, { test: "data1" });
|
||||
@@ -516,7 +505,7 @@ describe("var delete", () => {
|
||||
});
|
||||
|
||||
test("cascade delete tags and labels", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const store = await openFsStore(storePath);
|
||||
const typeHash = await getBootstrapHash(store);
|
||||
const hash = await createTestNode(store, typeHash, { test: "data" });
|
||||
|
||||
@@ -542,7 +531,7 @@ describe("var delete", () => {
|
||||
|
||||
describe("var list", () => {
|
||||
test("list all variables", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const store = await openFsStore(storePath);
|
||||
const typeHash = await getBootstrapHash(store);
|
||||
const hash1 = await createTestNode(store, typeHash, { test: "data1" });
|
||||
const hash2 = await createTestNode(store, typeHash, { test: "data2" });
|
||||
@@ -565,7 +554,7 @@ describe("var list", () => {
|
||||
});
|
||||
|
||||
test("filter by name prefix", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const store = await openFsStore(storePath);
|
||||
const typeHash = await getBootstrapHash(store);
|
||||
const hash1 = await createTestNode(store, typeHash, { test: "data1" });
|
||||
const hash2 = await createTestNode(store, typeHash, { test: "data2" });
|
||||
@@ -588,7 +577,7 @@ describe("var list", () => {
|
||||
});
|
||||
|
||||
test("filter by schema", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const store = await openFsStore(storePath);
|
||||
const bootstrapHash = await getBootstrapHash(store);
|
||||
const typeHash1 = await putSchema(store, {
|
||||
title: "TypeA",
|
||||
@@ -623,7 +612,7 @@ describe("var list", () => {
|
||||
});
|
||||
|
||||
test("filter by tags (AND logic)", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const store = await openFsStore(storePath);
|
||||
const typeHash = await getBootstrapHash(store);
|
||||
const hash1 = await createTestNode(store, typeHash, { test: "data1" });
|
||||
const hash2 = await createTestNode(store, typeHash, { test: "data2" });
|
||||
@@ -670,7 +659,7 @@ describe("var list", () => {
|
||||
});
|
||||
|
||||
test("filter by labels", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const store = await openFsStore(storePath);
|
||||
const typeHash = await getBootstrapHash(store);
|
||||
const hash1 = await createTestNode(store, typeHash, { test: "data1" });
|
||||
const hash2 = await createTestNode(store, typeHash, { test: "data2" });
|
||||
@@ -710,7 +699,7 @@ describe("var list", () => {
|
||||
});
|
||||
|
||||
test("combined filters", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const store = await openFsStore(storePath);
|
||||
const typeHash1 = await getBootstrapHash(store);
|
||||
const hash1 = await createTestNode(store, typeHash1, { test: "data1" });
|
||||
const hash2 = await createTestNode(store, typeHash1, { test: "data2" });
|
||||
@@ -765,7 +754,7 @@ describe("var list", () => {
|
||||
|
||||
describe("var tag", () => {
|
||||
test("add new tag", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const store = await openFsStore(storePath);
|
||||
const typeHash = await getBootstrapHash(store);
|
||||
const hash = await createTestNode(store, typeHash, { test: "data" });
|
||||
|
||||
@@ -789,7 +778,7 @@ describe("var tag", () => {
|
||||
});
|
||||
|
||||
test("update existing tag value", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const store = await openFsStore(storePath);
|
||||
const typeHash = await getBootstrapHash(store);
|
||||
const hash = await createTestNode(store, typeHash, { test: "data" });
|
||||
|
||||
@@ -813,7 +802,7 @@ describe("var tag", () => {
|
||||
});
|
||||
|
||||
test("add label", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const store = await openFsStore(storePath);
|
||||
const typeHash = await getBootstrapHash(store);
|
||||
const hash = await createTestNode(store, typeHash, { test: "data" });
|
||||
|
||||
@@ -837,7 +826,7 @@ describe("var tag", () => {
|
||||
});
|
||||
|
||||
test("delete tag", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const store = await openFsStore(storePath);
|
||||
const typeHash = await getBootstrapHash(store);
|
||||
const hash = await createTestNode(store, typeHash, { test: "data" });
|
||||
|
||||
@@ -870,7 +859,7 @@ describe("var tag", () => {
|
||||
});
|
||||
|
||||
test("delete label", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const store = await openFsStore(storePath);
|
||||
const typeHash = await getBootstrapHash(store);
|
||||
const hash = await createTestNode(store, typeHash, { test: "data" });
|
||||
|
||||
@@ -903,7 +892,7 @@ describe("var tag", () => {
|
||||
});
|
||||
|
||||
test("mixed add and delete operations", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const store = await openFsStore(storePath);
|
||||
const typeHash = await getBootstrapHash(store);
|
||||
const hash = await createTestNode(store, typeHash, { test: "data" });
|
||||
|
||||
@@ -930,7 +919,7 @@ describe("var tag", () => {
|
||||
});
|
||||
|
||||
test("error on tag/label conflict", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const store = await openFsStore(storePath);
|
||||
const typeHash = await getBootstrapHash(store);
|
||||
const hash = await createTestNode(store, typeHash, { test: "data" });
|
||||
|
||||
@@ -952,7 +941,7 @@ describe("var tag", () => {
|
||||
});
|
||||
|
||||
test("error when variable not found", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const store = await openFsStore(storePath);
|
||||
const typeHash = await getBootstrapHash(store);
|
||||
|
||||
const { stderr, exitCode } = await runCli(
|
||||
@@ -985,7 +974,7 @@ describe("var tag", () => {
|
||||
});
|
||||
|
||||
test("error when no operations provided", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const store = await openFsStore(storePath);
|
||||
const typeHash = await getBootstrapHash(store);
|
||||
|
||||
const { stderr, exitCode } = await runCli(
|
||||
@@ -1005,7 +994,7 @@ describe("var tag", () => {
|
||||
|
||||
describe("global options", () => {
|
||||
test("--json flag for compact output", async () => {
|
||||
const store = createFsStore(storePath);
|
||||
const store = await openFsStore(storePath);
|
||||
const typeHash = await getBootstrapHash(store);
|
||||
const hash = await createTestNode(store, typeHash, { test: "data" });
|
||||
|
||||
@@ -1029,7 +1018,7 @@ describe("global options", () => {
|
||||
const customStorePath = join(testDir, "custom-store");
|
||||
mkdirSync(customStorePath, { recursive: true });
|
||||
|
||||
const store = createFsStore(customStorePath);
|
||||
const store = await openFsStore(customStorePath);
|
||||
const typeHash = await getBootstrapHash(store);
|
||||
const hash = await createTestNode(store, typeHash, { test: "data" });
|
||||
|
||||
@@ -1041,39 +1030,6 @@ describe("global options", () => {
|
||||
cliPath,
|
||||
"--home",
|
||||
customStorePath,
|
||||
"--var-db",
|
||||
varDbPath,
|
||||
"var",
|
||||
"set",
|
||||
"@test/x",
|
||||
hash,
|
||||
],
|
||||
{
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
},
|
||||
);
|
||||
|
||||
await proc.exited;
|
||||
expect(proc.exitCode).toBe(0);
|
||||
});
|
||||
|
||||
test("--var-db flag for custom database path", async () => {
|
||||
const customDbPath = join(testDir, "custom.db");
|
||||
const store = createFsStore(storePath);
|
||||
const typeHash = await getBootstrapHash(store);
|
||||
const hash = await createTestNode(store, typeHash, { test: "data" });
|
||||
|
||||
// Override with custom db path
|
||||
const proc = Bun.spawn(
|
||||
[
|
||||
"bun",
|
||||
"run",
|
||||
cliPath,
|
||||
"--home",
|
||||
storePath,
|
||||
"--var-db",
|
||||
customDbPath,
|
||||
"var",
|
||||
"set",
|
||||
"@test/x",
|
||||
|
||||
@@ -7,13 +7,11 @@ import { envValue, stripVolatile } from "./helpers";
|
||||
const entrypoint = resolve(import.meta.dir, "../src/index.ts");
|
||||
|
||||
let tmpStore: string;
|
||||
let varDbPath: string;
|
||||
let typeHash: string;
|
||||
let nodeHash: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpStore = mkdtempSync(join(tmpdir(), "ocas-e2e-"));
|
||||
varDbPath = join(tmpStore, "variables.db");
|
||||
|
||||
const schemaFile = join(tmpStore, "test-schema.json");
|
||||
writeFileSync(
|
||||
@@ -46,10 +44,10 @@ afterAll(() => {
|
||||
async function runCli(
|
||||
args: string[],
|
||||
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
|
||||
const proc = Bun.spawn(
|
||||
["bun", entrypoint, "--home", tmpStore, "--var-db", varDbPath, ...args],
|
||||
{ stdout: "pipe", stderr: "pipe" },
|
||||
);
|
||||
const proc = Bun.spawn(["bun", entrypoint, "--home", tmpStore, ...args], {
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
const exitCode = await proc.exited;
|
||||
const stdout = (await new Response(proc.stdout).text()).trim();
|
||||
const stderr = (await new Response(proc.stderr).text()).trim();
|
||||
|
||||
@@ -34,7 +34,7 @@ const OUTPUT_ALIASES = [
|
||||
|
||||
describe("bootstrap - Built-in Schemas", () => {
|
||||
test("should return map of 30 built-in schema aliases to hashes", async () => {
|
||||
const store = createMemoryStore().cas;
|
||||
const store = createMemoryStore();
|
||||
const builtinSchemas = await bootstrap(store);
|
||||
|
||||
// Should return object with 9 primitive + 21 output aliases = 30
|
||||
@@ -62,7 +62,7 @@ describe("bootstrap - Built-in Schemas", () => {
|
||||
});
|
||||
|
||||
test("should register @ocas/schema as meta-schema alias", async () => {
|
||||
const store = createMemoryStore().cas;
|
||||
const store = createMemoryStore();
|
||||
const builtinSchemas = await bootstrap(store);
|
||||
|
||||
const metaHash = builtinSchemas["@ocas/schema"];
|
||||
@@ -75,7 +75,7 @@ describe("bootstrap - Built-in Schemas", () => {
|
||||
});
|
||||
|
||||
test("should register @ocas/string schema correctly", async () => {
|
||||
const store = createMemoryStore().cas;
|
||||
const store = createMemoryStore();
|
||||
const builtinSchemas = await bootstrap(store);
|
||||
|
||||
const stringHash = builtinSchemas["@ocas/string"];
|
||||
@@ -86,7 +86,7 @@ describe("bootstrap - Built-in Schemas", () => {
|
||||
});
|
||||
|
||||
test("should register @ocas/number schema correctly", async () => {
|
||||
const store = createMemoryStore().cas;
|
||||
const store = createMemoryStore();
|
||||
const builtinSchemas = await bootstrap(store);
|
||||
|
||||
const numberHash = builtinSchemas["@ocas/number"];
|
||||
@@ -97,7 +97,7 @@ describe("bootstrap - Built-in Schemas", () => {
|
||||
});
|
||||
|
||||
test("should register @ocas/object schema correctly", async () => {
|
||||
const store = createMemoryStore().cas;
|
||||
const store = createMemoryStore();
|
||||
const builtinSchemas = await bootstrap(store);
|
||||
|
||||
const objectHash = builtinSchemas["@ocas/object"];
|
||||
@@ -108,7 +108,7 @@ describe("bootstrap - Built-in Schemas", () => {
|
||||
});
|
||||
|
||||
test("should register @ocas/array schema correctly", async () => {
|
||||
const store = createMemoryStore().cas;
|
||||
const store = createMemoryStore();
|
||||
const builtinSchemas = await bootstrap(store);
|
||||
|
||||
const arrayHash = builtinSchemas["@ocas/array"];
|
||||
@@ -119,7 +119,7 @@ describe("bootstrap - Built-in Schemas", () => {
|
||||
});
|
||||
|
||||
test("should register @ocas/bool schema correctly", async () => {
|
||||
const store = createMemoryStore().cas;
|
||||
const store = createMemoryStore();
|
||||
const builtinSchemas = await bootstrap(store);
|
||||
|
||||
const boolHash = builtinSchemas["@ocas/bool"];
|
||||
@@ -130,7 +130,7 @@ describe("bootstrap - Built-in Schemas", () => {
|
||||
});
|
||||
|
||||
test("should return same hashes on repeated bootstrap calls", async () => {
|
||||
const store = createMemoryStore().cas;
|
||||
const store = createMemoryStore();
|
||||
const first = await bootstrap(store);
|
||||
const second = await bootstrap(store);
|
||||
|
||||
@@ -146,7 +146,7 @@ describe("bootstrap - Built-in Schemas", () => {
|
||||
});
|
||||
|
||||
test("all built-in schemas should be typed by meta-schema", async () => {
|
||||
const store = createMemoryStore().cas;
|
||||
const store = createMemoryStore();
|
||||
const builtinSchemas = await bootstrap(store);
|
||||
|
||||
const metaHash = builtinSchemas["@ocas/schema"];
|
||||
@@ -155,7 +155,7 @@ describe("bootstrap - Built-in Schemas", () => {
|
||||
for (const [alias, hash] of Object.entries(builtinSchemas)) {
|
||||
if (alias === "@ocas/schema") continue; // meta-schema is self-typed
|
||||
|
||||
const node = store.get(hash);
|
||||
const node = store.cas.get(hash);
|
||||
expect(node).not.toBeNull();
|
||||
expect(node?.type).toBe(metaHash);
|
||||
}
|
||||
@@ -168,7 +168,7 @@ describe("bootstrap - Built-in Schemas", () => {
|
||||
|
||||
describe("bootstrap - @ocas/output/* Schemas", () => {
|
||||
test("each @ocas/output/* schema has a title", async () => {
|
||||
const store = createMemoryStore().cas;
|
||||
const store = createMemoryStore();
|
||||
const aliases = await bootstrap(store);
|
||||
|
||||
for (const alias of OUTPUT_ALIASES) {
|
||||
@@ -183,7 +183,7 @@ describe("bootstrap - @ocas/output/* Schemas", () => {
|
||||
});
|
||||
|
||||
test("@ocas/output/put schema describes a ocas_ref string", async () => {
|
||||
const store = createMemoryStore().cas;
|
||||
const store = createMemoryStore();
|
||||
const aliases = await bootstrap(store);
|
||||
const hash = aliases["@ocas/output/put"];
|
||||
if (!hash) throw new Error("@ocas/output/put not found");
|
||||
@@ -197,7 +197,7 @@ describe("bootstrap - @ocas/output/* Schemas", () => {
|
||||
});
|
||||
|
||||
test("@ocas/output/get schema describes object with type, payload, timestamp", async () => {
|
||||
const store = createMemoryStore().cas;
|
||||
const store = createMemoryStore();
|
||||
const aliases = await bootstrap(store);
|
||||
const hash = aliases["@ocas/output/get"];
|
||||
if (!hash) throw new Error("@ocas/output/get not found");
|
||||
@@ -213,7 +213,7 @@ describe("bootstrap - @ocas/output/* Schemas", () => {
|
||||
});
|
||||
|
||||
test("@ocas/output/has schema describes a boolean", async () => {
|
||||
const store = createMemoryStore().cas;
|
||||
const store = createMemoryStore();
|
||||
const aliases = await bootstrap(store);
|
||||
const hash = aliases["@ocas/output/has"];
|
||||
if (!hash) throw new Error("@ocas/output/has not found");
|
||||
@@ -225,7 +225,7 @@ describe("bootstrap - @ocas/output/* Schemas", () => {
|
||||
});
|
||||
|
||||
test("@ocas/output/verify schema describes enum of ok|corrupted|invalid", async () => {
|
||||
const store = createMemoryStore().cas;
|
||||
const store = createMemoryStore();
|
||||
const aliases = await bootstrap(store);
|
||||
const hash = aliases["@ocas/output/verify"];
|
||||
if (!hash) throw new Error("@ocas/output/verify not found");
|
||||
@@ -239,7 +239,7 @@ describe("bootstrap - @ocas/output/* Schemas", () => {
|
||||
});
|
||||
|
||||
test("@ocas/output/refs schema describes array of ocas_ref strings", async () => {
|
||||
const store = createMemoryStore().cas;
|
||||
const store = createMemoryStore();
|
||||
const aliases = await bootstrap(store);
|
||||
const hash = aliases["@ocas/output/refs"];
|
||||
if (!hash) throw new Error("@ocas/output/refs not found");
|
||||
@@ -252,7 +252,7 @@ describe("bootstrap - @ocas/output/* Schemas", () => {
|
||||
});
|
||||
|
||||
test("@ocas/output/gc schema describes object with gc stats fields", async () => {
|
||||
const store = createMemoryStore().cas;
|
||||
const store = createMemoryStore();
|
||||
const aliases = await bootstrap(store);
|
||||
const hash = aliases["@ocas/output/gc"];
|
||||
if (!hash) throw new Error("@ocas/output/gc not found");
|
||||
@@ -269,7 +269,7 @@ describe("bootstrap - @ocas/output/* Schemas", () => {
|
||||
});
|
||||
|
||||
test("@ocas/output/var-set schema describes a Variable object", async () => {
|
||||
const store = createMemoryStore().cas;
|
||||
const store = createMemoryStore();
|
||||
const aliases = await bootstrap(store);
|
||||
const hash = aliases["@ocas/output/var-set"];
|
||||
if (!hash) throw new Error("@ocas/output/var-set not found");
|
||||
@@ -285,7 +285,7 @@ describe("bootstrap - @ocas/output/* Schemas", () => {
|
||||
});
|
||||
|
||||
test("@ocas/output/var-list schema describes array of Variable objects", async () => {
|
||||
const store = createMemoryStore().cas;
|
||||
const store = createMemoryStore();
|
||||
const aliases = await bootstrap(store);
|
||||
const hash = aliases["@ocas/output/var-list"];
|
||||
if (!hash) throw new Error("@ocas/output/var-list not found");
|
||||
@@ -301,7 +301,7 @@ describe("bootstrap - @ocas/output/* Schemas", () => {
|
||||
});
|
||||
|
||||
test("@ocas/output/template-delete schema describes object with deleted boolean", async () => {
|
||||
const store = createMemoryStore().cas;
|
||||
const store = createMemoryStore();
|
||||
const aliases = await bootstrap(store);
|
||||
const hash = aliases["@ocas/output/template-delete"];
|
||||
if (!hash) throw new Error("@ocas/output/template-delete not found");
|
||||
@@ -314,7 +314,7 @@ describe("bootstrap - @ocas/output/* Schemas", () => {
|
||||
});
|
||||
|
||||
test("all @ocas/output/* schemas are distinct hashes", async () => {
|
||||
const store = createMemoryStore().cas;
|
||||
const store = createMemoryStore();
|
||||
const aliases = await bootstrap(store);
|
||||
|
||||
const outputHashes = OUTPUT_ALIASES.map((alias) => aliases[alias]);
|
||||
@@ -325,16 +325,18 @@ describe("bootstrap - @ocas/output/* Schemas", () => {
|
||||
|
||||
describe("bootstrap - meta and schemas indexes (D1)", () => {
|
||||
test("listMeta contains the bootstrap meta-schema hash", async () => {
|
||||
const store = createMemoryStore().cas;
|
||||
const store = createMemoryStore();
|
||||
const aliases = await bootstrap(store);
|
||||
const metaHash = aliases["@ocas/schema"];
|
||||
expect(store.listMeta().map((e) => e.hash)).toContain(metaHash as string);
|
||||
expect(store.cas.listMeta().map((e) => e.hash)).toContain(
|
||||
metaHash as string,
|
||||
);
|
||||
});
|
||||
|
||||
test("listSchemas contains meta-schema and all built-in schemas", async () => {
|
||||
const store = createMemoryStore().cas;
|
||||
const store = createMemoryStore();
|
||||
const aliases = await bootstrap(store);
|
||||
const schemas = store.listSchemas().map((e) => e.hash);
|
||||
const schemas = store.cas.listSchemas().map((e) => e.hash);
|
||||
|
||||
for (const [, hash] of Object.entries(aliases)) {
|
||||
expect(schemas).toContain(hash);
|
||||
|
||||
@@ -2,8 +2,7 @@ import {
|
||||
BOOTSTRAP_STORE,
|
||||
isBootstrapCapableStore,
|
||||
} from "./bootstrap-capable.js";
|
||||
import type { Hash, Store } from "./types.js";
|
||||
import type { VariableStore } from "./variable-store.js";
|
||||
import type { Hash, OcasStore } from "./types.js";
|
||||
|
||||
const JSON_SCHEMA_TYPES = [
|
||||
"string",
|
||||
@@ -326,29 +325,28 @@ const OUTPUT_SCHEMAS: ReadonlyArray<
|
||||
* and @ocas/output/* schemas.
|
||||
* Idempotent: calling bootstrap multiple times returns the same hashes.
|
||||
*
|
||||
* If a varStore is provided, all aliases are also written to it via
|
||||
* varStore.set(name, hash). This bypasses @ocas/ namespace protection
|
||||
* (protection is enforced only at the CLI layer).
|
||||
* All aliases are written to `store.var` via `var.set(name, hash)`, bypassing
|
||||
* @ocas/ namespace protection (protection is enforced only at the CLI layer).
|
||||
*/
|
||||
export async function bootstrap(
|
||||
store: Store,
|
||||
varStore?: VariableStore,
|
||||
store: OcasStore,
|
||||
): Promise<Record<string, Hash>> {
|
||||
if (!isBootstrapCapableStore(store)) {
|
||||
const cas = store.cas;
|
||||
if (!isBootstrapCapableStore(cas)) {
|
||||
throw new Error("Store does not support bootstrap");
|
||||
}
|
||||
|
||||
// 1. Bootstrap the meta-schema (self-referential)
|
||||
const metaHash = await store[BOOTSTRAP_STORE](BOOTSTRAP_PAYLOAD);
|
||||
const metaHash = await cas[BOOTSTRAP_STORE](BOOTSTRAP_PAYLOAD);
|
||||
|
||||
// 2. Register built-in primitive schemas directly (without putSchema to avoid recursion)
|
||||
const stringHash = await store.put(metaHash, { type: "string" });
|
||||
const numberHash = await store.put(metaHash, { type: "number" });
|
||||
const integerHash = await store.put(metaHash, { type: "integer" });
|
||||
const boolHash = await store.put(metaHash, { type: "boolean" });
|
||||
const objectHash = await store.put(metaHash, { type: "object" });
|
||||
const arrayHash = await store.put(metaHash, { type: "array" });
|
||||
const nullHash = await store.put(metaHash, { type: "null" });
|
||||
const stringHash = await cas.put(metaHash, { type: "string" });
|
||||
const numberHash = await cas.put(metaHash, { type: "number" });
|
||||
const integerHash = await cas.put(metaHash, { type: "integer" });
|
||||
const boolHash = await cas.put(metaHash, { type: "boolean" });
|
||||
const objectHash = await cas.put(metaHash, { type: "object" });
|
||||
const arrayHash = await cas.put(metaHash, { type: "array" });
|
||||
const nullHash = await cas.put(metaHash, { type: "null" });
|
||||
|
||||
// 3. Register @ocas/output/* schemas
|
||||
const aliases: Record<string, Hash> = {
|
||||
@@ -364,16 +362,14 @@ export async function bootstrap(
|
||||
};
|
||||
|
||||
for (const [alias, schema] of OUTPUT_SCHEMAS) {
|
||||
aliases[alias] = await store.put(metaHash, schema);
|
||||
aliases[alias] = await cas.put(metaHash, schema);
|
||||
}
|
||||
|
||||
// 4. Write all aliases to varStore (when provided).
|
||||
// Idempotent: VariableStore.set is an upsert. Bypasses @ocas/ namespace
|
||||
// protection — protection is only enforced on the CLI `var set` command.
|
||||
if (varStore !== undefined) {
|
||||
for (const [name, hash] of Object.entries(aliases)) {
|
||||
varStore.set(name, hash);
|
||||
}
|
||||
// 4. Write all aliases to the var store. Idempotent: VarStore.set is an
|
||||
// upsert. Bypasses @ocas/ namespace protection — protection is enforced
|
||||
// only on the CLI `var set` command.
|
||||
for (const [name, hash] of Object.entries(aliases)) {
|
||||
store.var.set(name, hash);
|
||||
}
|
||||
|
||||
return aliases;
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { Hash } from "./types.js";
|
||||
|
||||
/**
|
||||
* Maximum number of historical values retained per (variable_name, variable_schema).
|
||||
* Position 0 is current; positions 1..MAX_HISTORY-1 are previous values (LRU).
|
||||
*/
|
||||
export const MAX_HISTORY = 10;
|
||||
|
||||
/**
|
||||
* Custom error types for variable operations
|
||||
*/
|
||||
export class VariableNotFoundError extends Error {
|
||||
constructor(
|
||||
public variableName: string,
|
||||
public variableSchema: Hash,
|
||||
) {
|
||||
super(`Variable not found: name=${variableName}, schema=${variableSchema}`);
|
||||
this.name = "VariableNotFoundError";
|
||||
}
|
||||
}
|
||||
|
||||
export class InvalidVariableNameError extends Error {
|
||||
constructor(
|
||||
public variableName: string,
|
||||
public reason: string,
|
||||
) {
|
||||
super(`Invalid variable name "${variableName}": ${reason}`);
|
||||
this.name = "InvalidVariableNameError";
|
||||
}
|
||||
}
|
||||
|
||||
export class SchemaMismatchError extends Error {
|
||||
constructor(
|
||||
public expected: string,
|
||||
public actual: string,
|
||||
) {
|
||||
super(`Schema mismatch: expected ${expected}, got ${actual}`);
|
||||
this.name = "SchemaMismatchError";
|
||||
}
|
||||
}
|
||||
|
||||
export class CasNodeNotFoundError extends Error {
|
||||
constructor(
|
||||
public readonly hash: string,
|
||||
message?: string,
|
||||
) {
|
||||
super(message ?? `CAS node not found: ${hash}`);
|
||||
this.name = "CasNodeNotFoundError";
|
||||
}
|
||||
}
|
||||
|
||||
export class TagLabelConflictError extends Error {
|
||||
constructor(
|
||||
public conflictName: string,
|
||||
public existingType: "tag" | "label",
|
||||
public attemptedType: "tag" | "label",
|
||||
) {
|
||||
super(`Conflict: '${conflictName}' already exists as a ${existingType}`);
|
||||
this.name = "TagLabelConflictError";
|
||||
}
|
||||
}
|
||||
|
||||
export class InvalidTagFormatError extends Error {
|
||||
constructor(tag: string) {
|
||||
super(`Invalid tag format: ${tag}`);
|
||||
this.name = "InvalidTagFormatError";
|
||||
}
|
||||
}
|
||||
+56
-114
@@ -1,139 +1,94 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test";
|
||||
import { unlinkSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { bootstrap } from "./bootstrap.js";
|
||||
import { gc } from "./gc.js";
|
||||
import { putSchema } from "./schema.js";
|
||||
import { createMemoryStore } from "./store.js";
|
||||
import type { Store } from "./types.js";
|
||||
import { VariableStore } from "./variable-store.js";
|
||||
|
||||
const tmpDbPath = () =>
|
||||
join(
|
||||
tmpdir(),
|
||||
`test-gc-${Date.now()}-${Math.random().toString(36).slice(2)}.db`,
|
||||
);
|
||||
|
||||
describe("GC - Variable Model Refactoring", () => {
|
||||
let store: Store;
|
||||
let dbPath: string;
|
||||
|
||||
afterEach(() => {
|
||||
try {
|
||||
unlinkSync(dbPath);
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
});
|
||||
|
||||
test("GC preserves variable-referenced nodes", async () => {
|
||||
store = createMemoryStore().cas;
|
||||
const store = createMemoryStore();
|
||||
await bootstrap(store);
|
||||
const schema = { type: "object", properties: { name: { type: "string" } } };
|
||||
const schemaHash = await putSchema(store, schema);
|
||||
|
||||
const hashRef = await store.put(schemaHash, { name: "referenced" });
|
||||
const hashOrphan = await store.put(schemaHash, { name: "orphan" });
|
||||
const hashRef = store.cas.put(schemaHash, { name: "referenced" });
|
||||
const hashOrphan = store.cas.put(schemaHash, { name: "orphan" });
|
||||
|
||||
dbPath = tmpDbPath();
|
||||
const varStore = new VariableStore(dbPath, store);
|
||||
store.var.set("@test/config", hashRef);
|
||||
|
||||
varStore.set("@test/config", hashRef);
|
||||
const stats = gc(store);
|
||||
|
||||
const stats = gc(store, varStore);
|
||||
|
||||
expect(store.has(hashRef)).toBe(true);
|
||||
expect(store.has(hashOrphan)).toBe(false);
|
||||
expect(stats.scanned).toBe(1);
|
||||
expect(store.cas.has(hashRef)).toBe(true);
|
||||
expect(store.cas.has(hashOrphan)).toBe(false);
|
||||
expect(stats.scanned).toBeGreaterThanOrEqual(1);
|
||||
expect(stats.collected).toBeGreaterThanOrEqual(1);
|
||||
|
||||
varStore.close();
|
||||
});
|
||||
|
||||
test("GC preserves nodes from variables with same name, different schemas", async () => {
|
||||
store = createMemoryStore().cas;
|
||||
const store = createMemoryStore();
|
||||
await bootstrap(store);
|
||||
const schemaA = { type: "object", properties: { x: { type: "number" } } };
|
||||
const schemaB = { type: "object", properties: { y: { type: "string" } } };
|
||||
const schemaAHash = await putSchema(store, schemaA);
|
||||
const schemaBHash = await putSchema(store, schemaB);
|
||||
|
||||
const hashA = await store.put(schemaAHash, { x: 42 });
|
||||
const hashB = await store.put(schemaBHash, { y: "hello" });
|
||||
const hashOrphan = await store.put(schemaAHash, { x: 99 });
|
||||
const hashA = store.cas.put(schemaAHash, { x: 42 });
|
||||
const hashB = store.cas.put(schemaBHash, { y: "hello" });
|
||||
const hashOrphan = store.cas.put(schemaAHash, { x: 99 });
|
||||
|
||||
dbPath = tmpDbPath();
|
||||
const varStore = new VariableStore(dbPath, store);
|
||||
store.var.set("@test/config", hashA);
|
||||
store.var.set("@test/config", hashB);
|
||||
|
||||
varStore.set("@test/config", hashA);
|
||||
varStore.set("@test/config", hashB);
|
||||
gc(store);
|
||||
|
||||
const stats = gc(store, varStore);
|
||||
|
||||
expect(store.has(hashA)).toBe(true);
|
||||
expect(store.has(hashB)).toBe(true);
|
||||
expect(store.has(hashOrphan)).toBe(false);
|
||||
expect(stats.scanned).toBe(2);
|
||||
|
||||
varStore.close();
|
||||
expect(store.cas.has(hashA)).toBe(true);
|
||||
expect(store.cas.has(hashB)).toBe(true);
|
||||
expect(store.cas.has(hashOrphan)).toBe(false);
|
||||
});
|
||||
|
||||
test("GC removes nodes after variable deletion", async () => {
|
||||
store = createMemoryStore().cas;
|
||||
const store = createMemoryStore();
|
||||
await bootstrap(store);
|
||||
const schema = { type: "object", properties: { name: { type: "string" } } };
|
||||
const schemaHash = await putSchema(store, schema);
|
||||
|
||||
const hashRef = await store.put(schemaHash, { name: "referenced" });
|
||||
const hashRef = store.cas.put(schemaHash, { name: "referenced" });
|
||||
|
||||
dbPath = tmpDbPath();
|
||||
const varStore = new VariableStore(dbPath, store);
|
||||
store.var.set("@test/config", hashRef);
|
||||
store.var.remove("@test/config", schemaHash);
|
||||
|
||||
varStore.set("@test/config", hashRef);
|
||||
varStore.remove("@test/config", schemaHash);
|
||||
gc(store);
|
||||
|
||||
const stats = gc(store, varStore);
|
||||
|
||||
expect(store.has(hashRef)).toBe(false);
|
||||
expect(stats.scanned).toBe(0);
|
||||
|
||||
varStore.close();
|
||||
expect(store.cas.has(hashRef)).toBe(false);
|
||||
});
|
||||
|
||||
test("GC is global across all variables", async () => {
|
||||
store = createMemoryStore().cas;
|
||||
const store = createMemoryStore();
|
||||
await bootstrap(store);
|
||||
const schemaA = { type: "object", properties: { x: { type: "number" } } };
|
||||
const schemaB = { type: "object", properties: { y: { type: "string" } } };
|
||||
const schemaAHash = await putSchema(store, schemaA);
|
||||
const schemaBHash = await putSchema(store, schemaB);
|
||||
|
||||
const hash1 = await store.put(schemaAHash, { x: 1 });
|
||||
const hash2 = await store.put(schemaAHash, { x: 2 });
|
||||
const hash3 = await store.put(schemaBHash, { y: "a" });
|
||||
const hashOrphan = await store.put(schemaAHash, { x: 999 });
|
||||
const hash1 = store.cas.put(schemaAHash, { x: 1 });
|
||||
const hash2 = store.cas.put(schemaAHash, { x: 2 });
|
||||
const hash3 = store.cas.put(schemaBHash, { y: "a" });
|
||||
const hashOrphan = store.cas.put(schemaAHash, { x: 999 });
|
||||
|
||||
dbPath = tmpDbPath();
|
||||
const varStore = new VariableStore(dbPath, store);
|
||||
store.var.set("@test/uwf.thread", hash1);
|
||||
store.var.set("@test/uwf.workflow", hash2);
|
||||
store.var.set("@test/app.config", hash3);
|
||||
|
||||
varStore.set("@test/uwf.thread", hash1);
|
||||
varStore.set("@test/uwf.workflow", hash2);
|
||||
varStore.set("@test/app.config", hash3);
|
||||
gc(store);
|
||||
|
||||
const stats = gc(store, varStore);
|
||||
|
||||
expect(store.has(hash1)).toBe(true);
|
||||
expect(store.has(hash2)).toBe(true);
|
||||
expect(store.has(hash3)).toBe(true);
|
||||
expect(store.has(hashOrphan)).toBe(false);
|
||||
expect(stats.scanned).toBe(3);
|
||||
|
||||
varStore.close();
|
||||
expect(store.cas.has(hash1)).toBe(true);
|
||||
expect(store.cas.has(hash2)).toBe(true);
|
||||
expect(store.cas.has(hash3)).toBe(true);
|
||||
expect(store.cas.has(hashOrphan)).toBe(false);
|
||||
});
|
||||
|
||||
test("GC integration with refactored variable store", async () => {
|
||||
store = createMemoryStore().cas;
|
||||
const store = createMemoryStore();
|
||||
await bootstrap(store);
|
||||
|
||||
const schemaA = { type: "object", properties: { x: { type: "number" } } };
|
||||
@@ -141,39 +96,26 @@ describe("GC - Variable Model Refactoring", () => {
|
||||
const schemaAHash = await putSchema(store, schemaA);
|
||||
const schemaBHash = await putSchema(store, schemaB);
|
||||
|
||||
const hashA1 = await store.put(schemaAHash, { x: 1 });
|
||||
const hashA2 = await store.put(schemaAHash, { x: 2 });
|
||||
const hashB = await store.put(schemaBHash, { y: "hello" });
|
||||
const hashOrphan1 = await store.put(schemaAHash, { x: 999 });
|
||||
const hashOrphan2 = await store.put(schemaBHash, { y: "orphan" });
|
||||
const hashA1 = store.cas.put(schemaAHash, { x: 1 });
|
||||
const hashA2 = store.cas.put(schemaAHash, { x: 2 });
|
||||
const hashB = store.cas.put(schemaBHash, { y: "hello" });
|
||||
store.cas.put(schemaAHash, { x: 999 });
|
||||
store.cas.put(schemaBHash, { y: "orphan" });
|
||||
|
||||
dbPath = tmpDbPath();
|
||||
const varStore = new VariableStore(dbPath, store);
|
||||
store.var.set("@test/var1", hashA1);
|
||||
store.var.set("@test/var2", hashA2);
|
||||
store.var.set("@test/var3", hashB);
|
||||
|
||||
// Create variables
|
||||
varStore.set("@test/var1", hashA1);
|
||||
varStore.set("@test/var2", hashA2);
|
||||
varStore.set("@test/var3", hashB);
|
||||
gc(store);
|
||||
expect(store.cas.has(hashA1)).toBe(true);
|
||||
expect(store.cas.has(hashA2)).toBe(true);
|
||||
expect(store.cas.has(hashB)).toBe(true);
|
||||
|
||||
// First GC: orphans removed
|
||||
let stats = gc(store, varStore);
|
||||
expect(store.has(hashA1)).toBe(true);
|
||||
expect(store.has(hashA2)).toBe(true);
|
||||
expect(store.has(hashB)).toBe(true);
|
||||
expect(store.has(hashOrphan1)).toBe(false);
|
||||
expect(store.has(hashOrphan2)).toBe(false);
|
||||
expect(stats.scanned).toBe(3);
|
||||
store.var.remove("@test/var2", schemaAHash);
|
||||
|
||||
// Delete one variable
|
||||
varStore.remove("@test/var2", schemaAHash);
|
||||
|
||||
// Second GC: hashA2 removed
|
||||
stats = gc(store, varStore);
|
||||
expect(store.has(hashA1)).toBe(true);
|
||||
expect(store.has(hashA2)).toBe(false);
|
||||
expect(store.has(hashB)).toBe(true);
|
||||
expect(stats.scanned).toBe(2);
|
||||
|
||||
varStore.close();
|
||||
gc(store);
|
||||
expect(store.cas.has(hashA1)).toBe(true);
|
||||
expect(store.cas.has(hashA2)).toBe(false);
|
||||
expect(store.cas.has(hashB)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { walk } from "./schema.js";
|
||||
import type { Hash, Store } from "./types.js";
|
||||
import type { VariableStore } from "./variable-store.js";
|
||||
import type { Hash, OcasStore } from "./types.js";
|
||||
|
||||
export interface GcStats {
|
||||
total: number; // Total CAS nodes before GC
|
||||
@@ -16,10 +15,10 @@ export interface GcStats {
|
||||
* - Sweep: delete unmarked nodes
|
||||
* - Schema preservation: schemas of reachable nodes are also marked
|
||||
*/
|
||||
export function gc(store: Store, varStore: VariableStore): GcStats {
|
||||
export function gc(store: OcasStore): GcStats {
|
||||
// Get all variables (no filters → global). Omit `limit` so the full
|
||||
// variable set is returned for use as gc roots.
|
||||
const variables = varStore.list();
|
||||
const variables = store.var.list();
|
||||
const scanned = variables.length;
|
||||
|
||||
// Collect unique root hashes from all variables
|
||||
@@ -44,7 +43,7 @@ export function gc(store: Store, varStore: VariableStore): GcStats {
|
||||
// For each reachable schema, walk its schema chain (not its references)
|
||||
const schemasToWalk = new Set<Hash>();
|
||||
for (const hash of reachable) {
|
||||
const node = store.get(hash);
|
||||
const node = store.cas.get(hash);
|
||||
if (node) {
|
||||
schemasToWalk.add(node.type);
|
||||
}
|
||||
@@ -55,7 +54,7 @@ export function gc(store: Store, varStore: VariableStore): GcStats {
|
||||
let current: Hash | null = schemaHash;
|
||||
while (current !== null && !reachable.has(current)) {
|
||||
reachable.add(current);
|
||||
const node = store.get(current);
|
||||
const node = store.cas.get(current);
|
||||
if (!node || node.type === current) {
|
||||
// Self-referencing or missing node, stop
|
||||
break;
|
||||
@@ -66,9 +65,9 @@ export function gc(store: Store, varStore: VariableStore): GcStats {
|
||||
|
||||
// Preserve all self-referencing nodes (bootstrap meta-schema)
|
||||
// These are nodes where type === hash
|
||||
const allHashes = store.listAll();
|
||||
const allHashes = store.cas.listAll();
|
||||
for (const hash of allHashes) {
|
||||
const node = store.get(hash);
|
||||
const node = store.cas.get(hash);
|
||||
if (node && node.type === hash) {
|
||||
reachable.add(hash);
|
||||
}
|
||||
@@ -81,7 +80,7 @@ export function gc(store: Store, varStore: VariableStore): GcStats {
|
||||
let collected = 0;
|
||||
for (const hash of allHashes) {
|
||||
if (!reachable.has(hash)) {
|
||||
store.delete(hash);
|
||||
store.cas.delete(hash);
|
||||
collected++;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,17 +68,17 @@ describe("computeHash", () => {
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// Step 3: store.put() and store.get()
|
||||
// Step 3: store.cas.put() and store.cas.get()
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
describe("createMemoryStore – put and get", () => {
|
||||
test("put returns a hash and get retrieves the node", async () => {
|
||||
const store = createMemoryStore().cas;
|
||||
const store = createMemoryStore();
|
||||
const typeHash = await computeSelfHash({ name: "my-type" });
|
||||
const hash = await store.put(typeHash, { greeting: "hello" });
|
||||
const hash = await store.cas.put(typeHash, { greeting: "hello" });
|
||||
|
||||
expect(hash).toHaveLength(13);
|
||||
|
||||
const node = store.get(hash);
|
||||
const node = store.cas.get(hash);
|
||||
expect(node).not.toBeNull();
|
||||
expect(node?.type).toBe(typeHash);
|
||||
expect(node?.payload).toEqual({ greeting: "hello" });
|
||||
@@ -86,69 +86,69 @@ describe("createMemoryStore – put and get", () => {
|
||||
});
|
||||
|
||||
test("get returns null for unknown hash", () => {
|
||||
const store = createMemoryStore().cas;
|
||||
expect(store.get("0000000000000")).toBeNull();
|
||||
const store = createMemoryStore();
|
||||
expect(store.cas.get("0000000000000")).toBeNull();
|
||||
});
|
||||
|
||||
test("put is idempotent: same type+payload → same hash, no duplicate", async () => {
|
||||
const store = createMemoryStore().cas;
|
||||
const store = createMemoryStore();
|
||||
const typeHash = await computeSelfHash({ name: "my-type" });
|
||||
|
||||
const h1 = await store.put(typeHash, { n: 42 });
|
||||
const h2 = await store.put(typeHash, { n: 42 });
|
||||
const h1 = await store.cas.put(typeHash, { n: 42 });
|
||||
const h2 = await store.cas.put(typeHash, { n: 42 });
|
||||
expect(h1).toBe(h2);
|
||||
expect(store.listByType(typeHash)).toHaveLength(1);
|
||||
expect(store.cas.listByType(typeHash)).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("put does not create self-referencing nodes", async () => {
|
||||
const store = createMemoryStore().cas;
|
||||
const store = createMemoryStore();
|
||||
const payload = { name: "type-descriptor" };
|
||||
const typeHash = await computeSelfHash(payload);
|
||||
const hash = await store.put(typeHash, payload);
|
||||
const hash = await store.cas.put(typeHash, payload);
|
||||
|
||||
const node = store.get(hash);
|
||||
const node = store.cas.get(hash);
|
||||
expect(node?.type).toBe(typeHash);
|
||||
expect(node?.type).not.toBe(hash);
|
||||
});
|
||||
|
||||
test("timestamp is preserved on second put (idempotency)", async () => {
|
||||
const store = createMemoryStore().cas;
|
||||
const store = createMemoryStore();
|
||||
const typeHash = await computeSelfHash({ name: "my-type" });
|
||||
|
||||
const h1 = await store.put(typeHash, { v: 1 });
|
||||
const ts1 = store.get(h1)?.timestamp;
|
||||
const h1 = await store.cas.put(typeHash, { v: 1 });
|
||||
const ts1 = store.cas.get(h1)?.timestamp;
|
||||
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
await store.put(typeHash, { v: 1 });
|
||||
const ts2 = store.get(h1)?.timestamp;
|
||||
await store.cas.put(typeHash, { v: 1 });
|
||||
const ts2 = store.cas.get(h1)?.timestamp;
|
||||
|
||||
expect(ts1).toBe(ts2);
|
||||
});
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// Step 4: store.has()
|
||||
// Step 4: store.cas.has()
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
describe("createMemoryStore – has", () => {
|
||||
test("has returns false before put, true after", async () => {
|
||||
const store = createMemoryStore().cas;
|
||||
const store = createMemoryStore();
|
||||
const typeHash = await computeSelfHash({ name: "t" });
|
||||
const hash = await computeHash(typeHash, { x: 1 });
|
||||
|
||||
expect(store.has(hash)).toBe(false);
|
||||
await store.put(typeHash, { x: 1 });
|
||||
expect(store.has(hash)).toBe(true);
|
||||
expect(store.cas.has(hash)).toBe(false);
|
||||
await store.cas.put(typeHash, { x: 1 });
|
||||
expect(store.cas.has(hash)).toBe(true);
|
||||
});
|
||||
|
||||
test("listByType returns all stored hashes for a type", async () => {
|
||||
const store = createMemoryStore().cas;
|
||||
const store = createMemoryStore();
|
||||
const typeHash = await computeSelfHash({ name: "t" });
|
||||
|
||||
const h1 = await store.put(typeHash, { a: 1 });
|
||||
const h2 = await store.put(typeHash, { a: 2 });
|
||||
const h3 = await store.put(typeHash, { a: 3 });
|
||||
const h1 = await store.cas.put(typeHash, { a: 1 });
|
||||
const h2 = await store.cas.put(typeHash, { a: 2 });
|
||||
const h3 = await store.cas.put(typeHash, { a: 3 });
|
||||
|
||||
const all = store.listByType(typeHash).map((e) => e.hash);
|
||||
const all = store.cas.listByType(typeHash).map((e) => e.hash);
|
||||
expect(all).toHaveLength(3);
|
||||
expect(all).toContain(h1);
|
||||
expect(all).toContain(h2);
|
||||
@@ -156,52 +156,52 @@ describe("createMemoryStore – has", () => {
|
||||
});
|
||||
|
||||
test("listByType returns empty array on fresh store", () => {
|
||||
const store = createMemoryStore().cas;
|
||||
expect(store.listByType("0000000000000")).toEqual([]);
|
||||
const store = createMemoryStore();
|
||||
expect(store.cas.listByType("0000000000000")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// Step 4b: store.listByType()
|
||||
// Step 4b: store.cas.listByType()
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
describe("createMemoryStore – listByType", () => {
|
||||
test("returns empty array for unknown type", () => {
|
||||
const store = createMemoryStore().cas;
|
||||
expect(store.listByType("0000000000000")).toEqual([]);
|
||||
const store = createMemoryStore();
|
||||
expect(store.cas.listByType("0000000000000")).toEqual([]);
|
||||
});
|
||||
|
||||
test("returns all hashes for the given type", async () => {
|
||||
const store = createMemoryStore().cas;
|
||||
const store = createMemoryStore();
|
||||
const typeHash = await computeSelfHash({ name: "t" });
|
||||
const otherType = await computeSelfHash({ name: "other" });
|
||||
|
||||
const h1 = await store.put(typeHash, { a: 1 });
|
||||
const h2 = await store.put(typeHash, { a: 2 });
|
||||
await store.put(otherType, { b: 1 });
|
||||
const h1 = await store.cas.put(typeHash, { a: 1 });
|
||||
const h2 = await store.cas.put(typeHash, { a: 2 });
|
||||
await store.cas.put(otherType, { b: 1 });
|
||||
|
||||
const byType = store.listByType(typeHash).map((e) => e.hash);
|
||||
const byType = store.cas.listByType(typeHash).map((e) => e.hash);
|
||||
expect(byType).toHaveLength(2);
|
||||
expect(byType).toContain(h1);
|
||||
expect(byType).toContain(h2);
|
||||
});
|
||||
|
||||
test("idempotent put does not duplicate in listByType", async () => {
|
||||
const store = createMemoryStore().cas;
|
||||
const store = createMemoryStore();
|
||||
const typeHash = await computeSelfHash({ name: "t" });
|
||||
|
||||
const h1 = await store.put(typeHash, { n: 1 });
|
||||
await store.put(typeHash, { n: 1 });
|
||||
const h1 = await store.cas.put(typeHash, { n: 1 });
|
||||
await store.cas.put(typeHash, { n: 1 });
|
||||
|
||||
expect(store.listByType(typeHash).map((e) => e.hash)).toEqual([h1]);
|
||||
expect(store.cas.listByType(typeHash).map((e) => e.hash)).toEqual([h1]);
|
||||
});
|
||||
|
||||
test("bootstrap node is listed under its self type", async () => {
|
||||
const store = createMemoryStore().cas;
|
||||
const store = createMemoryStore();
|
||||
const builtinSchemas = await bootstrap(store);
|
||||
const hash = builtinSchemas["@ocas/schema"] ?? "";
|
||||
|
||||
// All built-in schemas should be typed by the meta-schema
|
||||
const allTypedByMeta = store.listByType(hash).map((e) => e.hash);
|
||||
const allTypedByMeta = store.cas.listByType(hash).map((e) => e.hash);
|
||||
expect(allTypedByMeta).toContain(hash); // meta-schema itself
|
||||
expect(allTypedByMeta).toContain(builtinSchemas["@ocas/string"] ?? "");
|
||||
expect(allTypedByMeta).toContain(builtinSchemas["@ocas/number"] ?? "");
|
||||
@@ -216,18 +216,18 @@ describe("createMemoryStore – listByType", () => {
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
describe("verify", () => {
|
||||
test("returns true for a correctly stored node", async () => {
|
||||
const store = createMemoryStore().cas;
|
||||
const store = createMemoryStore();
|
||||
const typeHash = await computeSelfHash({ name: "my-type" });
|
||||
const hash = await store.put(typeHash, { data: 123 });
|
||||
const node = store.get(hash) as CasNode;
|
||||
const hash = await store.cas.put(typeHash, { data: 123 });
|
||||
const node = store.cas.get(hash) as CasNode;
|
||||
|
||||
expect(await verify(hash, node)).toBe(true);
|
||||
});
|
||||
|
||||
test("returns false when payload is tampered", async () => {
|
||||
const store = createMemoryStore().cas;
|
||||
const store = createMemoryStore();
|
||||
const typeHash = await computeSelfHash({ name: "my-type" });
|
||||
const hash = await store.put(typeHash, { data: 123 });
|
||||
const hash = await store.cas.put(typeHash, { data: 123 });
|
||||
|
||||
const tampered: CasNode = {
|
||||
type: typeHash,
|
||||
@@ -238,10 +238,10 @@ describe("verify", () => {
|
||||
});
|
||||
|
||||
test("returns false when type is tampered", async () => {
|
||||
const store = createMemoryStore().cas;
|
||||
const store = createMemoryStore();
|
||||
const typeHash = await computeSelfHash({ name: "my-type" });
|
||||
const hash = await store.put(typeHash, { data: 123 });
|
||||
const node = store.get(hash) as CasNode;
|
||||
const hash = await store.cas.put(typeHash, { data: 123 });
|
||||
const node = store.cas.get(hash) as CasNode;
|
||||
|
||||
const tampered: CasNode = { ...node, type: "AAAAAAAAAAAAA" };
|
||||
expect(await verify(hash, tampered)).toBe(false);
|
||||
@@ -253,19 +253,24 @@ describe("verify", () => {
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
describe("bootstrap", () => {
|
||||
test("throws when store lacks internal bootstrap path", async () => {
|
||||
const store: Store = {
|
||||
const cas: Store = {
|
||||
put: async () => "0000000000000",
|
||||
get: () => null,
|
||||
has: () => false,
|
||||
listByType: () => [],
|
||||
};
|
||||
await expect(bootstrap(store)).rejects.toThrow(
|
||||
const fakeStore = {
|
||||
cas,
|
||||
var: { set: () => null } as never,
|
||||
tag: {} as never,
|
||||
} as never;
|
||||
await expect(bootstrap(fakeStore)).rejects.toThrow(
|
||||
"Store does not support bootstrap",
|
||||
);
|
||||
});
|
||||
|
||||
test("returns a map with 30 built-in schema aliases", async () => {
|
||||
const store = createMemoryStore().cas;
|
||||
const store = createMemoryStore();
|
||||
const builtinSchemas = await bootstrap(store);
|
||||
|
||||
expect(builtinSchemas).toHaveProperty("@ocas/schema");
|
||||
@@ -288,40 +293,40 @@ describe("bootstrap", () => {
|
||||
});
|
||||
|
||||
test("meta-schema node is stored and retrievable", async () => {
|
||||
const store = createMemoryStore().cas;
|
||||
const store = createMemoryStore();
|
||||
const builtinSchemas = await bootstrap(store);
|
||||
const metaHash = builtinSchemas["@ocas/schema"] ?? "";
|
||||
|
||||
expect(store.has(metaHash)).toBe(true);
|
||||
const node = store.get(metaHash);
|
||||
expect(store.cas.has(metaHash)).toBe(true);
|
||||
const node = store.cas.get(metaHash);
|
||||
expect(node).not.toBeNull();
|
||||
});
|
||||
|
||||
test("meta-schema node is self-referencing: type === hash", async () => {
|
||||
const store = createMemoryStore().cas;
|
||||
const store = createMemoryStore();
|
||||
const builtinSchemas = await bootstrap(store);
|
||||
const metaHash = builtinSchemas["@ocas/schema"] ?? "";
|
||||
const node = store.get(metaHash) as CasNode;
|
||||
const node = store.cas.get(metaHash) as CasNode;
|
||||
|
||||
expect(node.type).toBe(metaHash);
|
||||
});
|
||||
|
||||
test("bootstrap node passes verify()", async () => {
|
||||
const store = createMemoryStore().cas;
|
||||
const store = createMemoryStore();
|
||||
const builtinSchemas = await bootstrap(store);
|
||||
const metaHash = builtinSchemas["@ocas/schema"] ?? "";
|
||||
const node = store.get(metaHash) as CasNode;
|
||||
const node = store.cas.get(metaHash) as CasNode;
|
||||
|
||||
expect(await verify(metaHash, node)).toBe(true);
|
||||
});
|
||||
|
||||
test("bootstrap is idempotent: same hashes on repeated calls", async () => {
|
||||
const store = createMemoryStore().cas;
|
||||
const store = createMemoryStore();
|
||||
const h1 = await bootstrap(store);
|
||||
const h2 = await bootstrap(store);
|
||||
|
||||
expect(h1).toEqual(h2);
|
||||
// All built-in schemas typed by the meta-schema (1 self + 7 unique primitives + 21 outputs)
|
||||
expect(store.listByType(h1["@ocas/schema"] ?? "")).toHaveLength(29);
|
||||
expect(store.cas.listByType(h1["@ocas/schema"] ?? "")).toHaveLength(29);
|
||||
});
|
||||
});
|
||||
|
||||
+21
-13
@@ -2,8 +2,23 @@ export { bootstrap } from "./bootstrap.js";
|
||||
export type { BootstrapCapableStore } from "./bootstrap-capable.js";
|
||||
export { BOOTSTRAP_STORE } from "./bootstrap-capable.js";
|
||||
export { cborEncode } from "./cbor.js";
|
||||
export {
|
||||
CasNodeNotFoundError,
|
||||
InvalidTagFormatError,
|
||||
InvalidVariableNameError,
|
||||
MAX_HISTORY,
|
||||
SchemaMismatchError,
|
||||
TagLabelConflictError,
|
||||
VariableNotFoundError,
|
||||
} from "./errors.js";
|
||||
export { type GcStats, gc } from "./gc.js";
|
||||
export { computeHash, computeSelfHash } from "./hash.js";
|
||||
export {
|
||||
computeHash,
|
||||
computeHashSync,
|
||||
computeSelfHash,
|
||||
computeSelfHashSync,
|
||||
initHasher,
|
||||
} from "./hash.js";
|
||||
export { renderWithTemplate } from "./liquid-render.js";
|
||||
export { applyListOptions, casListEntry } from "./list-utils.js";
|
||||
export { registerOutputTemplates } from "./output-templates.js";
|
||||
@@ -22,7 +37,11 @@ export {
|
||||
validate,
|
||||
walk,
|
||||
} from "./schema.js";
|
||||
export { createMemoryStore } from "./store.js";
|
||||
export {
|
||||
createMemoryStore,
|
||||
createMemoryTagStoreImpl,
|
||||
createMemoryVarStoreFor,
|
||||
} from "./store.js";
|
||||
export type {
|
||||
CasNode,
|
||||
CasStore,
|
||||
@@ -41,16 +60,5 @@ export type {
|
||||
VarStore,
|
||||
} from "./types.js";
|
||||
export type { Variable } from "./variable.js";
|
||||
export {
|
||||
CasNodeNotFoundError,
|
||||
createVariableStore,
|
||||
InvalidTagFormatError,
|
||||
InvalidVariableNameError,
|
||||
MAX_HISTORY,
|
||||
SchemaMismatchError,
|
||||
TagLabelConflictError,
|
||||
VariableNotFoundError,
|
||||
VariableStore,
|
||||
} from "./variable-store.js";
|
||||
export { verify } from "./verify.js";
|
||||
export { wrapEnvelope } from "./wrap-envelope.js";
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,7 @@
|
||||
import { type Context, Liquid, type TagToken } from "liquidjs";
|
||||
import type { RenderOptions } from "./render.js";
|
||||
import { putSchema } from "./schema.js";
|
||||
import type { Hash, Store } from "./types.js";
|
||||
import type { VariableStore } from "./variable-store.js";
|
||||
import type { Hash, OcasStore } from "./types.js";
|
||||
|
||||
const DEFAULT_RESOLUTION = 1.0;
|
||||
const DEFAULT_DECAY = 0.5;
|
||||
@@ -14,8 +13,7 @@ const FLOAT_TOLERANCE = 1e-10;
|
||||
* Templates are discovered via variables: @ocas/template/text/<type-hash>
|
||||
*/
|
||||
export async function renderWithTemplate(
|
||||
store: Store,
|
||||
varStore: VariableStore,
|
||||
store: OcasStore,
|
||||
hash: Hash,
|
||||
options?: RenderOptions,
|
||||
): Promise<string> {
|
||||
@@ -37,27 +35,15 @@ export async function renderWithTemplate(
|
||||
const visited = new Set<Hash>();
|
||||
|
||||
// Create Liquid engine
|
||||
const engine = createLiquidEngine(store, varStore, decay);
|
||||
const engine = createLiquidEngine(store, decay);
|
||||
|
||||
return await renderNode(
|
||||
engine,
|
||||
store,
|
||||
varStore,
|
||||
hash,
|
||||
resolution,
|
||||
epsilon,
|
||||
visited,
|
||||
);
|
||||
return await renderNode(engine, store, hash, resolution, epsilon, visited);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a Liquid engine instance with custom render tag
|
||||
*/
|
||||
function createLiquidEngine(
|
||||
store: Store,
|
||||
varStore: VariableStore,
|
||||
globalDecay: number,
|
||||
): Liquid {
|
||||
function createLiquidEngine(store: OcasStore, globalDecay: number): Liquid {
|
||||
const engine = new Liquid({
|
||||
strictFilters: false,
|
||||
strictVariables: false,
|
||||
@@ -70,7 +56,7 @@ function createLiquidEngine(
|
||||
};
|
||||
|
||||
// Register custom {% render %} tag
|
||||
// Capture store, varStore, globalDecay in closure
|
||||
// Capture store, globalDecay in closure
|
||||
engine.registerTag("render", {
|
||||
parse(token: TagToken) {
|
||||
// Parse "variable" or "variable, decay: 0.7" syntax
|
||||
@@ -137,7 +123,6 @@ function createLiquidEngine(
|
||||
const output = await renderNode(
|
||||
engine,
|
||||
store,
|
||||
varStore,
|
||||
nodeHash,
|
||||
childResolution,
|
||||
currentEpsilon,
|
||||
@@ -156,8 +141,7 @@ function createLiquidEngine(
|
||||
*/
|
||||
async function renderNode(
|
||||
engine: Liquid,
|
||||
store: Store,
|
||||
varStore: VariableStore,
|
||||
store: OcasStore,
|
||||
hash: Hash,
|
||||
currentResolution: number,
|
||||
epsilon: number,
|
||||
@@ -169,7 +153,7 @@ async function renderNode(
|
||||
}
|
||||
|
||||
// Fetch the node
|
||||
const node = store.get(hash);
|
||||
const node = store.cas.get(hash);
|
||||
if (node === null) {
|
||||
return `cas:${hash}`;
|
||||
}
|
||||
@@ -182,13 +166,13 @@ async function renderNode(
|
||||
|
||||
try {
|
||||
// Try to find a template for this node's type
|
||||
const template = await findTemplate(store, varStore, node.type);
|
||||
const template = await findTemplate(store, node.type);
|
||||
|
||||
if (template === null) {
|
||||
// No template found - this is handled by the caller (fallback to YAML)
|
||||
// For now, return a simple representation
|
||||
visited.delete(hash);
|
||||
return renderFallback(store, node.payload);
|
||||
return renderFallback(node.payload);
|
||||
}
|
||||
|
||||
// Render using the template
|
||||
@@ -216,8 +200,7 @@ async function renderNode(
|
||||
* Find a template for a given type hash
|
||||
*/
|
||||
async function findTemplate(
|
||||
store: Store,
|
||||
varStore: VariableStore,
|
||||
store: OcasStore,
|
||||
typeHash: Hash,
|
||||
): Promise<string | null> {
|
||||
const varName = `@ocas/template/text/${typeHash}`;
|
||||
@@ -226,12 +209,12 @@ async function findTemplate(
|
||||
// Find the string schema hash (we need this to query variables)
|
||||
const stringSchema = await putSchema(store, { type: "string" });
|
||||
|
||||
const variable = varStore.get(varName, stringSchema);
|
||||
const variable = store.var.get(varName, stringSchema);
|
||||
if (variable === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const templateNode = store.get(variable.value);
|
||||
const templateNode = store.cas.get(variable.value);
|
||||
if (templateNode === null) {
|
||||
return null;
|
||||
}
|
||||
@@ -250,7 +233,7 @@ async function findTemplate(
|
||||
/**
|
||||
* Fallback renderer for nodes without templates
|
||||
*/
|
||||
function renderFallback(_store: Store, payload: unknown): string {
|
||||
function renderFallback(payload: unknown): string {
|
||||
// Simple YAML-like representation
|
||||
if (payload === null) {
|
||||
return "null\n";
|
||||
|
||||
@@ -12,7 +12,7 @@ async function putN(
|
||||
): Promise<string[]> {
|
||||
const hashes: string[] = [];
|
||||
for (let i = 0; i < n; i++) {
|
||||
hashes.push(await store.put(type, { i }));
|
||||
hashes.push(store.cas.put(type, { i }));
|
||||
if (delayMs > 0 && i < n - 1) {
|
||||
await new Promise((r) => setTimeout(r, delayMs));
|
||||
}
|
||||
@@ -22,11 +22,11 @@ async function putN(
|
||||
|
||||
describe("listByType - pagination + sort + timestamps", () => {
|
||||
test("A1. returns objects with hash/created/updated", async () => {
|
||||
const store = createMemoryStore().cas;
|
||||
const m = await store[BOOTSTRAP_STORE]({ type: "object" });
|
||||
const store = createMemoryStore();
|
||||
const m = await store.cas[BOOTSTRAP_STORE]({ type: "object" });
|
||||
await putN(store, m, 3, 0);
|
||||
|
||||
const list = store.listByType(m);
|
||||
const list = store.cas.listByType(m);
|
||||
for (const e of list) {
|
||||
expect(e.hash).toMatch(HASH_RE);
|
||||
expect(typeof e.created).toBe("number");
|
||||
@@ -36,11 +36,11 @@ describe("listByType - pagination + sort + timestamps", () => {
|
||||
});
|
||||
|
||||
test("A2. default sort is created ASC", async () => {
|
||||
const store = createMemoryStore().cas;
|
||||
const m = await store[BOOTSTRAP_STORE]({ type: "object" });
|
||||
const store = createMemoryStore();
|
||||
const m = await store.cas[BOOTSTRAP_STORE]({ type: "object" });
|
||||
await putN(store, m, 4);
|
||||
|
||||
const list = store.listByType(m);
|
||||
const list = store.cas.listByType(m);
|
||||
for (let i = 1; i < list.length; i++) {
|
||||
expect((list[i] as { created: number }).created).toBeGreaterThanOrEqual(
|
||||
(list[i - 1] as { created: number }).created,
|
||||
@@ -49,11 +49,11 @@ describe("listByType - pagination + sort + timestamps", () => {
|
||||
});
|
||||
|
||||
test("A3. desc:true reverses order", async () => {
|
||||
const store = createMemoryStore().cas;
|
||||
const m = await store[BOOTSTRAP_STORE]({ type: "object" });
|
||||
const store = createMemoryStore();
|
||||
const m = await store.cas[BOOTSTRAP_STORE]({ type: "object" });
|
||||
await putN(store, m, 4);
|
||||
|
||||
const list = store.listByType(m, { desc: true });
|
||||
const list = store.cas.listByType(m, { desc: true });
|
||||
for (let i = 1; i < list.length; i++) {
|
||||
expect((list[i] as { created: number }).created).toBeLessThanOrEqual(
|
||||
(list[i - 1] as { created: number }).created,
|
||||
@@ -62,62 +62,62 @@ describe("listByType - pagination + sort + timestamps", () => {
|
||||
});
|
||||
|
||||
test("A4. sort: 'updated' is equivalent to 'created' for CAS nodes", async () => {
|
||||
const store = createMemoryStore().cas;
|
||||
const m = await store[BOOTSTRAP_STORE]({ type: "object" });
|
||||
const store = createMemoryStore();
|
||||
const m = await store.cas[BOOTSTRAP_STORE]({ type: "object" });
|
||||
await putN(store, m, 4);
|
||||
|
||||
const a = store.listByType(m, { sort: "created" });
|
||||
const b = store.listByType(m, { sort: "updated" });
|
||||
const a = store.cas.listByType(m, { sort: "created" });
|
||||
const b = store.cas.listByType(m, { sort: "updated" });
|
||||
expect(a).toEqual(b);
|
||||
});
|
||||
|
||||
test("A5. limit truncates", async () => {
|
||||
const store = createMemoryStore().cas;
|
||||
const m = await store[BOOTSTRAP_STORE]({ type: "object" });
|
||||
const store = createMemoryStore();
|
||||
const m = await store.cas[BOOTSTRAP_STORE]({ type: "object" });
|
||||
await putN(store, m, 5, 0);
|
||||
expect(store.listByType(m, { limit: 2 })).toHaveLength(2);
|
||||
expect(store.cas.listByType(m, { limit: 2 })).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("A6. offset skips", async () => {
|
||||
const store = createMemoryStore().cas;
|
||||
const m = await store[BOOTSTRAP_STORE]({ type: "object" });
|
||||
const store = createMemoryStore();
|
||||
const m = await store.cas[BOOTSTRAP_STORE]({ type: "object" });
|
||||
await putN(store, m, 5);
|
||||
|
||||
const all = store.listByType(m);
|
||||
const skip = store.listByType(m, { offset: 2, limit: 10 });
|
||||
const all = store.cas.listByType(m);
|
||||
const skip = store.cas.listByType(m, { offset: 2, limit: 10 });
|
||||
expect(skip).toHaveLength(all.length - 2);
|
||||
expect(skip[0]).toEqual(all[2] as (typeof all)[number]);
|
||||
});
|
||||
|
||||
test("A7. limit:0 returns empty array", async () => {
|
||||
const store = createMemoryStore().cas;
|
||||
const m = await store[BOOTSTRAP_STORE]({ type: "object" });
|
||||
const store = createMemoryStore();
|
||||
const m = await store.cas[BOOTSTRAP_STORE]({ type: "object" });
|
||||
await putN(store, m, 3, 0);
|
||||
expect(store.listByType(m, { limit: 0 })).toEqual([]);
|
||||
expect(store.cas.listByType(m, { limit: 0 })).toEqual([]);
|
||||
});
|
||||
|
||||
test("A8. offset past end returns empty array", async () => {
|
||||
const store = createMemoryStore().cas;
|
||||
const m = await store[BOOTSTRAP_STORE]({ type: "object" });
|
||||
const store = createMemoryStore();
|
||||
const m = await store.cas[BOOTSTRAP_STORE]({ type: "object" });
|
||||
await putN(store, m, 3, 0);
|
||||
expect(store.listByType(m, { offset: 100 })).toEqual([]);
|
||||
expect(store.cas.listByType(m, { offset: 100 })).toEqual([]);
|
||||
});
|
||||
|
||||
test("A9. core has no default limit (returns all)", async () => {
|
||||
const store = createMemoryStore().cas;
|
||||
const m = await store[BOOTSTRAP_STORE]({ type: "object" });
|
||||
const store = createMemoryStore();
|
||||
const m = await store.cas[BOOTSTRAP_STORE]({ type: "object" });
|
||||
await putN(store, m, 150, 0);
|
||||
// No CLI-layer cap; with 150 nodes of type m (plus m itself which is
|
||||
// self-typed), the full set is returned.
|
||||
expect(store.listByType(m)).toHaveLength(151);
|
||||
expect(store.cas.listByType(m)).toHaveLength(151);
|
||||
});
|
||||
|
||||
test("A10. desc + offset + limit combined", async () => {
|
||||
const store = createMemoryStore().cas;
|
||||
const m = await store[BOOTSTRAP_STORE]({ type: "object" });
|
||||
const store = createMemoryStore();
|
||||
const m = await store.cas[BOOTSTRAP_STORE]({ type: "object" });
|
||||
await putN(store, m, 5, 15);
|
||||
const all = store.listByType(m);
|
||||
const got = store.listByType(m, { desc: true, offset: 1, limit: 2 });
|
||||
const all = store.cas.listByType(m);
|
||||
const got = store.cas.listByType(m, { desc: true, offset: 1, limit: 2 });
|
||||
expect(got).toHaveLength(2);
|
||||
// desc order is reverse of `all`; offset 1 + limit 2 → all[n-2], all[n-3]
|
||||
const n = all.length;
|
||||
@@ -128,9 +128,9 @@ describe("listByType - pagination + sort + timestamps", () => {
|
||||
|
||||
describe("listMeta / listSchemas - pagination", () => {
|
||||
test("B1. listMeta returns {hash,created,updated}", async () => {
|
||||
const store = createMemoryStore().cas;
|
||||
const h = await store[BOOTSTRAP_STORE]({ type: "object" });
|
||||
const list = store.listMeta();
|
||||
const store = createMemoryStore();
|
||||
const h = await store.cas[BOOTSTRAP_STORE]({ type: "object" });
|
||||
const list = store.cas.listMeta();
|
||||
expect(list).toHaveLength(1);
|
||||
const e = list[0] as { hash: string; created: number; updated: number };
|
||||
expect(e.hash).toBe(h);
|
||||
@@ -139,55 +139,55 @@ describe("listMeta / listSchemas - pagination", () => {
|
||||
});
|
||||
|
||||
test("B2. listMeta has no default limit (returns all)", async () => {
|
||||
const store = createMemoryStore().cas;
|
||||
const store = createMemoryStore();
|
||||
for (let i = 0; i < 150; i++) {
|
||||
await store[BOOTSTRAP_STORE]({ type: "object", i });
|
||||
await store.cas[BOOTSTRAP_STORE]({ type: "object", i });
|
||||
}
|
||||
expect(store.listMeta()).toHaveLength(150);
|
||||
expect(store.cas.listMeta()).toHaveLength(150);
|
||||
});
|
||||
|
||||
test("B3. listMeta limit/offset/desc", async () => {
|
||||
const store = createMemoryStore().cas;
|
||||
const store = createMemoryStore();
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await store[BOOTSTRAP_STORE]({ type: "object", i });
|
||||
await store.cas[BOOTSTRAP_STORE]({ type: "object", i });
|
||||
await new Promise((r) => setTimeout(r, 2));
|
||||
}
|
||||
expect(store.listMeta({ limit: 2 })).toHaveLength(2);
|
||||
const all = store.listMeta();
|
||||
const desc = store.listMeta({ desc: true });
|
||||
expect(store.cas.listMeta({ limit: 2 })).toHaveLength(2);
|
||||
const all = store.cas.listMeta();
|
||||
const desc = store.cas.listMeta({ desc: true });
|
||||
expect(desc[0]).toEqual(all[all.length - 1] as (typeof all)[number]);
|
||||
});
|
||||
|
||||
test("B4. listSchemas returns objects, supports limit", async () => {
|
||||
const store = createMemoryStore().cas;
|
||||
const m = await store[BOOTSTRAP_STORE]({ type: "object" });
|
||||
await store.put(m, { type: "string" });
|
||||
await store.put(m, { type: "number" });
|
||||
const store = createMemoryStore();
|
||||
const m = await store.cas[BOOTSTRAP_STORE]({ type: "object" });
|
||||
store.cas.put(m, { type: "string" });
|
||||
store.cas.put(m, { type: "number" });
|
||||
|
||||
const list = store.listSchemas();
|
||||
const list = store.cas.listSchemas();
|
||||
for (const e of list) {
|
||||
expect(e.hash).toMatch(HASH_RE);
|
||||
expect(typeof e.created).toBe("number");
|
||||
}
|
||||
expect(store.listSchemas({ limit: 1 })).toHaveLength(1);
|
||||
expect(store.cas.listSchemas({ limit: 1 })).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Determinism / edge cases", () => {
|
||||
test("I1. same-ms timestamps yield deterministic ordering across calls", async () => {
|
||||
const store = createMemoryStore().cas;
|
||||
const m = await store[BOOTSTRAP_STORE]({ type: "object" });
|
||||
const store = createMemoryStore();
|
||||
const m = await store.cas[BOOTSTRAP_STORE]({ type: "object" });
|
||||
// No delay → likely same millisecond
|
||||
await putN(store, m, 5, 0);
|
||||
const a = store.listByType(m);
|
||||
const b = store.listByType(m);
|
||||
const a = store.cas.listByType(m);
|
||||
const b = store.cas.listByType(m);
|
||||
expect(b).toEqual(a);
|
||||
});
|
||||
|
||||
test("I2. empty store returns []", () => {
|
||||
const store = createMemoryStore().cas;
|
||||
expect(store.listByType("0000000000000")).toEqual([]);
|
||||
expect(store.listMeta()).toEqual([]);
|
||||
expect(store.listSchemas()).toEqual([]);
|
||||
const store = createMemoryStore();
|
||||
expect(store.cas.listByType("0000000000000")).toEqual([]);
|
||||
expect(store.cas.listMeta()).toEqual([]);
|
||||
expect(store.cas.listSchemas()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,51 +1,37 @@
|
||||
import type { BootstrapCapableStore } from "./bootstrap-capable.js";
|
||||
import { BOOTSTRAP_STORE } from "./bootstrap-capable.js";
|
||||
import { createMemoryStore } from "./store.js";
|
||||
import type { CasNode, Hash, ListEntry, ListOptions } from "./types.js";
|
||||
import type { CasStore, OcasStore, TagStore, VarStore } from "./types.js";
|
||||
|
||||
/** In-memory store wrapper used by schema validation tests. Wraps the
|
||||
* `cas` sub-store of an `OcasStore` and exposes the legacy
|
||||
* `BootstrapCapableStore` interface (async `put`, etc.). */
|
||||
export class MemStore implements BootstrapCapableStore {
|
||||
readonly #inner: ReturnType<typeof createMemoryStore>["cas"];
|
||||
/**
|
||||
* In-memory `OcasStore` used by schema validation tests. It exposes the
|
||||
* `cas`, `var`, and `tag` sub-stores of an `OcasStore` plus a few legacy
|
||||
* pass-through helpers (`get`, `put`, `has`, …) that some older tests still
|
||||
* use directly.
|
||||
*/
|
||||
export class MemStore implements OcasStore {
|
||||
readonly cas: CasStore;
|
||||
readonly var: VarStore;
|
||||
readonly tag: TagStore;
|
||||
|
||||
constructor() {
|
||||
this.#inner = createMemoryStore().cas;
|
||||
const store = createMemoryStore();
|
||||
this.cas = store.cas;
|
||||
this.var = store.var;
|
||||
this.tag = store.tag;
|
||||
}
|
||||
|
||||
async put(typeHash: Hash, payload: unknown): Promise<Hash> {
|
||||
return this.#inner.put(typeHash, payload);
|
||||
}
|
||||
|
||||
get(hash: Hash): CasNode | null {
|
||||
return this.#inner.get(hash);
|
||||
}
|
||||
|
||||
has(hash: Hash): boolean {
|
||||
return this.#inner.has(hash);
|
||||
}
|
||||
|
||||
listByType(typeHash: Hash, options?: ListOptions): ListEntry[] {
|
||||
return this.#inner.listByType(typeHash, options);
|
||||
}
|
||||
|
||||
listAll(): Hash[] {
|
||||
return this.#inner.listAll();
|
||||
}
|
||||
|
||||
listMeta(options?: ListOptions): ListEntry[] {
|
||||
return this.#inner.listMeta(options);
|
||||
}
|
||||
|
||||
listSchemas(options?: ListOptions): ListEntry[] {
|
||||
return this.#inner.listSchemas(options);
|
||||
}
|
||||
|
||||
delete(hash: Hash): void {
|
||||
this.#inner.delete(hash);
|
||||
}
|
||||
|
||||
async [BOOTSTRAP_STORE](payload: unknown): Promise<Hash> {
|
||||
return this.#inner[BOOTSTRAP_STORE](payload);
|
||||
}
|
||||
// Legacy convenience pass-throughs ----------------------------------------
|
||||
get = (hash: Parameters<CasStore["get"]>[0]): ReturnType<CasStore["get"]> =>
|
||||
this.cas.get(hash);
|
||||
has = (hash: Parameters<CasStore["has"]>[0]): ReturnType<CasStore["has"]> =>
|
||||
this.cas.has(hash);
|
||||
put = (
|
||||
typeHash: Parameters<CasStore["put"]>[0],
|
||||
payload: unknown,
|
||||
): ReturnType<CasStore["put"]> => this.cas.put(typeHash, payload);
|
||||
listByType: CasStore["listByType"] = (typeHash, options) =>
|
||||
this.cas.listByType(typeHash, options);
|
||||
listAll: CasStore["listAll"] = () => this.cas.listAll();
|
||||
listMeta: CasStore["listMeta"] = (options) => this.cas.listMeta(options);
|
||||
listSchemas: CasStore["listSchemas"] = (options) =>
|
||||
this.cas.listSchemas(options);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
function* walk(dir: string): Generator<string> {
|
||||
for (const name of readdirSync(dir)) {
|
||||
const path = join(dir, name);
|
||||
const stats = statSync(path);
|
||||
if (stats.isDirectory()) {
|
||||
yield* walk(path);
|
||||
} else if (stats.isFile()) {
|
||||
yield path;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe("no SQLite in @ocas/core", () => {
|
||||
test("source files do not import sqlite", () => {
|
||||
const srcDir = import.meta.dir;
|
||||
const needle = ["bun", "sqlite"].join(":");
|
||||
for (const file of walk(srcDir)) {
|
||||
if (!file.endsWith(".ts")) continue;
|
||||
if (file.endsWith("no-sqlite.test.ts")) continue;
|
||||
const content = readFileSync(file, "utf-8");
|
||||
expect(content).not.toContain(needle);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,13 +1,7 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { bootstrap } from "./bootstrap.js";
|
||||
import { registerOutputTemplates } from "./output-templates.js";
|
||||
import { createMemoryStore } from "./store.js";
|
||||
import type { Store } from "./types.js";
|
||||
import type { VariableStore } from "./variable-store.js";
|
||||
import { createVariableStore } from "./variable-store.js";
|
||||
|
||||
const OUTPUT_ALIASES = [
|
||||
"@ocas/output/put",
|
||||
@@ -32,22 +26,11 @@ const OUTPUT_ALIASES = [
|
||||
] as const;
|
||||
|
||||
describe("registerOutputTemplates", () => {
|
||||
let store: Store;
|
||||
let varStore: VariableStore;
|
||||
let tempDir: string;
|
||||
|
||||
afterEach(async () => {
|
||||
varStore.close();
|
||||
await rm(tempDir, { recursive: true });
|
||||
});
|
||||
|
||||
test("registers a template for every @ocas/output/* schema", async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), "ocas-tmpl-"));
|
||||
store = createMemoryStore().cas;
|
||||
const store = createMemoryStore();
|
||||
await bootstrap(store);
|
||||
varStore = createVariableStore(join(tempDir, "vars.db"), store);
|
||||
|
||||
const registered = await registerOutputTemplates(store, varStore);
|
||||
const registered = await registerOutputTemplates(store);
|
||||
|
||||
expect(Object.keys(registered)).toHaveLength(19);
|
||||
|
||||
@@ -57,12 +40,10 @@ describe("registerOutputTemplates", () => {
|
||||
});
|
||||
|
||||
test("each template is retrievable via @ocas/template/text/<hash>", async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), "ocas-tmpl-"));
|
||||
store = createMemoryStore().cas;
|
||||
const store = createMemoryStore();
|
||||
const aliases = await bootstrap(store);
|
||||
varStore = createVariableStore(join(tempDir, "vars.db"), store);
|
||||
|
||||
await registerOutputTemplates(store, varStore);
|
||||
await registerOutputTemplates(store);
|
||||
|
||||
const stringHash = aliases["@ocas/string"];
|
||||
if (!stringHash) throw new Error("@ocas/string not found");
|
||||
@@ -72,10 +53,10 @@ describe("registerOutputTemplates", () => {
|
||||
if (!schemaHash) throw new Error(`${alias} not found`);
|
||||
|
||||
const varName = `@ocas/template/text/${schemaHash}`;
|
||||
const variable = varStore.get(varName, stringHash);
|
||||
const variable = store.var.get(varName, stringHash);
|
||||
if (variable === null) throw new Error(`Variable ${varName} not found`);
|
||||
|
||||
const templateNode = store.get(variable.value);
|
||||
const templateNode = store.cas.get(variable.value);
|
||||
if (templateNode === null)
|
||||
throw new Error(`Template node ${variable.value} not found`);
|
||||
expect(typeof templateNode.payload).toBe("string");
|
||||
@@ -83,35 +64,34 @@ describe("registerOutputTemplates", () => {
|
||||
});
|
||||
|
||||
test("is idempotent — safe to call multiple times", async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), "ocas-tmpl-"));
|
||||
store = createMemoryStore().cas;
|
||||
const store = createMemoryStore();
|
||||
await bootstrap(store);
|
||||
varStore = createVariableStore(join(tempDir, "vars.db"), store);
|
||||
|
||||
const first = await registerOutputTemplates(store, varStore);
|
||||
const second = await registerOutputTemplates(store, varStore);
|
||||
const first = await registerOutputTemplates(store);
|
||||
const second = await registerOutputTemplates(store);
|
||||
|
||||
expect(first).toEqual(second);
|
||||
});
|
||||
|
||||
test("@ocas/output/put template contains payload reference", async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), "ocas-tmpl-"));
|
||||
store = createMemoryStore().cas;
|
||||
const store = createMemoryStore();
|
||||
const aliases = await bootstrap(store);
|
||||
varStore = createVariableStore(join(tempDir, "vars.db"), store);
|
||||
|
||||
await registerOutputTemplates(store, varStore);
|
||||
await registerOutputTemplates(store);
|
||||
|
||||
const putHash = aliases["@ocas/output/put"];
|
||||
if (!putHash) throw new Error("@ocas/output/put not found");
|
||||
const stringHash = aliases["@ocas/string"];
|
||||
if (!stringHash) throw new Error("@ocas/string not found");
|
||||
|
||||
const variable = varStore.get(`@ocas/template/text/${putHash}`, stringHash);
|
||||
const variable = store.var.get(
|
||||
`@ocas/template/text/${putHash}`,
|
||||
stringHash,
|
||||
);
|
||||
if (variable === null)
|
||||
throw new Error("@ocas/output/put template variable not found");
|
||||
|
||||
const templateNode = store.get(variable.value);
|
||||
const templateNode = store.cas.get(variable.value);
|
||||
if (templateNode === null) throw new Error("Template node not found");
|
||||
expect(templateNode.payload).toBe("{{ payload }}");
|
||||
});
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { bootstrap } from "./bootstrap.js";
|
||||
import type { Hash, Store } from "./types.js";
|
||||
import type { VariableStore } from "./variable-store.js";
|
||||
import type { Hash, OcasStore } from "./types.js";
|
||||
|
||||
const DEFAULT_TEMPLATES: ReadonlyArray<
|
||||
readonly [alias: string, template: string]
|
||||
@@ -64,8 +63,7 @@ const DEFAULT_TEMPLATES: ReadonlyArray<
|
||||
* Idempotent: safe to call multiple times.
|
||||
*/
|
||||
export async function registerOutputTemplates(
|
||||
store: Store,
|
||||
varStore: VariableStore,
|
||||
store: OcasStore,
|
||||
): Promise<Record<string, Hash>> {
|
||||
const aliases = await bootstrap(store);
|
||||
const stringHash = aliases["@ocas/string"];
|
||||
@@ -81,9 +79,9 @@ export async function registerOutputTemplates(
|
||||
throw new Error(`Schema alias not found: ${alias}`);
|
||||
}
|
||||
|
||||
const contentHash = await store.put(stringHash, template);
|
||||
const contentHash = store.cas.put(stringHash, template);
|
||||
const varName = `@ocas/template/text/${schemaHash}`;
|
||||
varStore.set(varName, contentHash);
|
||||
store.var.set(varName, contentHash);
|
||||
registered[alias] = contentHash;
|
||||
}
|
||||
|
||||
|
||||
+101
-101
@@ -1,17 +1,17 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { bootstrap } from "./bootstrap.js";
|
||||
import { CasNodeNotFoundError } from "./errors.js";
|
||||
import { render, renderAsync, renderDirect } from "./render.js";
|
||||
import { putSchema } from "./schema.js";
|
||||
import { createMemoryStore } from "./store.js";
|
||||
import type { Hash } from "./types.js";
|
||||
import { CasNodeNotFoundError } from "./variable-store.js";
|
||||
|
||||
describe("Suite 1: Basic Rendering (No Nesting)", () => {
|
||||
test("1.1 Render Simple Primitives", async () => {
|
||||
const store = createMemoryStore().cas;
|
||||
const store = createMemoryStore();
|
||||
await bootstrap(store);
|
||||
const textSchema = await putSchema(store, { type: "string" });
|
||||
const hash = await store.put(textSchema, "hello");
|
||||
const hash = store.cas.put(textSchema, "hello");
|
||||
|
||||
const output = render(store, hash, { resolution: 1.0 });
|
||||
|
||||
@@ -20,7 +20,7 @@ describe("Suite 1: Basic Rendering (No Nesting)", () => {
|
||||
});
|
||||
|
||||
test("1.2 Render Object Node (Flat)", async () => {
|
||||
const store = createMemoryStore().cas;
|
||||
const store = createMemoryStore();
|
||||
await bootstrap(store);
|
||||
const objSchema = await putSchema(store, {
|
||||
type: "object",
|
||||
@@ -29,7 +29,7 @@ describe("Suite 1: Basic Rendering (No Nesting)", () => {
|
||||
count: { type: "number" },
|
||||
},
|
||||
});
|
||||
const hash = await store.put(objSchema, { name: "test", count: 42 });
|
||||
const hash = store.cas.put(objSchema, { name: "test", count: 42 });
|
||||
|
||||
const output = render(store, hash, { resolution: 1.0 });
|
||||
|
||||
@@ -40,13 +40,13 @@ describe("Suite 1: Basic Rendering (No Nesting)", () => {
|
||||
});
|
||||
|
||||
test("1.3 Render Array Node (Flat)", async () => {
|
||||
const store = createMemoryStore().cas;
|
||||
const store = createMemoryStore();
|
||||
await bootstrap(store);
|
||||
const arraySchema = await putSchema(store, {
|
||||
type: "array",
|
||||
items: { type: "number" },
|
||||
});
|
||||
const hash = await store.put(arraySchema, [1, 2, 3]);
|
||||
const hash = store.cas.put(arraySchema, [1, 2, 3]);
|
||||
|
||||
const output = render(store, hash, { resolution: 1.0 });
|
||||
|
||||
@@ -56,10 +56,10 @@ describe("Suite 1: Basic Rendering (No Nesting)", () => {
|
||||
});
|
||||
|
||||
test("1.4 Render with resolution=0 (Force Reference)", async () => {
|
||||
const store = createMemoryStore().cas;
|
||||
const store = createMemoryStore();
|
||||
await bootstrap(store);
|
||||
const textSchema = await putSchema(store, { type: "string" });
|
||||
const hash = await store.put(textSchema, "hello");
|
||||
const hash = store.cas.put(textSchema, "hello");
|
||||
|
||||
const output = render(store, hash, { resolution: 0 });
|
||||
|
||||
@@ -67,7 +67,7 @@ describe("Suite 1: Basic Rendering (No Nesting)", () => {
|
||||
});
|
||||
|
||||
test("1.5 Render Non-existent Hash Throws Error", () => {
|
||||
const store = createMemoryStore().cas;
|
||||
const store = createMemoryStore();
|
||||
const fakeHash = "ZZZZZZZZZZZZZ" as Hash;
|
||||
|
||||
// Non-existent root node should throw
|
||||
@@ -79,7 +79,7 @@ describe("Suite 1: Basic Rendering (No Nesting)", () => {
|
||||
|
||||
describe("Suite 2: Resolution Decay Model", () => {
|
||||
test("2.1 Single-level Nesting with Default Decay", async () => {
|
||||
const store = createMemoryStore().cas;
|
||||
const store = createMemoryStore();
|
||||
await bootstrap(store);
|
||||
|
||||
const childSchema = await putSchema(store, {
|
||||
@@ -88,7 +88,7 @@ describe("Suite 2: Resolution Decay Model", () => {
|
||||
content: { type: "string" },
|
||||
},
|
||||
});
|
||||
const childHash = await store.put(childSchema, { content: "leaf" });
|
||||
const childHash = store.cas.put(childSchema, { content: "leaf" });
|
||||
|
||||
const parentSchema = await putSchema(store, {
|
||||
type: "object",
|
||||
@@ -97,7 +97,7 @@ describe("Suite 2: Resolution Decay Model", () => {
|
||||
child: { type: "string", format: "ocas_ref" },
|
||||
},
|
||||
});
|
||||
const parentHash = await store.put(parentSchema, {
|
||||
const parentHash = store.cas.put(parentSchema, {
|
||||
title: "root",
|
||||
child: childHash,
|
||||
});
|
||||
@@ -115,7 +115,7 @@ describe("Suite 2: Resolution Decay Model", () => {
|
||||
});
|
||||
|
||||
test("2.2 Multi-level Nesting Reaches Epsilon", async () => {
|
||||
const store = createMemoryStore().cas;
|
||||
const store = createMemoryStore();
|
||||
await bootstrap(store);
|
||||
|
||||
const leafSchema = await putSchema(store, {
|
||||
@@ -131,7 +131,7 @@ describe("Suite 2: Resolution Decay Model", () => {
|
||||
// Create 8-level chain
|
||||
let currentHash: Hash | null = null;
|
||||
for (let i = 7; i >= 0; i--) {
|
||||
currentHash = await store.put(leafSchema, {
|
||||
currentHash = store.cas.put(leafSchema, {
|
||||
value: i,
|
||||
next: currentHash,
|
||||
});
|
||||
@@ -151,7 +151,7 @@ describe("Suite 2: Resolution Decay Model", () => {
|
||||
});
|
||||
|
||||
test("2.3 High Decay (Quick Cutoff)", async () => {
|
||||
const store = createMemoryStore().cas;
|
||||
const store = createMemoryStore();
|
||||
await bootstrap(store);
|
||||
|
||||
const nodeSchema = await putSchema(store, {
|
||||
@@ -165,12 +165,12 @@ describe("Suite 2: Resolution Decay Model", () => {
|
||||
});
|
||||
|
||||
// Create 3-level nested structure
|
||||
const level2Hash = await store.put(nodeSchema, { level: 2, child: null });
|
||||
const level1Hash = await store.put(nodeSchema, {
|
||||
const level2Hash = store.cas.put(nodeSchema, { level: 2, child: null });
|
||||
const level1Hash = store.cas.put(nodeSchema, {
|
||||
level: 1,
|
||||
child: level2Hash,
|
||||
});
|
||||
const rootHash = await store.put(nodeSchema, {
|
||||
const rootHash = store.cas.put(nodeSchema, {
|
||||
level: 0,
|
||||
child: level1Hash,
|
||||
});
|
||||
@@ -189,7 +189,7 @@ describe("Suite 2: Resolution Decay Model", () => {
|
||||
});
|
||||
|
||||
test("2.4 Low Decay (Deep Expansion)", async () => {
|
||||
const store = createMemoryStore().cas;
|
||||
const store = createMemoryStore();
|
||||
await bootstrap(store);
|
||||
|
||||
const nodeSchema = await putSchema(store, {
|
||||
@@ -205,7 +205,7 @@ describe("Suite 2: Resolution Decay Model", () => {
|
||||
// Create 10-level chain
|
||||
let currentHash: Hash | null = null;
|
||||
for (let i = 9; i >= 0; i--) {
|
||||
currentHash = await store.put(nodeSchema, {
|
||||
currentHash = store.cas.put(nodeSchema, {
|
||||
level: i,
|
||||
next: currentHash,
|
||||
});
|
||||
@@ -224,7 +224,7 @@ describe("Suite 2: Resolution Decay Model", () => {
|
||||
});
|
||||
|
||||
test("2.5 Starting Resolution Below 1.0", async () => {
|
||||
const store = createMemoryStore().cas;
|
||||
const store = createMemoryStore();
|
||||
await bootstrap(store);
|
||||
|
||||
const nodeSchema = await putSchema(store, {
|
||||
@@ -240,7 +240,7 @@ describe("Suite 2: Resolution Decay Model", () => {
|
||||
// Create 5-level chain
|
||||
let currentHash: Hash | null = null;
|
||||
for (let i = 4; i >= 0; i--) {
|
||||
currentHash = await store.put(nodeSchema, {
|
||||
currentHash = store.cas.put(nodeSchema, {
|
||||
level: i,
|
||||
next: currentHash,
|
||||
});
|
||||
@@ -262,7 +262,7 @@ describe("Suite 2: Resolution Decay Model", () => {
|
||||
|
||||
describe("Suite 3: Complex Graph Structures", () => {
|
||||
test("3.1 Multiple Child References", async () => {
|
||||
const store = createMemoryStore().cas;
|
||||
const store = createMemoryStore();
|
||||
await bootstrap(store);
|
||||
|
||||
const itemSchema = await putSchema(store, {
|
||||
@@ -272,9 +272,9 @@ describe("Suite 3: Complex Graph Structures", () => {
|
||||
},
|
||||
});
|
||||
|
||||
const item1 = await store.put(itemSchema, { name: "item1" });
|
||||
const item2 = await store.put(itemSchema, { name: "item2" });
|
||||
const item3 = await store.put(itemSchema, { name: "item3" });
|
||||
const item1 = store.cas.put(itemSchema, { name: "item1" });
|
||||
const item2 = store.cas.put(itemSchema, { name: "item2" });
|
||||
const item3 = store.cas.put(itemSchema, { name: "item3" });
|
||||
|
||||
const parentSchema = await putSchema(store, {
|
||||
type: "object",
|
||||
@@ -285,7 +285,7 @@ describe("Suite 3: Complex Graph Structures", () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
const parentHash = await store.put(parentSchema, {
|
||||
const parentHash = store.cas.put(parentSchema, {
|
||||
items: [item1, item2, item3],
|
||||
});
|
||||
|
||||
@@ -301,7 +301,7 @@ describe("Suite 3: Complex Graph Structures", () => {
|
||||
});
|
||||
|
||||
test("3.2 Object with Multiple ocas_ref Fields", async () => {
|
||||
const store = createMemoryStore().cas;
|
||||
const store = createMemoryStore();
|
||||
await bootstrap(store);
|
||||
|
||||
const childSchema = await putSchema(store, {
|
||||
@@ -311,8 +311,8 @@ describe("Suite 3: Complex Graph Structures", () => {
|
||||
},
|
||||
});
|
||||
|
||||
const leftHash = await store.put(childSchema, { value: "left" });
|
||||
const rightHash = await store.put(childSchema, { value: "right" });
|
||||
const leftHash = store.cas.put(childSchema, { value: "left" });
|
||||
const rightHash = store.cas.put(childSchema, { value: "right" });
|
||||
|
||||
const parentSchema = await putSchema(store, {
|
||||
type: "object",
|
||||
@@ -322,7 +322,7 @@ describe("Suite 3: Complex Graph Structures", () => {
|
||||
data: { type: "string" },
|
||||
},
|
||||
});
|
||||
const parentHash = await store.put(parentSchema, {
|
||||
const parentHash = store.cas.put(parentSchema, {
|
||||
left: leftHash,
|
||||
right: rightHash,
|
||||
data: "node",
|
||||
@@ -340,7 +340,7 @@ describe("Suite 3: Complex Graph Structures", () => {
|
||||
});
|
||||
|
||||
test("3.3 Cycle Detection", async () => {
|
||||
const store = createMemoryStore().cas;
|
||||
const store = createMemoryStore();
|
||||
await bootstrap(store);
|
||||
|
||||
const nodeSchema = await putSchema(store, {
|
||||
@@ -353,8 +353,8 @@ describe("Suite 3: Complex Graph Structures", () => {
|
||||
},
|
||||
});
|
||||
|
||||
const hashA = await store.put(nodeSchema, { name: "A", ref: null });
|
||||
const hashB = await store.put(nodeSchema, { name: "B", ref: hashA });
|
||||
const hashA = store.cas.put(nodeSchema, { name: "A", ref: null });
|
||||
const hashB = store.cas.put(nodeSchema, { name: "B", ref: hashA });
|
||||
|
||||
// Manually update A to reference B (simulate cycle)
|
||||
// Note: In practice, this requires store manipulation
|
||||
@@ -372,7 +372,7 @@ describe("Suite 3: Complex Graph Structures", () => {
|
||||
});
|
||||
|
||||
test("3.4 DAG (Shared Descendant)", async () => {
|
||||
const store = createMemoryStore().cas;
|
||||
const store = createMemoryStore();
|
||||
await bootstrap(store);
|
||||
|
||||
const leafSchema = await putSchema(store, {
|
||||
@@ -381,7 +381,7 @@ describe("Suite 3: Complex Graph Structures", () => {
|
||||
value: { type: "string" },
|
||||
},
|
||||
});
|
||||
const sharedLeaf = await store.put(leafSchema, { value: "shared" });
|
||||
const sharedLeaf = store.cas.put(leafSchema, { value: "shared" });
|
||||
|
||||
const branchSchema = await putSchema(store, {
|
||||
type: "object",
|
||||
@@ -390,11 +390,11 @@ describe("Suite 3: Complex Graph Structures", () => {
|
||||
child: { type: "string", format: "ocas_ref" },
|
||||
},
|
||||
});
|
||||
const branchA = await store.put(branchSchema, {
|
||||
const branchA = store.cas.put(branchSchema, {
|
||||
name: "A",
|
||||
child: sharedLeaf,
|
||||
});
|
||||
const branchB = await store.put(branchSchema, {
|
||||
const branchB = store.cas.put(branchSchema, {
|
||||
name: "B",
|
||||
child: sharedLeaf,
|
||||
});
|
||||
@@ -406,7 +406,7 @@ describe("Suite 3: Complex Graph Structures", () => {
|
||||
right: { type: "string", format: "ocas_ref" },
|
||||
},
|
||||
});
|
||||
const rootHash = await store.put(rootSchema, {
|
||||
const rootHash = store.cas.put(rootSchema, {
|
||||
left: branchA,
|
||||
right: branchB,
|
||||
});
|
||||
@@ -423,7 +423,7 @@ describe("Suite 3: Complex Graph Structures", () => {
|
||||
});
|
||||
|
||||
test("3.5 Deep Tree", async () => {
|
||||
const store = createMemoryStore().cas;
|
||||
const store = createMemoryStore();
|
||||
await bootstrap(store);
|
||||
|
||||
const nodeSchema = await putSchema(store, {
|
||||
@@ -442,11 +442,11 @@ describe("Suite 3: Complex Graph Structures", () => {
|
||||
// Create binary tree (just 5 levels for test speed)
|
||||
async function createTree(depth: number, value: number): Promise<Hash> {
|
||||
if (depth === 0) {
|
||||
return store.put(nodeSchema, { value, left: null, right: null });
|
||||
return store.cas.put(nodeSchema, { value, left: null, right: null });
|
||||
}
|
||||
const left = await createTree(depth - 1, value * 2);
|
||||
const right = await createTree(depth - 1, value * 2 + 1);
|
||||
return store.put(nodeSchema, { value, left, right });
|
||||
return store.cas.put(nodeSchema, { value, left, right });
|
||||
}
|
||||
|
||||
const rootHash = await createTree(5, 1);
|
||||
@@ -464,10 +464,10 @@ describe("Suite 3: Complex Graph Structures", () => {
|
||||
|
||||
describe("Suite 4: Epsilon Boundary Cases", () => {
|
||||
test("4.1 Resolution Exactly at Epsilon", async () => {
|
||||
const store = createMemoryStore().cas;
|
||||
const store = createMemoryStore();
|
||||
await bootstrap(store);
|
||||
const textSchema = await putSchema(store, { type: "string" });
|
||||
const hash = await store.put(textSchema, "test");
|
||||
const hash = store.cas.put(textSchema, "test");
|
||||
|
||||
const output = render(store, hash, {
|
||||
resolution: 0.01,
|
||||
@@ -479,10 +479,10 @@ describe("Suite 4: Epsilon Boundary Cases", () => {
|
||||
});
|
||||
|
||||
test("4.2 Resolution Just Above Epsilon", async () => {
|
||||
const store = createMemoryStore().cas;
|
||||
const store = createMemoryStore();
|
||||
await bootstrap(store);
|
||||
const textSchema = await putSchema(store, { type: "string" });
|
||||
const hash = await store.put(textSchema, "test");
|
||||
const hash = store.cas.put(textSchema, "test");
|
||||
|
||||
const output = render(store, hash, {
|
||||
resolution: 0.0100001,
|
||||
@@ -494,7 +494,7 @@ describe("Suite 4: Epsilon Boundary Cases", () => {
|
||||
});
|
||||
|
||||
test("4.3 Very Small Epsilon (Deep Expansion)", async () => {
|
||||
const store = createMemoryStore().cas;
|
||||
const store = createMemoryStore();
|
||||
await bootstrap(store);
|
||||
|
||||
const nodeSchema = await putSchema(store, {
|
||||
@@ -510,7 +510,7 @@ describe("Suite 4: Epsilon Boundary Cases", () => {
|
||||
// Create 15-level chain
|
||||
let currentHash: Hash | null = null;
|
||||
for (let i = 14; i >= 0; i--) {
|
||||
currentHash = await store.put(nodeSchema, {
|
||||
currentHash = store.cas.put(nodeSchema, {
|
||||
level: i,
|
||||
next: currentHash,
|
||||
});
|
||||
@@ -529,7 +529,7 @@ describe("Suite 4: Epsilon Boundary Cases", () => {
|
||||
});
|
||||
|
||||
test("4.4 Zero Epsilon (Never Prune)", async () => {
|
||||
const store = createMemoryStore().cas;
|
||||
const store = createMemoryStore();
|
||||
await bootstrap(store);
|
||||
|
||||
const nodeSchema = await putSchema(store, {
|
||||
@@ -545,7 +545,7 @@ describe("Suite 4: Epsilon Boundary Cases", () => {
|
||||
// Create 20-level chain
|
||||
let currentHash: Hash | null = null;
|
||||
for (let i = 19; i >= 0; i--) {
|
||||
currentHash = await store.put(nodeSchema, {
|
||||
currentHash = store.cas.put(nodeSchema, {
|
||||
level: i,
|
||||
next: currentHash,
|
||||
});
|
||||
@@ -566,7 +566,7 @@ describe("Suite 4: Epsilon Boundary Cases", () => {
|
||||
|
||||
describe("Suite 5: YAML Output Format", () => {
|
||||
test("5.1 Valid YAML Syntax", async () => {
|
||||
const store = createMemoryStore().cas;
|
||||
const store = createMemoryStore();
|
||||
await bootstrap(store);
|
||||
const objSchema = await putSchema(store, {
|
||||
type: "object",
|
||||
@@ -575,7 +575,7 @@ describe("Suite 5: YAML Output Format", () => {
|
||||
count: { type: "number" },
|
||||
},
|
||||
});
|
||||
const hash = await store.put(objSchema, { name: "test", count: 42 });
|
||||
const hash = store.cas.put(objSchema, { name: "test", count: 42 });
|
||||
|
||||
const output = render(store, hash);
|
||||
|
||||
@@ -584,7 +584,7 @@ describe("Suite 5: YAML Output Format", () => {
|
||||
});
|
||||
|
||||
test("5.2 Nested Object Indentation", async () => {
|
||||
const store = createMemoryStore().cas;
|
||||
const store = createMemoryStore();
|
||||
await bootstrap(store);
|
||||
const nestedSchema = await putSchema(store, {
|
||||
type: "object",
|
||||
@@ -597,7 +597,7 @@ describe("Suite 5: YAML Output Format", () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
const hash = await store.put(nestedSchema, {
|
||||
const hash = store.cas.put(nestedSchema, {
|
||||
outer: { inner: "value" },
|
||||
});
|
||||
|
||||
@@ -610,13 +610,13 @@ describe("Suite 5: YAML Output Format", () => {
|
||||
});
|
||||
|
||||
test("5.3 Array Rendering", async () => {
|
||||
const store = createMemoryStore().cas;
|
||||
const store = createMemoryStore();
|
||||
await bootstrap(store);
|
||||
const arraySchema = await putSchema(store, {
|
||||
type: "array",
|
||||
items: { type: "number" },
|
||||
});
|
||||
const hash = await store.put(arraySchema, [1, 2, 3]);
|
||||
const hash = store.cas.put(arraySchema, [1, 2, 3]);
|
||||
|
||||
const output = render(store, hash);
|
||||
|
||||
@@ -625,7 +625,7 @@ describe("Suite 5: YAML Output Format", () => {
|
||||
});
|
||||
|
||||
test("5.4 CAS Reference in YAML", async () => {
|
||||
const store = createMemoryStore().cas;
|
||||
const store = createMemoryStore();
|
||||
await bootstrap(store);
|
||||
|
||||
const childSchema = await putSchema(store, {
|
||||
@@ -634,7 +634,7 @@ describe("Suite 5: YAML Output Format", () => {
|
||||
value: { type: "string" },
|
||||
},
|
||||
});
|
||||
const childHash = await store.put(childSchema, { value: "child" });
|
||||
const childHash = store.cas.put(childSchema, { value: "child" });
|
||||
|
||||
const parentSchema = await putSchema(store, {
|
||||
type: "object",
|
||||
@@ -642,7 +642,7 @@ describe("Suite 5: YAML Output Format", () => {
|
||||
child: { type: "string", format: "ocas_ref" },
|
||||
},
|
||||
});
|
||||
const parentHash = await store.put(parentSchema, { child: childHash });
|
||||
const parentHash = store.cas.put(parentSchema, { child: childHash });
|
||||
|
||||
const output = render(store, parentHash, {
|
||||
resolution: 1.0,
|
||||
@@ -655,10 +655,10 @@ describe("Suite 5: YAML Output Format", () => {
|
||||
});
|
||||
|
||||
test("5.5 Special Characters Escaping", async () => {
|
||||
const store = createMemoryStore().cas;
|
||||
const store = createMemoryStore();
|
||||
await bootstrap(store);
|
||||
const textSchema = await putSchema(store, { type: "string" });
|
||||
const hash = await store.put(textSchema, "line1\nline2: value");
|
||||
const hash = store.cas.put(textSchema, "line1\nline2: value");
|
||||
|
||||
const output = render(store, hash);
|
||||
|
||||
@@ -667,7 +667,7 @@ describe("Suite 5: YAML Output Format", () => {
|
||||
});
|
||||
|
||||
test("5.6 Null Handling", async () => {
|
||||
const store = createMemoryStore().cas;
|
||||
const store = createMemoryStore();
|
||||
await bootstrap(store);
|
||||
const nullableSchema = await putSchema(store, {
|
||||
type: "object",
|
||||
@@ -677,7 +677,7 @@ describe("Suite 5: YAML Output Format", () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
const hash = await store.put(nullableSchema, { ref: null });
|
||||
const hash = store.cas.put(nullableSchema, { ref: null });
|
||||
|
||||
const output = render(store, hash);
|
||||
|
||||
@@ -687,7 +687,7 @@ describe("Suite 5: YAML Output Format", () => {
|
||||
|
||||
describe("Suite 6: Schema Integration", () => {
|
||||
test("6.1 Detect ocas_ref Fields via Schema", async () => {
|
||||
const store = createMemoryStore().cas;
|
||||
const store = createMemoryStore();
|
||||
await bootstrap(store);
|
||||
|
||||
const childSchema = await putSchema(store, {
|
||||
@@ -696,7 +696,7 @@ describe("Suite 6: Schema Integration", () => {
|
||||
value: { type: "string" },
|
||||
},
|
||||
});
|
||||
const childHash = await store.put(childSchema, { value: "child" });
|
||||
const childHash = store.cas.put(childSchema, { value: "child" });
|
||||
|
||||
const parentSchema = await putSchema(store, {
|
||||
type: "object",
|
||||
@@ -704,7 +704,7 @@ describe("Suite 6: Schema Integration", () => {
|
||||
link: { type: "string", format: "ocas_ref" },
|
||||
},
|
||||
});
|
||||
const parentHash = await store.put(parentSchema, { link: childHash });
|
||||
const parentHash = store.cas.put(parentSchema, { link: childHash });
|
||||
|
||||
const output = render(store, parentHash, {
|
||||
resolution: 1.0,
|
||||
@@ -716,7 +716,7 @@ describe("Suite 6: Schema Integration", () => {
|
||||
});
|
||||
|
||||
test("6.2 Non-ocas_ref String Not Expanded", async () => {
|
||||
const store = createMemoryStore().cas;
|
||||
const store = createMemoryStore();
|
||||
await bootstrap(store);
|
||||
const objSchema = await putSchema(store, {
|
||||
type: "object",
|
||||
@@ -724,7 +724,7 @@ describe("Suite 6: Schema Integration", () => {
|
||||
name: { type: "string" },
|
||||
},
|
||||
});
|
||||
const hash = await store.put(objSchema, { name: "ABC123XYZ9012" });
|
||||
const hash = store.cas.put(objSchema, { name: "ABC123XYZ9012" });
|
||||
|
||||
const output = render(store, hash);
|
||||
|
||||
@@ -734,7 +734,7 @@ describe("Suite 6: Schema Integration", () => {
|
||||
});
|
||||
|
||||
test("6.3 Array of ocas_ref", async () => {
|
||||
const store = createMemoryStore().cas;
|
||||
const store = createMemoryStore();
|
||||
await bootstrap(store);
|
||||
|
||||
const itemSchema = await putSchema(store, {
|
||||
@@ -743,14 +743,14 @@ describe("Suite 6: Schema Integration", () => {
|
||||
name: { type: "string" },
|
||||
},
|
||||
});
|
||||
const item1 = await store.put(itemSchema, { name: "item1" });
|
||||
const item2 = await store.put(itemSchema, { name: "item2" });
|
||||
const item1 = store.cas.put(itemSchema, { name: "item1" });
|
||||
const item2 = store.cas.put(itemSchema, { name: "item2" });
|
||||
|
||||
const arraySchema = await putSchema(store, {
|
||||
type: "array",
|
||||
items: { type: "string", format: "ocas_ref" },
|
||||
});
|
||||
const arrayHash = await store.put(arraySchema, [item1, item2]);
|
||||
const arrayHash = store.cas.put(arraySchema, [item1, item2]);
|
||||
|
||||
const output = render(store, arrayHash, {
|
||||
resolution: 1.0,
|
||||
@@ -763,7 +763,7 @@ describe("Suite 6: Schema Integration", () => {
|
||||
});
|
||||
|
||||
test("6.4 anyOf with ocas_ref (Nullable Reference)", async () => {
|
||||
const store = createMemoryStore().cas;
|
||||
const store = createMemoryStore();
|
||||
await bootstrap(store);
|
||||
|
||||
const childSchema = await putSchema(store, {
|
||||
@@ -772,7 +772,7 @@ describe("Suite 6: Schema Integration", () => {
|
||||
value: { type: "string" },
|
||||
},
|
||||
});
|
||||
const childHash = await store.put(childSchema, { value: "child" });
|
||||
const childHash = store.cas.put(childSchema, { value: "child" });
|
||||
|
||||
const parentSchema = await putSchema(store, {
|
||||
type: "object",
|
||||
@@ -782,7 +782,7 @@ describe("Suite 6: Schema Integration", () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
const parentHash = await store.put(parentSchema, { ref: childHash });
|
||||
const parentHash = store.cas.put(parentSchema, { ref: childHash });
|
||||
|
||||
const output = render(store, parentHash, {
|
||||
resolution: 1.0,
|
||||
@@ -794,7 +794,7 @@ describe("Suite 6: Schema Integration", () => {
|
||||
});
|
||||
|
||||
test("6.5 Schema-less Node (Bootstrap Node)", async () => {
|
||||
const store = createMemoryStore().cas;
|
||||
const store = createMemoryStore();
|
||||
const types = await bootstrap(store);
|
||||
const schemaHash = types["@ocas/schema"];
|
||||
|
||||
@@ -807,7 +807,7 @@ describe("Suite 6: Schema Integration", () => {
|
||||
|
||||
describe("Suite 7: Error Handling", () => {
|
||||
test("7.1 Missing Referenced Node", async () => {
|
||||
const store = createMemoryStore().cas;
|
||||
const store = createMemoryStore();
|
||||
await bootstrap(store);
|
||||
|
||||
const parentSchema = await putSchema(store, {
|
||||
@@ -817,7 +817,7 @@ describe("Suite 7: Error Handling", () => {
|
||||
},
|
||||
});
|
||||
const fakeChildHash = "ZZZZZZZZZZZZZ" as Hash;
|
||||
const parentHash = await store.put(parentSchema, { child: fakeChildHash });
|
||||
const parentHash = store.cas.put(parentSchema, { child: fakeChildHash });
|
||||
|
||||
const output = render(store, parentHash);
|
||||
|
||||
@@ -826,21 +826,21 @@ describe("Suite 7: Error Handling", () => {
|
||||
});
|
||||
|
||||
test("7.3 Invalid Resolution Parameter", () => {
|
||||
const store = createMemoryStore().cas;
|
||||
const store = createMemoryStore();
|
||||
const fakeHash = "AAAAAAAAAAAAA" as Hash;
|
||||
|
||||
expect(() => render(store, fakeHash, { resolution: -1 })).toThrow();
|
||||
});
|
||||
|
||||
test("7.4 Invalid Decay Parameter", () => {
|
||||
const store = createMemoryStore().cas;
|
||||
const store = createMemoryStore();
|
||||
const fakeHash = "AAAAAAAAAAAAA" as Hash;
|
||||
|
||||
expect(() => render(store, fakeHash, { decay: 1.5 })).toThrow();
|
||||
});
|
||||
|
||||
test("7.5 Invalid Epsilon Parameter", () => {
|
||||
const store = createMemoryStore().cas;
|
||||
const store = createMemoryStore();
|
||||
const fakeHash = "AAAAAAAAAAAAA" as Hash;
|
||||
|
||||
expect(() => render(store, fakeHash, { epsilon: -0.01 })).toThrow();
|
||||
@@ -849,7 +849,7 @@ describe("Suite 7: Error Handling", () => {
|
||||
|
||||
describe("Suite 8: Performance & Edge Cases", () => {
|
||||
test("8.1 Large Payload", async () => {
|
||||
const store = createMemoryStore().cas;
|
||||
const store = createMemoryStore();
|
||||
await bootstrap(store);
|
||||
const arraySchema = await putSchema(store, {
|
||||
type: "array",
|
||||
@@ -866,7 +866,7 @@ describe("Suite 8: Performance & Edge Cases", () => {
|
||||
id: i,
|
||||
name: `item${i}`,
|
||||
}));
|
||||
const hash = await store.put(arraySchema, largeArray);
|
||||
const hash = store.cas.put(arraySchema, largeArray);
|
||||
|
||||
const start = Date.now();
|
||||
const output = render(store, hash);
|
||||
@@ -877,7 +877,7 @@ describe("Suite 8: Performance & Edge Cases", () => {
|
||||
});
|
||||
|
||||
test("8.2 Wide Fan-out", async () => {
|
||||
const store = createMemoryStore().cas;
|
||||
const store = createMemoryStore();
|
||||
await bootstrap(store);
|
||||
|
||||
const itemSchema = await putSchema(store, {
|
||||
@@ -889,7 +889,7 @@ describe("Suite 8: Performance & Edge Cases", () => {
|
||||
|
||||
const children: Hash[] = [];
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const hash = await store.put(itemSchema, { value: i });
|
||||
const hash = store.cas.put(itemSchema, { value: i });
|
||||
children.push(hash);
|
||||
}
|
||||
|
||||
@@ -897,7 +897,7 @@ describe("Suite 8: Performance & Edge Cases", () => {
|
||||
type: "array",
|
||||
items: { type: "string", format: "ocas_ref" },
|
||||
});
|
||||
const parentHash = await store.put(parentSchema, children);
|
||||
const parentHash = store.cas.put(parentSchema, children);
|
||||
|
||||
const output = render(store, parentHash, {
|
||||
resolution: 1.0,
|
||||
@@ -909,10 +909,10 @@ describe("Suite 8: Performance & Edge Cases", () => {
|
||||
});
|
||||
|
||||
test("8.3 Empty Payload", async () => {
|
||||
const store = createMemoryStore().cas;
|
||||
const store = createMemoryStore();
|
||||
await bootstrap(store);
|
||||
const emptySchema = await putSchema(store, { type: "object" });
|
||||
const hash = await store.put(emptySchema, {});
|
||||
const hash = store.cas.put(emptySchema, {});
|
||||
|
||||
const output = render(store, hash);
|
||||
|
||||
@@ -920,7 +920,7 @@ describe("Suite 8: Performance & Edge Cases", () => {
|
||||
});
|
||||
|
||||
test("8.4 Unicode in Payload", async () => {
|
||||
const store = createMemoryStore().cas;
|
||||
const store = createMemoryStore();
|
||||
await bootstrap(store);
|
||||
const textSchema = await putSchema(store, {
|
||||
type: "object",
|
||||
@@ -928,7 +928,7 @@ describe("Suite 8: Performance & Edge Cases", () => {
|
||||
text: { type: "string" },
|
||||
},
|
||||
});
|
||||
const hash = await store.put(textSchema, { text: "你好世界 🌍" });
|
||||
const hash = store.cas.put(textSchema, { text: "你好世界 🌍" });
|
||||
|
||||
const output = render(store, hash);
|
||||
|
||||
@@ -985,7 +985,7 @@ describe("Suite 9: renderDirect (in-memory rendering)", () => {
|
||||
});
|
||||
|
||||
test("9.5 Render with store expands ocas_ref fields", async () => {
|
||||
const store = createMemoryStore().cas;
|
||||
const store = createMemoryStore();
|
||||
await bootstrap(store);
|
||||
|
||||
// Create a child node
|
||||
@@ -993,7 +993,7 @@ describe("Suite 9: renderDirect (in-memory rendering)", () => {
|
||||
type: "object",
|
||||
properties: { msg: { type: "string" } },
|
||||
});
|
||||
const childHash = await store.put(childSchema, { msg: "inner" });
|
||||
const childHash = store.cas.put(childSchema, { msg: "inner" });
|
||||
|
||||
// Parent schema with ocas_ref
|
||||
const parentSchema = await putSchema(store, {
|
||||
@@ -1051,7 +1051,7 @@ describe("Suite 9: renderDirect (in-memory rendering)", () => {
|
||||
});
|
||||
|
||||
test("9.10 store present but schema missing — renders without ref expansion", async () => {
|
||||
const store = createMemoryStore().cas;
|
||||
const store = createMemoryStore();
|
||||
await bootstrap(store);
|
||||
const unknownType = "ZZZZZZZZZZZZ0" as Hash;
|
||||
const output = renderDirect(unknownType, { key: "val" }, store, null);
|
||||
@@ -1061,7 +1061,7 @@ describe("Suite 9: renderDirect (in-memory rendering)", () => {
|
||||
|
||||
describe("Suite 10: Missing Root Hash Error Handling (Issue #53)", () => {
|
||||
test("10.1 renderAsync() throws CasNodeNotFoundError for missing root hash", async () => {
|
||||
const store = createMemoryStore().cas;
|
||||
const store = createMemoryStore();
|
||||
await bootstrap(store);
|
||||
const fakeHash = "AAAAAAAAAAAAA" as Hash;
|
||||
|
||||
@@ -1075,7 +1075,7 @@ describe("Suite 10: Missing Root Hash Error Handling (Issue #53)", () => {
|
||||
});
|
||||
|
||||
test("10.2 render() throws CasNodeNotFoundError for missing root hash", () => {
|
||||
const store = createMemoryStore().cas;
|
||||
const store = createMemoryStore();
|
||||
const fakeHash = "ZZZZZZZZZZZZZ" as Hash;
|
||||
|
||||
expect(() => render(store, fakeHash)).toThrow(CasNodeNotFoundError);
|
||||
@@ -1084,7 +1084,7 @@ describe("Suite 10: Missing Root Hash Error Handling (Issue #53)", () => {
|
||||
});
|
||||
|
||||
test("10.3 renderDirect() does NOT throw for non-existent type hash", () => {
|
||||
const store = createMemoryStore().cas;
|
||||
const store = createMemoryStore();
|
||||
const fakeTypeHash = "0000000000000" as Hash;
|
||||
const output = renderDirect(fakeTypeHash, { key: "value" }, store, null);
|
||||
|
||||
@@ -1092,7 +1092,7 @@ describe("Suite 10: Missing Root Hash Error Handling (Issue #53)", () => {
|
||||
});
|
||||
|
||||
test("10.4 Missing nested node renders as cas: reference (no error)", async () => {
|
||||
const store = createMemoryStore().cas;
|
||||
const store = createMemoryStore();
|
||||
await bootstrap(store);
|
||||
|
||||
const parentSchema = await putSchema(store, {
|
||||
@@ -1104,7 +1104,7 @@ describe("Suite 10: Missing Root Hash Error Handling (Issue #53)", () => {
|
||||
});
|
||||
|
||||
const fakeChildHash = "ZZZZZZZZZZZZZ" as Hash;
|
||||
const parentHash = await store.put(parentSchema, {
|
||||
const parentHash = store.cas.put(parentSchema, {
|
||||
title: "root",
|
||||
child: fakeChildHash,
|
||||
});
|
||||
@@ -1116,7 +1116,7 @@ describe("Suite 10: Missing Root Hash Error Handling (Issue #53)", () => {
|
||||
});
|
||||
|
||||
test("10.5 Resolution below epsilon renders as cas: reference (no error)", async () => {
|
||||
const store = createMemoryStore().cas;
|
||||
const store = createMemoryStore();
|
||||
await bootstrap(store);
|
||||
|
||||
const nodeSchema = await putSchema(store, {
|
||||
@@ -1132,7 +1132,7 @@ describe("Suite 10: Missing Root Hash Error Handling (Issue #53)", () => {
|
||||
// Create 3-level chain
|
||||
let currentHash: Hash | null = null;
|
||||
for (let i = 2; i >= 0; i--) {
|
||||
currentHash = await store.put(nodeSchema, {
|
||||
currentHash = store.cas.put(nodeSchema, {
|
||||
level: i,
|
||||
next: currentHash,
|
||||
});
|
||||
|
||||
+35
-46
@@ -1,14 +1,12 @@
|
||||
import { CasNodeNotFoundError } from "./errors.js";
|
||||
import { renderWithTemplate } from "./liquid-render.js";
|
||||
import { collectRefs, getSchema, putSchema, refs } from "./schema.js";
|
||||
import type { Hash, Store } from "./types.js";
|
||||
import type { VariableStore } from "./variable-store.js";
|
||||
import { CasNodeNotFoundError } from "./variable-store.js";
|
||||
import type { Hash, OcasStore } from "./types.js";
|
||||
|
||||
export type RenderOptions = {
|
||||
resolution?: number; // (0, 1], default 1.0
|
||||
decay?: number; // (0, 1], default 0.5
|
||||
epsilon?: number; // >= 0, default 0.01
|
||||
varStore?: VariableStore; // Optional: for template lookup
|
||||
};
|
||||
|
||||
const DEFAULT_RESOLUTION = 1.0;
|
||||
@@ -20,12 +18,11 @@ const FLOAT_TOLERANCE = 1e-10;
|
||||
/**
|
||||
* Extract and validate resolution/decay/epsilon from options.
|
||||
*/
|
||||
function validateAndExtractOptions(
|
||||
options:
|
||||
| Pick<RenderOptions, "resolution" | "decay" | "epsilon">
|
||||
| null
|
||||
| undefined,
|
||||
): { resolution: number; decay: number; epsilon: number } {
|
||||
function validateAndExtractOptions(options: RenderOptions | null | undefined): {
|
||||
resolution: number;
|
||||
decay: number;
|
||||
epsilon: number;
|
||||
} {
|
||||
const resolution = options?.resolution ?? DEFAULT_RESOLUTION;
|
||||
const decay = options?.decay ?? DEFAULT_DECAY;
|
||||
const epsilon = options?.epsilon ?? DEFAULT_EPSILON;
|
||||
@@ -47,17 +44,17 @@ function validateAndExtractOptions(
|
||||
* Render a CAS node as YAML with resolution-based decay.
|
||||
* When resolution ≤ epsilon, nodes are rendered as opaque `cas:<hash>` references.
|
||||
* This is the synchronous version without template support.
|
||||
* For template support, use renderAsync() with varStore.
|
||||
* For template support, use renderAsync().
|
||||
*/
|
||||
export function render(
|
||||
store: Store,
|
||||
store: OcasStore,
|
||||
hash: Hash,
|
||||
options?: RenderOptions,
|
||||
): string {
|
||||
const { resolution, decay, epsilon } = validateAndExtractOptions(options);
|
||||
|
||||
// Check if root node exists
|
||||
if (store.get(hash) === null) {
|
||||
if (store.cas.get(hash) === null) {
|
||||
throw new CasNodeNotFoundError(hash);
|
||||
}
|
||||
|
||||
@@ -68,40 +65,36 @@ export function render(
|
||||
/**
|
||||
* Async render with LiquidJS template support.
|
||||
* When resolution ≤ epsilon, nodes are rendered as opaque `cas:<hash>` references.
|
||||
* If varStore is provided, attempts to use LiquidJS templates first, fallback to YAML.
|
||||
* Attempts to use LiquidJS templates first, falling back to YAML.
|
||||
*/
|
||||
export async function renderAsync(
|
||||
store: Store,
|
||||
store: OcasStore,
|
||||
hash: Hash,
|
||||
options?: RenderOptions,
|
||||
): Promise<string> {
|
||||
const { resolution, decay, epsilon } = validateAndExtractOptions(options);
|
||||
|
||||
// Check if root node exists
|
||||
if (store.get(hash) === null) {
|
||||
if (store.cas.get(hash) === null) {
|
||||
throw new CasNodeNotFoundError(hash);
|
||||
}
|
||||
|
||||
const varStore = options?.varStore;
|
||||
|
||||
// If varStore provided, try template rendering first
|
||||
if (varStore !== undefined) {
|
||||
try {
|
||||
const node = store.get(hash);
|
||||
if (node !== null) {
|
||||
// Check if a template exists for this type
|
||||
const templateExists = await hasTemplate(store, varStore, node.type);
|
||||
if (templateExists) {
|
||||
return await renderWithTemplate(store, varStore, hash, {
|
||||
resolution,
|
||||
decay,
|
||||
epsilon,
|
||||
});
|
||||
}
|
||||
// Try template rendering first
|
||||
try {
|
||||
const node = store.cas.get(hash);
|
||||
if (node !== null) {
|
||||
// Check if a template exists for this type
|
||||
const templateExists = await hasTemplate(store, node.type);
|
||||
if (templateExists) {
|
||||
return await renderWithTemplate(store, hash, {
|
||||
resolution,
|
||||
decay,
|
||||
epsilon,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Fall through to YAML rendering
|
||||
}
|
||||
} catch {
|
||||
// Fall through to YAML rendering
|
||||
}
|
||||
|
||||
// Fallback to YAML rendering
|
||||
@@ -118,8 +111,8 @@ export async function renderAsync(
|
||||
export function renderDirect(
|
||||
typeHash: Hash,
|
||||
value: unknown,
|
||||
store: Store | null,
|
||||
options: Omit<RenderOptions, "varStore"> | null,
|
||||
store: OcasStore | null,
|
||||
options: RenderOptions | null,
|
||||
): string {
|
||||
const { resolution, decay, epsilon } = validateAndExtractOptions(options);
|
||||
|
||||
@@ -136,7 +129,7 @@ export function renderDirect(
|
||||
const visited = new Set<Hash>();
|
||||
|
||||
return renderValue(
|
||||
store ?? null,
|
||||
store,
|
||||
value,
|
||||
refSet,
|
||||
childResolution,
|
||||
@@ -149,15 +142,11 @@ export function renderDirect(
|
||||
/**
|
||||
* Check if a template exists for a given type
|
||||
*/
|
||||
async function hasTemplate(
|
||||
store: Store,
|
||||
varStore: VariableStore,
|
||||
typeHash: Hash,
|
||||
): Promise<boolean> {
|
||||
async function hasTemplate(store: OcasStore, typeHash: Hash): Promise<boolean> {
|
||||
const varName = `@ocas/template/text/${typeHash}`;
|
||||
try {
|
||||
const stringSchema = await putSchema(store, { type: "string" });
|
||||
const variable = varStore.get(varName, stringSchema);
|
||||
const variable = store.var.get(varName, stringSchema);
|
||||
return variable !== null;
|
||||
} catch {
|
||||
return false;
|
||||
@@ -165,7 +154,7 @@ async function hasTemplate(
|
||||
}
|
||||
|
||||
function renderNode(
|
||||
store: Store | null,
|
||||
store: OcasStore | null,
|
||||
hash: Hash,
|
||||
currentResolution: number,
|
||||
decay: number,
|
||||
@@ -178,7 +167,7 @@ function renderNode(
|
||||
}
|
||||
|
||||
// Fetch the node
|
||||
const node = store !== null ? store.get(hash) : null;
|
||||
const node = store !== null ? store.cas.get(hash) : null;
|
||||
if (node === null) {
|
||||
// Missing node - render as cas: reference
|
||||
return `cas:${hash}`;
|
||||
@@ -214,7 +203,7 @@ function renderNode(
|
||||
}
|
||||
|
||||
function renderValue(
|
||||
store: Store | null,
|
||||
store: OcasStore | null,
|
||||
value: unknown,
|
||||
refHashes: Set<Hash>,
|
||||
childResolution: number,
|
||||
|
||||
+136
-136
@@ -10,35 +10,35 @@ import type { CasNode } from "./types.js";
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
describe("putSchema", () => {
|
||||
test("returns a valid 13-char hash", async () => {
|
||||
const store = createMemoryStore().cas;
|
||||
const store = createMemoryStore();
|
||||
const hash = await putSchema(store, { type: "object", properties: {} });
|
||||
expect(hash).toHaveLength(13);
|
||||
expect(hash).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
|
||||
});
|
||||
|
||||
test("schema node is stored in the store", async () => {
|
||||
const store = createMemoryStore().cas;
|
||||
const store = createMemoryStore();
|
||||
const schema = { type: "object", properties: { name: { type: "string" } } };
|
||||
const hash = await putSchema(store, schema);
|
||||
|
||||
expect(store.has(hash)).toBe(true);
|
||||
const node = store.get(hash);
|
||||
expect(store.cas.has(hash)).toBe(true);
|
||||
const node = store.cas.get(hash);
|
||||
expect(node).not.toBeNull();
|
||||
expect(node?.payload).toEqual(schema);
|
||||
});
|
||||
|
||||
test("schema node type equals the meta-schema hash", async () => {
|
||||
const store = createMemoryStore().cas;
|
||||
const store = createMemoryStore();
|
||||
const builtinSchemas = await bootstrap(store);
|
||||
const metaHash = builtinSchemas["@ocas/schema"] ?? "";
|
||||
const schemaHash = await putSchema(store, { type: "string" });
|
||||
const node = store.get(schemaHash) as CasNode;
|
||||
const node = store.cas.get(schemaHash) as CasNode;
|
||||
|
||||
expect(node.type).toBe(metaHash);
|
||||
});
|
||||
|
||||
test("putSchema is idempotent: same schema → same hash", async () => {
|
||||
const store = createMemoryStore().cas;
|
||||
const store = createMemoryStore();
|
||||
const schema = { type: "number" };
|
||||
const h1 = await putSchema(store, schema);
|
||||
const h2 = await putSchema(store, schema);
|
||||
@@ -47,7 +47,7 @@ describe("putSchema", () => {
|
||||
});
|
||||
|
||||
test("different schemas produce different hashes", async () => {
|
||||
const store = createMemoryStore().cas;
|
||||
const store = createMemoryStore();
|
||||
const h1 = await putSchema(store, { type: "string" });
|
||||
const h2 = await putSchema(store, { type: "number" });
|
||||
|
||||
@@ -60,7 +60,7 @@ describe("putSchema", () => {
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
describe("getSchema", () => {
|
||||
test("returns the original schema object", async () => {
|
||||
const store = createMemoryStore().cas;
|
||||
const store = createMemoryStore();
|
||||
const schema = { type: "object", properties: { age: { type: "number" } } };
|
||||
const hash = await putSchema(store, schema);
|
||||
|
||||
@@ -68,12 +68,12 @@ describe("getSchema", () => {
|
||||
});
|
||||
|
||||
test("returns null for an unknown hash", () => {
|
||||
const store = createMemoryStore().cas;
|
||||
const store = createMemoryStore();
|
||||
expect(getSchema(store, "0000000000000")).toBeNull();
|
||||
});
|
||||
|
||||
test("roundtrip: put then get returns the same schema", async () => {
|
||||
const store = createMemoryStore().cas;
|
||||
const store = createMemoryStore();
|
||||
const schema = {
|
||||
type: "object",
|
||||
required: ["id"],
|
||||
@@ -92,46 +92,46 @@ describe("getSchema", () => {
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
describe("validate", () => {
|
||||
test("returns true when payload matches the schema", async () => {
|
||||
const store = createMemoryStore().cas;
|
||||
const store = createMemoryStore();
|
||||
const schemaHash = await putSchema(store, {
|
||||
type: "object",
|
||||
properties: { name: { type: "string" }, age: { type: "number" } },
|
||||
required: ["name"],
|
||||
});
|
||||
const nodeHash = await store.put(schemaHash, { name: "Alice", age: 30 });
|
||||
const node = store.get(nodeHash) as CasNode;
|
||||
const nodeHash = store.cas.put(schemaHash, { name: "Alice", age: 30 });
|
||||
const node = store.cas.get(nodeHash) as CasNode;
|
||||
|
||||
expect(validate(store, node)).toBe(true);
|
||||
});
|
||||
|
||||
test("returns false when payload violates the schema", async () => {
|
||||
const store = createMemoryStore().cas;
|
||||
const store = createMemoryStore();
|
||||
const schemaHash = await putSchema(store, {
|
||||
type: "object",
|
||||
properties: { count: { type: "number" } },
|
||||
required: ["count"],
|
||||
});
|
||||
const nodeHash = await store.put(schemaHash, { count: "not-a-number" });
|
||||
const node = store.get(nodeHash) as CasNode;
|
||||
const nodeHash = store.cas.put(schemaHash, { count: "not-a-number" });
|
||||
const node = store.cas.get(nodeHash) as CasNode;
|
||||
|
||||
expect(validate(store, node)).toBe(false);
|
||||
});
|
||||
|
||||
test("returns false when required field is missing", async () => {
|
||||
const store = createMemoryStore().cas;
|
||||
const store = createMemoryStore();
|
||||
const schemaHash = await putSchema(store, {
|
||||
type: "object",
|
||||
required: ["title"],
|
||||
properties: { title: { type: "string" } },
|
||||
});
|
||||
const nodeHash = await store.put(schemaHash, {});
|
||||
const node = store.get(nodeHash) as CasNode;
|
||||
const nodeHash = store.cas.put(schemaHash, {});
|
||||
const node = store.cas.get(nodeHash) as CasNode;
|
||||
|
||||
expect(validate(store, node)).toBe(false);
|
||||
});
|
||||
|
||||
test("returns false when schema cannot be found", async () => {
|
||||
const store = createMemoryStore().cas;
|
||||
const store = createMemoryStore();
|
||||
const fakeNode: CasNode = {
|
||||
type: "0000000000000",
|
||||
payload: { x: 1 },
|
||||
@@ -147,19 +147,19 @@ describe("validate", () => {
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
describe("refs", () => {
|
||||
test("returns empty array when schema has no ocas_ref fields", async () => {
|
||||
const store = createMemoryStore().cas;
|
||||
const store = createMemoryStore();
|
||||
const schemaHash = await putSchema(store, {
|
||||
type: "object",
|
||||
properties: { title: { type: "string" } },
|
||||
});
|
||||
const nodeHash = await store.put(schemaHash, { title: "hello" });
|
||||
const node = store.get(nodeHash) as CasNode;
|
||||
const nodeHash = store.cas.put(schemaHash, { title: "hello" });
|
||||
const node = store.cas.get(nodeHash) as CasNode;
|
||||
|
||||
expect(refs(store, node)).toEqual([]);
|
||||
});
|
||||
|
||||
test("returns the ocas_ref hash values from payload", async () => {
|
||||
const store = createMemoryStore().cas;
|
||||
const store = createMemoryStore();
|
||||
const schemaHash = await putSchema(store, {
|
||||
type: "object",
|
||||
properties: {
|
||||
@@ -169,17 +169,17 @@ describe("refs", () => {
|
||||
});
|
||||
|
||||
const targetHash = "AAAAAAAAAAAAA";
|
||||
const nodeHash = await store.put(schemaHash, {
|
||||
const nodeHash = store.cas.put(schemaHash, {
|
||||
parentHash: targetHash,
|
||||
label: "child",
|
||||
});
|
||||
const node = store.get(nodeHash) as CasNode;
|
||||
const node = store.cas.get(nodeHash) as CasNode;
|
||||
|
||||
expect(refs(store, node)).toEqual([targetHash]);
|
||||
});
|
||||
|
||||
test("collects multiple ocas_ref fields", async () => {
|
||||
const store = createMemoryStore().cas;
|
||||
const store = createMemoryStore();
|
||||
const schemaHash = await putSchema(store, {
|
||||
type: "object",
|
||||
properties: {
|
||||
@@ -190,11 +190,11 @@ describe("refs", () => {
|
||||
|
||||
const h1 = "AAAAAAAAAAAAA";
|
||||
const h2 = "BBBBBBBBBBBBB";
|
||||
const nodeHash = await store.put(schemaHash, {
|
||||
const nodeHash = store.cas.put(schemaHash, {
|
||||
leftHash: h1,
|
||||
rightHash: h2,
|
||||
});
|
||||
const node = store.get(nodeHash) as CasNode;
|
||||
const node = store.cas.get(nodeHash) as CasNode;
|
||||
|
||||
const result = refs(store, node);
|
||||
expect(result).toHaveLength(2);
|
||||
@@ -203,7 +203,7 @@ describe("refs", () => {
|
||||
});
|
||||
|
||||
test("skips null/undefined ocas_ref values", async () => {
|
||||
const store = createMemoryStore().cas;
|
||||
const store = createMemoryStore();
|
||||
const schemaHash = await putSchema(store, {
|
||||
type: "object",
|
||||
properties: {
|
||||
@@ -212,14 +212,14 @@ describe("refs", () => {
|
||||
},
|
||||
});
|
||||
|
||||
const nodeHash = await store.put(schemaHash, { label: "no ref here" });
|
||||
const node = store.get(nodeHash) as CasNode;
|
||||
const nodeHash = store.cas.put(schemaHash, { label: "no ref here" });
|
||||
const node = store.cas.get(nodeHash) as CasNode;
|
||||
|
||||
expect(refs(store, node)).toEqual([]);
|
||||
});
|
||||
|
||||
test("returns empty array when schema is not found", () => {
|
||||
const store = createMemoryStore().cas;
|
||||
const store = createMemoryStore();
|
||||
const orphanNode: CasNode = {
|
||||
type: "0000000000000",
|
||||
payload: { x: 1 },
|
||||
@@ -235,12 +235,12 @@ describe("refs", () => {
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
describe("walk", () => {
|
||||
test("visits a single node with no refs", async () => {
|
||||
const store = createMemoryStore().cas;
|
||||
const store = createMemoryStore();
|
||||
const schemaHash = await putSchema(store, {
|
||||
type: "object",
|
||||
properties: { val: { type: "number" } },
|
||||
});
|
||||
const nodeHash = await store.put(schemaHash, { val: 42 });
|
||||
const nodeHash = store.cas.put(schemaHash, { val: 42 });
|
||||
|
||||
const visited: string[] = [];
|
||||
walk(store, nodeHash, (hash) => visited.push(hash));
|
||||
@@ -249,7 +249,7 @@ describe("walk", () => {
|
||||
});
|
||||
|
||||
test("visits all reachable nodes in a chain A → B → C", async () => {
|
||||
const store = createMemoryStore().cas;
|
||||
const store = createMemoryStore();
|
||||
const schemaHash = await putSchema(store, {
|
||||
type: "object",
|
||||
properties: {
|
||||
@@ -258,9 +258,9 @@ describe("walk", () => {
|
||||
},
|
||||
});
|
||||
|
||||
const hashC = await store.put(schemaHash, { val: 3 });
|
||||
const hashB = await store.put(schemaHash, { nextHash: hashC, val: 2 });
|
||||
const hashA = await store.put(schemaHash, { nextHash: hashB, val: 1 });
|
||||
const hashC = store.cas.put(schemaHash, { val: 3 });
|
||||
const hashB = store.cas.put(schemaHash, { nextHash: hashC, val: 2 });
|
||||
const hashA = store.cas.put(schemaHash, { nextHash: hashB, val: 1 });
|
||||
|
||||
const visited: string[] = [];
|
||||
walk(store, hashA, (hash) => visited.push(hash));
|
||||
@@ -273,7 +273,7 @@ describe("walk", () => {
|
||||
});
|
||||
|
||||
test("handles cycles without infinite loop", async () => {
|
||||
const store = createMemoryStore().cas;
|
||||
const store = createMemoryStore();
|
||||
const schemaHash = await putSchema(store, {
|
||||
type: "object",
|
||||
properties: {
|
||||
@@ -283,14 +283,14 @@ describe("walk", () => {
|
||||
});
|
||||
|
||||
// A → B, B → A (manual cycle by inserting pre-known hash)
|
||||
const hashA = await store.put(schemaHash, { val: 1 });
|
||||
const _hashB = await store.put(schemaHash, { peerHash: hashA, val: 2 });
|
||||
const hashA = store.cas.put(schemaHash, { val: 1 });
|
||||
const _hashB = store.cas.put(schemaHash, { peerHash: hashA, val: 2 });
|
||||
|
||||
// update A to point at B — since store is content-addressed we can't mutate,
|
||||
// so we build a diamond: root → A and root → B, A → C, B → C
|
||||
const hashC = await store.put(schemaHash, { val: 3 });
|
||||
const hashD = await store.put(schemaHash, { peerHash: hashC, val: 4 });
|
||||
const hashE = await store.put(schemaHash, { peerHash: hashC, val: 5 });
|
||||
const hashC = store.cas.put(schemaHash, { val: 3 });
|
||||
const hashD = store.cas.put(schemaHash, { peerHash: hashC, val: 4 });
|
||||
const hashE = store.cas.put(schemaHash, { peerHash: hashC, val: 5 });
|
||||
|
||||
const schemaHash2 = await putSchema(store, {
|
||||
type: "object",
|
||||
@@ -299,7 +299,7 @@ describe("walk", () => {
|
||||
rightHash: { type: "string", format: "ocas_ref" },
|
||||
},
|
||||
});
|
||||
const rootHash = await store.put(schemaHash2, {
|
||||
const rootHash = store.cas.put(schemaHash2, {
|
||||
leftHash: hashD,
|
||||
rightHash: hashE,
|
||||
});
|
||||
@@ -316,12 +316,12 @@ describe("walk", () => {
|
||||
});
|
||||
|
||||
test("skips missing hashes gracefully", async () => {
|
||||
const store = createMemoryStore().cas;
|
||||
const store = createMemoryStore();
|
||||
const schemaHash = await putSchema(store, {
|
||||
type: "object",
|
||||
properties: { ref: { type: "string", format: "ocas_ref" } },
|
||||
});
|
||||
const nodeHash = await store.put(schemaHash, { ref: "0000000000000" });
|
||||
const nodeHash = store.cas.put(schemaHash, { ref: "0000000000000" });
|
||||
|
||||
const visited: string[] = [];
|
||||
walk(store, nodeHash, (hash) => visited.push(hash));
|
||||
@@ -331,12 +331,12 @@ describe("walk", () => {
|
||||
});
|
||||
|
||||
test("visitor receives both hash and node", async () => {
|
||||
const store = createMemoryStore().cas;
|
||||
const store = createMemoryStore();
|
||||
const schemaHash = await putSchema(store, {
|
||||
type: "object",
|
||||
properties: { x: { type: "number" } },
|
||||
});
|
||||
const nodeHash = await store.put(schemaHash, { x: 7 });
|
||||
const nodeHash = store.cas.put(schemaHash, { x: 7 });
|
||||
|
||||
let receivedHash: string | null = null;
|
||||
let receivedNode: CasNode | null = null;
|
||||
@@ -355,34 +355,34 @@ describe("walk", () => {
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
describe("bootstrap meta-schema self-reference", () => {
|
||||
test("metaNode.type === metaHash (self-referencing)", async () => {
|
||||
const store = createMemoryStore().cas;
|
||||
const store = createMemoryStore();
|
||||
const builtinSchemas = await bootstrap(store);
|
||||
const metaHash = builtinSchemas["@ocas/schema"] ?? "";
|
||||
const metaNode = store.get(metaHash) as CasNode;
|
||||
const metaNode = store.cas.get(metaHash) as CasNode;
|
||||
|
||||
expect(metaNode.type).toBe(metaHash);
|
||||
});
|
||||
|
||||
test("schema nodes have type === metaHash", async () => {
|
||||
const store = createMemoryStore().cas;
|
||||
const store = createMemoryStore();
|
||||
const builtinSchemas = await bootstrap(store);
|
||||
const metaHash = builtinSchemas["@ocas/schema"] ?? "";
|
||||
const schemaHash = await putSchema(store, { type: "string" });
|
||||
const schemaNode = store.get(schemaHash) as CasNode;
|
||||
const schemaNode = store.cas.get(schemaHash) as CasNode;
|
||||
|
||||
expect(schemaNode.type).toBe(metaHash);
|
||||
});
|
||||
|
||||
test("data nodes have type === schemaHash (not metaHash)", async () => {
|
||||
const store = createMemoryStore().cas;
|
||||
const store = createMemoryStore();
|
||||
const builtinSchemas = await bootstrap(store);
|
||||
const metaHash = builtinSchemas["@ocas/schema"] ?? "";
|
||||
const schemaHash = await putSchema(store, {
|
||||
type: "object",
|
||||
properties: { val: { type: "number" } },
|
||||
});
|
||||
const dataHash = await store.put(schemaHash, { val: 99 });
|
||||
const dataNode = store.get(dataHash) as CasNode;
|
||||
const dataHash = store.cas.put(schemaHash, { val: 99 });
|
||||
const dataNode = store.cas.get(dataHash) as CasNode;
|
||||
|
||||
expect(dataNode.type).toBe(schemaHash);
|
||||
expect(dataNode.type).not.toBe(metaHash);
|
||||
@@ -391,7 +391,7 @@ describe("bootstrap meta-schema self-reference", () => {
|
||||
// ── P1 leaf constraints ──────────────────────────────────────────────────
|
||||
|
||||
test("accepts schema with numeric constraints (minimum/maximum)", async () => {
|
||||
const store = createMemoryStore().cas;
|
||||
const store = createMemoryStore();
|
||||
const hash = await putSchema(store, {
|
||||
type: "number",
|
||||
minimum: 0,
|
||||
@@ -402,18 +402,18 @@ describe("bootstrap meta-schema self-reference", () => {
|
||||
expect(hash).toHaveLength(13);
|
||||
|
||||
// validate a conforming payload
|
||||
const nodeHash = await store.put(hash, 42);
|
||||
const node = store.get(nodeHash) as CasNode;
|
||||
const nodeHash = store.cas.put(hash, 42);
|
||||
const node = store.cas.get(nodeHash) as CasNode;
|
||||
expect(validate(store, node)).toBe(true);
|
||||
|
||||
// validate a non-conforming payload
|
||||
const badHash = await store.put(hash, 200);
|
||||
const badNode = store.get(badHash) as CasNode;
|
||||
const badHash = store.cas.put(hash, 200);
|
||||
const badNode = store.cas.get(badHash) as CasNode;
|
||||
expect(validate(store, badNode)).toBe(false);
|
||||
});
|
||||
|
||||
test("accepts schema with string constraints (minLength/maxLength/pattern)", async () => {
|
||||
const store = createMemoryStore().cas;
|
||||
const store = createMemoryStore();
|
||||
const hash = await putSchema(store, {
|
||||
type: "string",
|
||||
minLength: 1,
|
||||
@@ -422,15 +422,15 @@ describe("bootstrap meta-schema self-reference", () => {
|
||||
});
|
||||
expect(hash).toHaveLength(13);
|
||||
|
||||
const goodHash = await store.put(hash, "hello");
|
||||
expect(validate(store, store.get(goodHash) as CasNode)).toBe(true);
|
||||
const goodHash = store.cas.put(hash, "hello");
|
||||
expect(validate(store, store.cas.get(goodHash) as CasNode)).toBe(true);
|
||||
|
||||
const badHash = await store.put(hash, "HELLO");
|
||||
expect(validate(store, store.get(badHash) as CasNode)).toBe(false);
|
||||
const badHash = store.cas.put(hash, "HELLO");
|
||||
expect(validate(store, store.cas.get(badHash) as CasNode)).toBe(false);
|
||||
});
|
||||
|
||||
test("accepts schema with array constraints (minItems/maxItems/uniqueItems)", async () => {
|
||||
const store = createMemoryStore().cas;
|
||||
const store = createMemoryStore();
|
||||
const hash = await putSchema(store, {
|
||||
type: "array",
|
||||
items: { type: "number" },
|
||||
@@ -440,18 +440,18 @@ describe("bootstrap meta-schema self-reference", () => {
|
||||
});
|
||||
expect(hash).toHaveLength(13);
|
||||
|
||||
const goodHash = await store.put(hash, [1, 2, 3]);
|
||||
expect(validate(store, store.get(goodHash) as CasNode)).toBe(true);
|
||||
const goodHash = store.cas.put(hash, [1, 2, 3]);
|
||||
expect(validate(store, store.cas.get(goodHash) as CasNode)).toBe(true);
|
||||
|
||||
const tooMany = await store.put(hash, [1, 2, 3, 4]);
|
||||
expect(validate(store, store.get(tooMany) as CasNode)).toBe(false);
|
||||
const tooMany = store.cas.put(hash, [1, 2, 3, 4]);
|
||||
expect(validate(store, store.cas.get(tooMany) as CasNode)).toBe(false);
|
||||
|
||||
const dupes = await store.put(hash, [1, 1]);
|
||||
expect(validate(store, store.get(dupes) as CasNode)).toBe(false);
|
||||
const dupes = store.cas.put(hash, [1, 1]);
|
||||
expect(validate(store, store.cas.get(dupes) as CasNode)).toBe(false);
|
||||
});
|
||||
|
||||
test("rejects schema with wrong constraint types", async () => {
|
||||
const store = createMemoryStore().cas;
|
||||
const store = createMemoryStore();
|
||||
await expect(
|
||||
putSchema(store, { type: "number", minimum: "zero" } as never),
|
||||
).rejects.toThrow();
|
||||
@@ -464,7 +464,7 @@ describe("bootstrap meta-schema self-reference", () => {
|
||||
});
|
||||
|
||||
test("accepts schema with nested property constraints", async () => {
|
||||
const store = createMemoryStore().cas;
|
||||
const store = createMemoryStore();
|
||||
const hash = await putSchema(store, {
|
||||
type: "object",
|
||||
properties: {
|
||||
@@ -481,16 +481,16 @@ describe("bootstrap meta-schema self-reference", () => {
|
||||
});
|
||||
expect(hash).toHaveLength(13);
|
||||
|
||||
const good = await store.put(hash, {
|
||||
const good = store.cas.put(hash, {
|
||||
name: "Alice",
|
||||
age: 30,
|
||||
scores: [95, 87],
|
||||
});
|
||||
expect(validate(store, store.get(good) as CasNode)).toBe(true);
|
||||
expect(validate(store, store.cas.get(good) as CasNode)).toBe(true);
|
||||
});
|
||||
|
||||
test("bootstrap is idempotent across putSchema calls", async () => {
|
||||
const store = createMemoryStore().cas;
|
||||
const store = createMemoryStore();
|
||||
const builtinSchemas = await bootstrap(store);
|
||||
const metaHash = builtinSchemas["@ocas/schema"] ?? "";
|
||||
|
||||
@@ -498,14 +498,14 @@ describe("bootstrap meta-schema self-reference", () => {
|
||||
await putSchema(store, { type: "number" });
|
||||
|
||||
// bootstrap node should still be there and unchanged
|
||||
const metaNode = store.get(metaHash) as CasNode;
|
||||
const metaNode = store.cas.get(metaHash) as CasNode;
|
||||
expect(metaNode.type).toBe(metaHash);
|
||||
});
|
||||
|
||||
// ── P2 combinators, conditionals, and leaf constraints ──────────────────
|
||||
|
||||
test("accepts schema with allOf", async () => {
|
||||
const store = createMemoryStore().cas;
|
||||
const store = createMemoryStore();
|
||||
const hash = await putSchema(store, {
|
||||
allOf: [
|
||||
{ type: "object", properties: { name: { type: "string" } } },
|
||||
@@ -514,15 +514,15 @@ describe("bootstrap meta-schema self-reference", () => {
|
||||
});
|
||||
expect(hash).toHaveLength(13);
|
||||
|
||||
const good = await store.put(hash, { name: "Alice" });
|
||||
expect(validate(store, store.get(good) as CasNode)).toBe(true);
|
||||
const good = store.cas.put(hash, { name: "Alice" });
|
||||
expect(validate(store, store.cas.get(good) as CasNode)).toBe(true);
|
||||
|
||||
const bad = await store.put(hash, {});
|
||||
expect(validate(store, store.get(bad) as CasNode)).toBe(false);
|
||||
const bad = store.cas.put(hash, {});
|
||||
expect(validate(store, store.cas.get(bad) as CasNode)).toBe(false);
|
||||
});
|
||||
|
||||
test("accepts schema with if/then/else", async () => {
|
||||
const store = createMemoryStore().cas;
|
||||
const store = createMemoryStore();
|
||||
const hash = await putSchema(store, {
|
||||
type: "object",
|
||||
properties: {
|
||||
@@ -538,7 +538,7 @@ describe("bootstrap meta-schema self-reference", () => {
|
||||
});
|
||||
|
||||
test("accepts schema with patternProperties", async () => {
|
||||
const store = createMemoryStore().cas;
|
||||
const store = createMemoryStore();
|
||||
const hash = await putSchema(store, {
|
||||
type: "object",
|
||||
patternProperties: {
|
||||
@@ -547,12 +547,12 @@ describe("bootstrap meta-schema self-reference", () => {
|
||||
});
|
||||
expect(hash).toHaveLength(13);
|
||||
|
||||
const good = await store.put(hash, { "x-custom": "hello" });
|
||||
expect(validate(store, store.get(good) as CasNode)).toBe(true);
|
||||
const good = store.cas.put(hash, { "x-custom": "hello" });
|
||||
expect(validate(store, store.cas.get(good) as CasNode)).toBe(true);
|
||||
});
|
||||
|
||||
test("accepts schema with prefixItems (tuple)", async () => {
|
||||
const store = createMemoryStore().cas;
|
||||
const store = createMemoryStore();
|
||||
const hash = await putSchema(store, {
|
||||
type: "array",
|
||||
prefixItems: [{ type: "string" }, { type: "number" }],
|
||||
@@ -561,22 +561,22 @@ describe("bootstrap meta-schema self-reference", () => {
|
||||
});
|
||||
|
||||
test("accepts schema with multipleOf", async () => {
|
||||
const store = createMemoryStore().cas;
|
||||
const store = createMemoryStore();
|
||||
const hash = await putSchema(store, {
|
||||
type: "number",
|
||||
multipleOf: 5,
|
||||
});
|
||||
expect(hash).toHaveLength(13);
|
||||
|
||||
const good = await store.put(hash, 15);
|
||||
expect(validate(store, store.get(good) as CasNode)).toBe(true);
|
||||
const good = store.cas.put(hash, 15);
|
||||
expect(validate(store, store.cas.get(good) as CasNode)).toBe(true);
|
||||
|
||||
const bad = await store.put(hash, 7);
|
||||
expect(validate(store, store.get(bad) as CasNode)).toBe(false);
|
||||
const bad = store.cas.put(hash, 7);
|
||||
expect(validate(store, store.cas.get(bad) as CasNode)).toBe(false);
|
||||
});
|
||||
|
||||
test("accepts schema with minProperties/maxProperties", async () => {
|
||||
const store = createMemoryStore().cas;
|
||||
const store = createMemoryStore();
|
||||
const hash = await putSchema(store, {
|
||||
type: "object",
|
||||
minProperties: 1,
|
||||
@@ -584,15 +584,15 @@ describe("bootstrap meta-schema self-reference", () => {
|
||||
});
|
||||
expect(hash).toHaveLength(13);
|
||||
|
||||
const good = await store.put(hash, { a: 1, b: 2 });
|
||||
expect(validate(store, store.get(good) as CasNode)).toBe(true);
|
||||
const good = store.cas.put(hash, { a: 1, b: 2 });
|
||||
expect(validate(store, store.cas.get(good) as CasNode)).toBe(true);
|
||||
|
||||
const empty = await store.put(hash, {});
|
||||
expect(validate(store, store.get(empty) as CasNode)).toBe(false);
|
||||
const empty = store.cas.put(hash, {});
|
||||
expect(validate(store, store.cas.get(empty) as CasNode)).toBe(false);
|
||||
});
|
||||
|
||||
test("accepts schema with default value", async () => {
|
||||
const store = createMemoryStore().cas;
|
||||
const store = createMemoryStore();
|
||||
const hash = await putSchema(store, {
|
||||
type: "string",
|
||||
default: "hello",
|
||||
@@ -601,7 +601,7 @@ describe("bootstrap meta-schema self-reference", () => {
|
||||
});
|
||||
|
||||
test("rejects invalid P2 keyword types", async () => {
|
||||
const store = createMemoryStore().cas;
|
||||
const store = createMemoryStore();
|
||||
await expect(
|
||||
putSchema(store, { allOf: "not-array" } as never),
|
||||
).rejects.toThrow();
|
||||
@@ -614,7 +614,7 @@ describe("bootstrap meta-schema self-reference", () => {
|
||||
});
|
||||
|
||||
test("collectRefs traverses allOf sub-schemas", async () => {
|
||||
const store = createMemoryStore().cas;
|
||||
const store = createMemoryStore();
|
||||
const innerSchema = await putSchema(store, { type: "string" });
|
||||
const schema = await putSchema(store, {
|
||||
allOf: [
|
||||
@@ -625,15 +625,15 @@ describe("bootstrap meta-schema self-reference", () => {
|
||||
],
|
||||
});
|
||||
|
||||
const targetHash = await store.put(innerSchema, "target");
|
||||
const nodeHash = await store.put(schema, { ref: targetHash });
|
||||
const node = store.get(nodeHash) as CasNode;
|
||||
const targetHash = store.cas.put(innerSchema, "target");
|
||||
const nodeHash = store.cas.put(schema, { ref: targetHash });
|
||||
const node = store.cas.get(nodeHash) as CasNode;
|
||||
const refList = refs(store, node);
|
||||
expect(refList).toContain(targetHash);
|
||||
});
|
||||
|
||||
test("collectRefs traverses patternProperties", async () => {
|
||||
const store = createMemoryStore().cas;
|
||||
const store = createMemoryStore();
|
||||
const innerSchema = await putSchema(store, { type: "string" });
|
||||
const schema = await putSchema(store, {
|
||||
type: "object",
|
||||
@@ -642,24 +642,24 @@ describe("bootstrap meta-schema self-reference", () => {
|
||||
},
|
||||
});
|
||||
|
||||
const targetHash = await store.put(innerSchema, "hello");
|
||||
const nodeHash = await store.put(schema, { ref_a: targetHash });
|
||||
const node = store.get(nodeHash) as CasNode;
|
||||
const targetHash = store.cas.put(innerSchema, "hello");
|
||||
const nodeHash = store.cas.put(schema, { ref_a: targetHash });
|
||||
const node = store.cas.get(nodeHash) as CasNode;
|
||||
const refList = refs(store, node);
|
||||
expect(refList).toContain(targetHash);
|
||||
});
|
||||
|
||||
test("collectRefs traverses prefixItems", async () => {
|
||||
const store = createMemoryStore().cas;
|
||||
const store = createMemoryStore();
|
||||
const innerSchema = await putSchema(store, { type: "string" });
|
||||
const schema = await putSchema(store, {
|
||||
type: "array",
|
||||
prefixItems: [{ type: "string", format: "ocas_ref" }, { type: "number" }],
|
||||
});
|
||||
|
||||
const targetHash = await store.put(innerSchema, "hello");
|
||||
const nodeHash = await store.put(schema, [targetHash, 42]);
|
||||
const node = store.get(nodeHash) as CasNode;
|
||||
const targetHash = store.cas.put(innerSchema, "hello");
|
||||
const nodeHash = store.cas.put(schema, [targetHash, 42]);
|
||||
const node = store.cas.get(nodeHash) as CasNode;
|
||||
const refList = refs(store, node);
|
||||
expect(refList).toContain(targetHash);
|
||||
});
|
||||
@@ -667,51 +667,51 @@ describe("bootstrap meta-schema self-reference", () => {
|
||||
// ── P3 combinators, propertyNames, and metadata ──────────────────────────
|
||||
|
||||
test("accepts schema with not", async () => {
|
||||
const store = createMemoryStore().cas;
|
||||
const store = createMemoryStore();
|
||||
const hash = await putSchema(store, {
|
||||
not: { type: "string" },
|
||||
});
|
||||
expect(hash).toHaveLength(13);
|
||||
|
||||
const good = await store.put(hash, 42);
|
||||
expect(validate(store, store.get(good) as CasNode)).toBe(true);
|
||||
const good = store.cas.put(hash, 42);
|
||||
expect(validate(store, store.cas.get(good) as CasNode)).toBe(true);
|
||||
|
||||
const bad = await store.put(hash, "hello");
|
||||
expect(validate(store, store.get(bad) as CasNode)).toBe(false);
|
||||
const bad = store.cas.put(hash, "hello");
|
||||
expect(validate(store, store.cas.get(bad) as CasNode)).toBe(false);
|
||||
});
|
||||
|
||||
test("accepts schema with contains", async () => {
|
||||
const store = createMemoryStore().cas;
|
||||
const store = createMemoryStore();
|
||||
const hash = await putSchema(store, {
|
||||
type: "array",
|
||||
contains: { type: "number", minimum: 10 },
|
||||
});
|
||||
expect(hash).toHaveLength(13);
|
||||
|
||||
const good = await store.put(hash, [1, 2, 15]);
|
||||
expect(validate(store, store.get(good) as CasNode)).toBe(true);
|
||||
const good = store.cas.put(hash, [1, 2, 15]);
|
||||
expect(validate(store, store.cas.get(good) as CasNode)).toBe(true);
|
||||
|
||||
const bad = await store.put(hash, [1, 2, 3]);
|
||||
expect(validate(store, store.get(bad) as CasNode)).toBe(false);
|
||||
const bad = store.cas.put(hash, [1, 2, 3]);
|
||||
expect(validate(store, store.cas.get(bad) as CasNode)).toBe(false);
|
||||
});
|
||||
|
||||
test("accepts schema with propertyNames", async () => {
|
||||
const store = createMemoryStore().cas;
|
||||
const store = createMemoryStore();
|
||||
const hash = await putSchema(store, {
|
||||
type: "object",
|
||||
propertyNames: { pattern: "^[a-z]+$" },
|
||||
});
|
||||
expect(hash).toHaveLength(13);
|
||||
|
||||
const good = await store.put(hash, { foo: 1, bar: 2 });
|
||||
expect(validate(store, store.get(good) as CasNode)).toBe(true);
|
||||
const good = store.cas.put(hash, { foo: 1, bar: 2 });
|
||||
expect(validate(store, store.cas.get(good) as CasNode)).toBe(true);
|
||||
|
||||
const bad = await store.put(hash, { Foo: 1 });
|
||||
expect(validate(store, store.get(bad) as CasNode)).toBe(false);
|
||||
const bad = store.cas.put(hash, { Foo: 1 });
|
||||
expect(validate(store, store.cas.get(bad) as CasNode)).toBe(false);
|
||||
});
|
||||
|
||||
test("accepts schema with metadata keywords", async () => {
|
||||
const store = createMemoryStore().cas;
|
||||
const store = createMemoryStore();
|
||||
const hash = await putSchema(store, {
|
||||
type: "string",
|
||||
examples: ["hello", "world"],
|
||||
@@ -723,7 +723,7 @@ describe("bootstrap meta-schema self-reference", () => {
|
||||
});
|
||||
|
||||
test("rejects invalid P3 keyword types", async () => {
|
||||
const store = createMemoryStore().cas;
|
||||
const store = createMemoryStore();
|
||||
await expect(
|
||||
putSchema(store, { not: "not-object" } as never),
|
||||
).rejects.toThrow();
|
||||
@@ -737,16 +737,16 @@ describe("bootstrap meta-schema self-reference", () => {
|
||||
});
|
||||
|
||||
test("collectRefs traverses contains", async () => {
|
||||
const store = createMemoryStore().cas;
|
||||
const store = createMemoryStore();
|
||||
const innerSchema = await putSchema(store, { type: "string" });
|
||||
const schema = await putSchema(store, {
|
||||
type: "array",
|
||||
contains: { type: "string", format: "ocas_ref" },
|
||||
});
|
||||
|
||||
const targetHash = await store.put(innerSchema, "hello");
|
||||
const nodeHash = await store.put(schema, [targetHash, "not-a-ref"]);
|
||||
const node = store.get(nodeHash) as CasNode;
|
||||
const targetHash = store.cas.put(innerSchema, "hello");
|
||||
const nodeHash = store.cas.put(schema, [targetHash, "not-a-ref"]);
|
||||
const node = store.cas.get(nodeHash) as CasNode;
|
||||
const refList = refs(store, node);
|
||||
expect(refList).toContain(targetHash);
|
||||
});
|
||||
|
||||
+10
-10
@@ -11,7 +11,7 @@ const Ajv = ((AjvModule as any).default ?? AjvModule) as {
|
||||
};
|
||||
|
||||
import { bootstrap } from "./bootstrap.js";
|
||||
import type { CasNode, Hash, Store } from "./types.js";
|
||||
import type { CasNode, Hash, OcasStore } from "./types.js";
|
||||
|
||||
export type JSONSchema = Record<string, unknown>;
|
||||
|
||||
@@ -239,7 +239,7 @@ function isValidSchema(value: unknown): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
function isMetaSchemaNode(store: Store, node: CasNode): boolean {
|
||||
function isMetaSchemaNode(store: OcasStore, node: CasNode): boolean {
|
||||
const schema = getSchema(store, node.type);
|
||||
return schema !== null && schema === node.payload;
|
||||
}
|
||||
@@ -249,7 +249,7 @@ function isMetaSchemaNode(store: Store, node: CasNode): boolean {
|
||||
* The returned hash becomes the typeHash for nodes that conform to this schema.
|
||||
*/
|
||||
export async function putSchema(
|
||||
store: Store,
|
||||
store: OcasStore,
|
||||
jsonSchema: JSONSchema,
|
||||
): Promise<Hash> {
|
||||
const builtinSchemas = await bootstrap(store);
|
||||
@@ -262,15 +262,15 @@ export async function putSchema(
|
||||
"Invalid schema: input does not conform to the ocas JSON Schema meta-schema",
|
||||
);
|
||||
}
|
||||
return Promise.resolve(store.put(metaHash, jsonSchema));
|
||||
return Promise.resolve(store.cas.put(metaHash, jsonSchema));
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve the JSON Schema payload for a given type hash.
|
||||
* Returns null if no node exists at that hash.
|
||||
*/
|
||||
export function getSchema(store: Store, typeHash: Hash): JSONSchema | null {
|
||||
const node = store.get(typeHash);
|
||||
export function getSchema(store: OcasStore, typeHash: Hash): JSONSchema | null {
|
||||
const node = store.cas.get(typeHash);
|
||||
if (node === null) return null;
|
||||
return node.payload as JSONSchema;
|
||||
}
|
||||
@@ -279,7 +279,7 @@ export function getSchema(store: Store, typeHash: Hash): JSONSchema | null {
|
||||
* Validate a node's payload against the schema identified by node.type.
|
||||
* Returns false if the schema cannot be found or validation fails.
|
||||
*/
|
||||
export function validate(store: Store, node: CasNode): boolean {
|
||||
export function validate(store: OcasStore, node: CasNode): boolean {
|
||||
const schema = getSchema(store, node.type);
|
||||
if (schema === null) return false;
|
||||
if (isMetaSchemaNode(store, node)) {
|
||||
@@ -416,7 +416,7 @@ export function collectRefs(schema: JSONSchema, value: unknown): Hash[] {
|
||||
* Return all hashes referenced by this node via ocas_ref fields in its schema.
|
||||
* Null/undefined values are skipped.
|
||||
*/
|
||||
export function refs(store: Store, node: CasNode): Hash[] {
|
||||
export function refs(store: OcasStore, node: CasNode): Hash[] {
|
||||
const schema = getSchema(store, node.type);
|
||||
if (schema === null) return [];
|
||||
return collectRefs(schema, node.payload);
|
||||
@@ -428,7 +428,7 @@ export function refs(store: Store, node: CasNode): Hash[] {
|
||||
* Handles cycles via a visited set.
|
||||
*/
|
||||
export function walk(
|
||||
store: Store,
|
||||
store: OcasStore,
|
||||
rootHash: Hash,
|
||||
visitor: (hash: Hash, node: CasNode) => void,
|
||||
): void {
|
||||
@@ -440,7 +440,7 @@ export function walk(
|
||||
if (visited.has(hash)) continue;
|
||||
visited.add(hash);
|
||||
|
||||
const node = store.get(hash);
|
||||
const node = store.cas.get(hash);
|
||||
if (node === null) continue;
|
||||
|
||||
visitor(hash, node);
|
||||
|
||||
+22
-12
@@ -2,10 +2,19 @@ import {
|
||||
BOOTSTRAP_STORE,
|
||||
type BootstrapCapableStore,
|
||||
} from "./bootstrap-capable.js";
|
||||
import {
|
||||
CasNodeNotFoundError,
|
||||
InvalidVariableNameError,
|
||||
MAX_HISTORY,
|
||||
SchemaMismatchError,
|
||||
TagLabelConflictError,
|
||||
VariableNotFoundError,
|
||||
} from "./errors.js";
|
||||
import { computeHashSync, computeSelfHashSync, initHasher } from "./hash.js";
|
||||
import { applyListOptions, casListEntry } from "./list-utils.js";
|
||||
import type {
|
||||
CasNode,
|
||||
CasStore,
|
||||
Hash,
|
||||
HistoryEntry,
|
||||
ListEntry,
|
||||
@@ -19,14 +28,6 @@ import type {
|
||||
VarStore,
|
||||
} from "./types.js";
|
||||
import type { Variable } from "./variable.js";
|
||||
import {
|
||||
CasNodeNotFoundError,
|
||||
InvalidVariableNameError,
|
||||
MAX_HISTORY,
|
||||
SchemaMismatchError,
|
||||
TagLabelConflictError,
|
||||
VariableNotFoundError,
|
||||
} from "./variable-store.js";
|
||||
|
||||
// Initialise the xxhash WASM instance once at module load. This allows the
|
||||
// CAS sub-store's `put` method to be synchronous (per the new CasStore type).
|
||||
@@ -199,7 +200,12 @@ function cloneVar(rec: VarRecord): Variable {
|
||||
};
|
||||
}
|
||||
|
||||
function createMemoryVarStore(cas: MemoryCasStore): VarStore {
|
||||
/**
|
||||
* Build an in-memory `VarStore` backed by the supplied CAS store. Exposed so
|
||||
* non-Memory CAS stores (e.g. the FS store) can compose a full `OcasStore`
|
||||
* without re-implementing variable storage.
|
||||
*/
|
||||
export function createMemoryVarStoreFor(cas: CasStore): VarStore {
|
||||
// composite key: `${name}\u0000${schema}`
|
||||
const records = new Map<string, VarRecord>();
|
||||
const byName = new Map<string, Set<string>>(); // name -> set of composite keys
|
||||
@@ -453,7 +459,11 @@ function createMemoryVarStore(cas: MemoryCasStore): VarStore {
|
||||
return varStore;
|
||||
}
|
||||
|
||||
function createMemoryTagStore(): TagStore {
|
||||
/**
|
||||
* Build an in-memory `TagStore`. Exposed for composition with non-Memory CAS
|
||||
* stores.
|
||||
*/
|
||||
export function createMemoryTagStoreImpl(): TagStore {
|
||||
// target -> key -> Tag
|
||||
const byTarget = new Map<Hash, Map<string, Tag>>();
|
||||
// key -> set of targets
|
||||
@@ -571,7 +581,7 @@ export function createMemoryStore(): OcasStore & {
|
||||
cas: MemoryCasStore;
|
||||
} {
|
||||
const cas = createCasStore();
|
||||
const varStore = createMemoryVarStore(cas);
|
||||
const tagStore = createMemoryTagStore();
|
||||
const varStore = createMemoryVarStoreFor(cas);
|
||||
const tagStore = createMemoryTagStoreImpl();
|
||||
return { cas, var: varStore, tag: tagStore };
|
||||
}
|
||||
|
||||
@@ -73,6 +73,7 @@ export type CasStore = {
|
||||
listByType(typeHash: Hash, options?: ListOptions): ListEntry[];
|
||||
listMeta(options?: ListOptions): ListEntry[];
|
||||
listSchemas(options?: ListOptions): ListEntry[];
|
||||
listAll(): Hash[];
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { createMemoryStore } from "./store.js";
|
||||
import type { Hash } from "./types.js";
|
||||
import {
|
||||
CasNodeNotFoundError,
|
||||
InvalidVariableNameError,
|
||||
@@ -8,7 +6,9 @@ import {
|
||||
SchemaMismatchError,
|
||||
TagLabelConflictError,
|
||||
VariableNotFoundError,
|
||||
} from "./variable-store.js";
|
||||
} from "./errors.js";
|
||||
import { createMemoryStore } from "./store.js";
|
||||
import type { Hash } from "./types.js";
|
||||
|
||||
function makeStoreWithSchema(): {
|
||||
store: ReturnType<typeof createMemoryStore>;
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { bootstrap } from "./bootstrap.js";
|
||||
import { createMemoryStore } from "./store.js";
|
||||
import type { Hash } from "./types.js";
|
||||
import { createVariableStore, type VariableStore } from "./variable-store.js";
|
||||
|
||||
let dbDir: string;
|
||||
let dbPath: string;
|
||||
let casStore: ReturnType<typeof createMemoryStore>;
|
||||
let varStore: VariableStore;
|
||||
let stringHash: Hash;
|
||||
|
||||
beforeEach(async () => {
|
||||
dbDir = mkdtempSync(join(tmpdir(), "ocas-var-pagination-"));
|
||||
dbPath = join(dbDir, "vars.db");
|
||||
casStore = createMemoryStore().cas;
|
||||
const aliases = await bootstrap(casStore);
|
||||
stringHash = aliases["@ocas/string"] as Hash;
|
||||
varStore = createVariableStore(dbPath, casStore);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
varStore.close();
|
||||
rmSync(dbDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function setN(prefix: string, n: number, delayMs = 2): Promise<Hash[]> {
|
||||
const hashes: Hash[] = [];
|
||||
for (let i = 0; i < n; i++) {
|
||||
const h = await casStore.put(stringHash, `${prefix}-${i}`);
|
||||
varStore.set(`@test/${prefix}-${i}`, h);
|
||||
hashes.push(h);
|
||||
if (delayMs > 0 && i < n - 1) {
|
||||
await new Promise((r) => setTimeout(r, delayMs));
|
||||
}
|
||||
}
|
||||
return hashes;
|
||||
}
|
||||
|
||||
describe("VariableStore.list - pagination + sort", () => {
|
||||
test("D1. default sort = created ASC", async () => {
|
||||
await setN("v", 3);
|
||||
const list = varStore.list({ namePrefix: "@test/v-" });
|
||||
for (let i = 1; i < list.length; i++) {
|
||||
expect((list[i] as { created: number }).created).toBeGreaterThanOrEqual(
|
||||
(list[i - 1] as { created: number }).created,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("D2. sort: 'updated' differs after re-set", async () => {
|
||||
await setN("u", 3);
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
// Re-set u-0 with a NEW value so updated changes
|
||||
const newHash = await casStore.put(stringHash, "u-0-new");
|
||||
varStore.set("@test/u-0", newHash);
|
||||
|
||||
const byUpdated = varStore.list({
|
||||
namePrefix: "@test/u-",
|
||||
sort: "updated",
|
||||
});
|
||||
// u-0 should be last when sorted updated ASC
|
||||
const last = byUpdated[byUpdated.length - 1] as { name: string };
|
||||
expect(last.name).toBe("@test/u-0");
|
||||
});
|
||||
|
||||
test("D3. desc reverses both sort modes", async () => {
|
||||
await setN("d", 3);
|
||||
const asc = varStore.list({ namePrefix: "@test/d-" });
|
||||
const desc = varStore.list({ namePrefix: "@test/d-", desc: true });
|
||||
expect(desc[0]).toEqual(asc[asc.length - 1] as (typeof asc)[number]);
|
||||
});
|
||||
|
||||
test("D4. limit/offset honored", async () => {
|
||||
await setN("p", 5);
|
||||
expect(varStore.list({ namePrefix: "@test/p-", limit: 2 })).toHaveLength(2);
|
||||
expect(
|
||||
varStore.list({ namePrefix: "@test/p-", offset: 2, limit: 10 }),
|
||||
).toHaveLength(3);
|
||||
});
|
||||
|
||||
test("D5. core has no default limit (returns all)", async () => {
|
||||
await setN("big", 105, 0);
|
||||
const list = varStore.list({ namePrefix: "@test/big-" });
|
||||
expect(list).toHaveLength(105);
|
||||
});
|
||||
|
||||
test("D6. pagination applied AFTER namePrefix/schema filters", async () => {
|
||||
await setN("filt", 5);
|
||||
const list = varStore.list({
|
||||
namePrefix: "@test/filt-",
|
||||
schema: stringHash,
|
||||
limit: 2,
|
||||
});
|
||||
expect(list).toHaveLength(2);
|
||||
for (const v of list) {
|
||||
expect((v as { name: string }).name.startsWith("@test/filt-")).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test("limit: 0 returns empty array", async () => {
|
||||
await setN("z", 3, 0);
|
||||
expect(varStore.list({ namePrefix: "@test/z-", limit: 0 })).toEqual([]);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,884 +0,0 @@
|
||||
import { Database } from "bun:sqlite";
|
||||
import type { Hash, ListSort, Store } from "./types.js";
|
||||
import type { Variable } from "./variable.js";
|
||||
|
||||
/**
|
||||
* Maximum number of historical values retained per (variable_name, variable_schema).
|
||||
* Position 0 is current; positions 1..MAX_HISTORY-1 are previous values (LRU).
|
||||
*/
|
||||
export const MAX_HISTORY = 10;
|
||||
|
||||
/**
|
||||
* Custom error types for variable operations
|
||||
*/
|
||||
export class VariableNotFoundError extends Error {
|
||||
constructor(
|
||||
public variableName: string,
|
||||
public variableSchema: Hash,
|
||||
) {
|
||||
super(`Variable not found: name=${variableName}, schema=${variableSchema}`);
|
||||
this.name = "VariableNotFoundError";
|
||||
}
|
||||
}
|
||||
|
||||
export class InvalidVariableNameError extends Error {
|
||||
constructor(
|
||||
public variableName: string,
|
||||
public reason: string,
|
||||
) {
|
||||
super(`Invalid variable name "${variableName}": ${reason}`);
|
||||
this.name = "InvalidVariableNameError";
|
||||
}
|
||||
}
|
||||
|
||||
export class SchemaMismatchError extends Error {
|
||||
constructor(
|
||||
public expected: string,
|
||||
public actual: string,
|
||||
) {
|
||||
super(`Schema mismatch: expected ${expected}, got ${actual}`);
|
||||
this.name = "SchemaMismatchError";
|
||||
}
|
||||
}
|
||||
|
||||
export class CasNodeNotFoundError extends Error {
|
||||
constructor(
|
||||
public readonly hash: string,
|
||||
message?: string,
|
||||
) {
|
||||
super(message ?? `CAS node not found: ${hash}`);
|
||||
this.name = "CasNodeNotFoundError";
|
||||
}
|
||||
}
|
||||
|
||||
export class TagLabelConflictError extends Error {
|
||||
constructor(
|
||||
public conflictName: string,
|
||||
public existingType: "tag" | "label",
|
||||
public attemptedType: "tag" | "label",
|
||||
) {
|
||||
super(`Conflict: '${conflictName}' already exists as a ${existingType}`);
|
||||
this.name = "TagLabelConflictError";
|
||||
}
|
||||
}
|
||||
|
||||
export class InvalidTagFormatError extends Error {
|
||||
constructor(tag: string) {
|
||||
super(`Invalid tag format: ${tag}`);
|
||||
this.name = "InvalidTagFormatError";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Variable store with SQLite backend
|
||||
*/
|
||||
export class VariableStore {
|
||||
private db: Database;
|
||||
|
||||
constructor(
|
||||
dbPath: string,
|
||||
private casStore: Store,
|
||||
) {
|
||||
this.db = new Database(dbPath, { create: true });
|
||||
// Enable foreign keys
|
||||
this.db.exec("PRAGMA foreign_keys = ON");
|
||||
this.initDb();
|
||||
}
|
||||
|
||||
private initDb(): void {
|
||||
this.db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS variables (
|
||||
name TEXT NOT NULL,
|
||||
schema TEXT NOT NULL,
|
||||
value TEXT NOT NULL,
|
||||
created INTEGER NOT NULL,
|
||||
updated INTEGER NOT NULL,
|
||||
PRIMARY KEY (name, schema)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_var_name ON variables(name);
|
||||
CREATE INDEX IF NOT EXISTS idx_var_value ON variables(value);
|
||||
CREATE INDEX IF NOT EXISTS idx_var_schema ON variables(schema);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS variable_tags (
|
||||
variable_name TEXT NOT NULL,
|
||||
variable_schema TEXT NOT NULL,
|
||||
key TEXT NOT NULL,
|
||||
value TEXT NOT NULL,
|
||||
PRIMARY KEY (variable_name, variable_schema, key),
|
||||
FOREIGN KEY (variable_name, variable_schema) REFERENCES variables(name, schema) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS variable_labels (
|
||||
variable_name TEXT NOT NULL,
|
||||
variable_schema TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
PRIMARY KEY (variable_name, variable_schema, name),
|
||||
FOREIGN KEY (variable_name, variable_schema) REFERENCES variables(name, schema) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_var_tag_key ON variable_tags(key);
|
||||
CREATE INDEX IF NOT EXISTS idx_var_tag_key_value ON variable_tags(key, value);
|
||||
CREATE INDEX IF NOT EXISTS idx_var_label_name ON variable_labels(name);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS variable_history (
|
||||
variable_name TEXT NOT NULL,
|
||||
variable_schema TEXT NOT NULL,
|
||||
value TEXT NOT NULL,
|
||||
position INTEGER NOT NULL,
|
||||
set_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (variable_name, variable_schema, position),
|
||||
FOREIGN KEY (variable_name, variable_schema) REFERENCES variables(name, schema) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_var_history_value ON variable_history(value);
|
||||
`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate variable name format.
|
||||
* All names must follow @scope/name pattern:
|
||||
* - scope: @[a-zA-Z][a-zA-Z0-9]* (e.g. @myapp, @ocas)
|
||||
* - name: one or more segments of [a-zA-Z0-9._-]+ separated by /
|
||||
* Examples: @myapp/config, @todo/schema, @ocas/schema
|
||||
*/
|
||||
private validateName(name: string): void {
|
||||
if (name === "") {
|
||||
throw new InvalidVariableNameError(name, "Name cannot be empty");
|
||||
}
|
||||
|
||||
// Must match @scope/name where scope starts with a letter
|
||||
const match = name.match(/^@([a-zA-Z][a-zA-Z0-9]*)\/(.+)$/);
|
||||
if (!match) {
|
||||
throw new InvalidVariableNameError(
|
||||
name,
|
||||
"Name must follow @scope/name format (e.g. @myapp/config)",
|
||||
);
|
||||
}
|
||||
|
||||
const rest = match[2] as string;
|
||||
|
||||
// Validate remaining segments
|
||||
if (rest.endsWith("/")) {
|
||||
throw new InvalidVariableNameError(
|
||||
name,
|
||||
"Name cannot end with trailing slash",
|
||||
);
|
||||
}
|
||||
|
||||
const segments = rest.split("/");
|
||||
for (const segment of segments) {
|
||||
if (segment === "") {
|
||||
throw new InvalidVariableNameError(
|
||||
name,
|
||||
"Name contains empty segment (consecutive slashes //)",
|
||||
);
|
||||
}
|
||||
if (!/^[a-zA-Z0-9._-]+$/.test(segment)) {
|
||||
throw new InvalidVariableNameError(
|
||||
name,
|
||||
`Segment "${segment}" contains invalid characters (only a-z, A-Z, 0-9, ., _, - allowed)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract schema hash from CAS node
|
||||
*/
|
||||
private extractSchema(hash: string): string {
|
||||
const node = this.casStore.get(hash);
|
||||
if (node === null) {
|
||||
throw new CasNodeNotFoundError(hash);
|
||||
}
|
||||
return node.type;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load tags for a variable
|
||||
*/
|
||||
private loadTags(name: string, schema: Hash): Record<string, string> {
|
||||
const stmt = this.db.prepare(`
|
||||
SELECT key, value
|
||||
FROM variable_tags
|
||||
WHERE variable_name = ? AND variable_schema = ?
|
||||
`);
|
||||
|
||||
const rows = stmt.all(name, schema) as Array<{
|
||||
key: string;
|
||||
value: string;
|
||||
}>;
|
||||
const tags: Record<string, string> = {};
|
||||
for (const row of rows) {
|
||||
tags[row.key] = row.value;
|
||||
}
|
||||
return tags;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load labels for a variable
|
||||
*/
|
||||
private loadLabels(name: string, schema: Hash): string[] {
|
||||
const stmt = this.db.prepare(`
|
||||
SELECT name
|
||||
FROM variable_labels
|
||||
WHERE variable_name = ? AND variable_schema = ?
|
||||
ORDER BY name ASC
|
||||
`);
|
||||
|
||||
const rows = stmt.all(name, schema) as Array<{ name: string }>;
|
||||
return rows.map((row) => row.name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Manage history for a variable on set().
|
||||
*
|
||||
* Rules:
|
||||
* - If new value equals current (position 0), no-op (idempotent).
|
||||
* - If new value already exists in history at position N, remove it; entries
|
||||
* with position < N shift +1; insert new value at position 0.
|
||||
* - Otherwise shift all entries +1, insert new at position 0, prune any
|
||||
* entries at position >= MAX_HISTORY.
|
||||
*
|
||||
* Caller must invoke inside a transaction.
|
||||
* Returns true if history changed (i.e. value differs from current),
|
||||
* false if it was a no-op.
|
||||
*/
|
||||
private recordHistory(
|
||||
name: string,
|
||||
schema: Hash,
|
||||
value: Hash,
|
||||
now: number,
|
||||
): boolean {
|
||||
// Check current value at position 0
|
||||
const currentRow = this.db
|
||||
.prepare(
|
||||
`SELECT value FROM variable_history WHERE variable_name = ? AND variable_schema = ? AND position = 0`,
|
||||
)
|
||||
.get(name, schema) as { value: string } | undefined | null;
|
||||
|
||||
if (currentRow && currentRow.value === value) {
|
||||
// Idempotent: same value as current; do nothing
|
||||
return false;
|
||||
}
|
||||
|
||||
// Find existing position of this value (if any)
|
||||
const existingRow = this.db
|
||||
.prepare(
|
||||
`SELECT position FROM variable_history WHERE variable_name = ? AND variable_schema = ? AND value = ?`,
|
||||
)
|
||||
.get(name, schema, value) as { position: number } | undefined | null;
|
||||
|
||||
if (existingRow) {
|
||||
const existingPos = existingRow.position;
|
||||
// Delete the existing entry first to free its position
|
||||
this.db
|
||||
.prepare(
|
||||
`DELETE FROM variable_history WHERE variable_name = ? AND variable_schema = ? AND position = ?`,
|
||||
)
|
||||
.run(name, schema, existingPos);
|
||||
|
||||
// Shift positions [0, existingPos) up by 1.
|
||||
// Use a temporary offset to avoid PK conflicts during the shift.
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE variable_history SET position = position + 1000000 WHERE variable_name = ? AND variable_schema = ? AND position < ?`,
|
||||
)
|
||||
.run(name, schema, existingPos);
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE variable_history SET position = position - 1000000 + 1 WHERE variable_name = ? AND variable_schema = ? AND position >= 1000000`,
|
||||
)
|
||||
.run(name, schema);
|
||||
} else {
|
||||
// New value: shift everything +1 (using temp offset to avoid PK conflicts)
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE variable_history SET position = position + 1000000 WHERE variable_name = ? AND variable_schema = ?`,
|
||||
)
|
||||
.run(name, schema);
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE variable_history SET position = position - 1000000 + 1 WHERE variable_name = ? AND variable_schema = ? AND position >= 1000000`,
|
||||
)
|
||||
.run(name, schema);
|
||||
|
||||
// Prune any entries that ended up at position >= MAX_HISTORY
|
||||
this.db
|
||||
.prepare(
|
||||
`DELETE FROM variable_history WHERE variable_name = ? AND variable_schema = ? AND position >= ?`,
|
||||
)
|
||||
.run(name, schema, MAX_HISTORY);
|
||||
}
|
||||
|
||||
// Insert new value at position 0
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT INTO variable_history (variable_name, variable_schema, value, position, set_at) VALUES (?, ?, ?, 0, ?)`,
|
||||
)
|
||||
.run(name, schema, value, now);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a variable (upsert: create or update)
|
||||
*/
|
||||
set(
|
||||
name: string,
|
||||
value: string,
|
||||
options?: {
|
||||
tags?: Record<string, string>;
|
||||
labels?: string[];
|
||||
},
|
||||
): Variable {
|
||||
// Validate name format
|
||||
this.validateName(name);
|
||||
|
||||
const schema = this.extractSchema(value);
|
||||
|
||||
// Check if variable exists
|
||||
const existing = this.get(name, schema);
|
||||
|
||||
if (existing !== null) {
|
||||
// Update existing variable
|
||||
const now = Date.now();
|
||||
|
||||
// If options provided, use them; otherwise preserve existing
|
||||
const tags = options?.tags ?? existing.tags;
|
||||
const labels = options?.labels ?? existing.labels;
|
||||
|
||||
// Check for tag/label conflicts when updating with new options
|
||||
if (options !== undefined) {
|
||||
const tagKeys = Object.keys(tags);
|
||||
for (const key of tagKeys) {
|
||||
if (labels.includes(key)) {
|
||||
throw new TagLabelConflictError(key, "label", "tag");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.db.exec("BEGIN TRANSACTION");
|
||||
|
||||
let changed = false;
|
||||
try {
|
||||
// Manage history (also detects idempotent same-value sets)
|
||||
changed = this.recordHistory(name, schema, value, now);
|
||||
|
||||
// Update value and timestamp only if value changed
|
||||
if (changed) {
|
||||
const updateStmt = this.db.prepare(`
|
||||
UPDATE variables
|
||||
SET value = ?, updated = ?
|
||||
WHERE name = ? AND schema = ?
|
||||
`);
|
||||
updateStmt.run(value, now, name, schema);
|
||||
}
|
||||
|
||||
// If options provided, update tags/labels
|
||||
if (options !== undefined) {
|
||||
// Delete existing tags and labels
|
||||
this.db
|
||||
.prepare(`
|
||||
DELETE FROM variable_tags WHERE variable_name = ? AND variable_schema = ?
|
||||
`)
|
||||
.run(name, schema);
|
||||
|
||||
this.db
|
||||
.prepare(`
|
||||
DELETE FROM variable_labels WHERE variable_name = ? AND variable_schema = ?
|
||||
`)
|
||||
.run(name, schema);
|
||||
|
||||
// Insert new tags
|
||||
const tagKeys = Object.keys(tags);
|
||||
if (tagKeys.length > 0) {
|
||||
const tagStmt = this.db.prepare(`
|
||||
INSERT INTO variable_tags (variable_name, variable_schema, key, value)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`);
|
||||
for (const [key, val] of Object.entries(tags)) {
|
||||
tagStmt.run(name, schema, key, val);
|
||||
}
|
||||
}
|
||||
|
||||
// Insert new labels
|
||||
if (labels.length > 0) {
|
||||
const labelStmt = this.db.prepare(`
|
||||
INSERT INTO variable_labels (variable_name, variable_schema, name)
|
||||
VALUES (?, ?, ?)
|
||||
`);
|
||||
for (const labelName of labels) {
|
||||
labelStmt.run(name, schema, labelName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.db.exec("COMMIT");
|
||||
} catch (e) {
|
||||
this.db.exec("ROLLBACK");
|
||||
throw e;
|
||||
}
|
||||
|
||||
return {
|
||||
name,
|
||||
schema,
|
||||
value,
|
||||
created: existing.created,
|
||||
updated: changed ? now : existing.updated,
|
||||
tags,
|
||||
labels: [...labels],
|
||||
};
|
||||
}
|
||||
|
||||
// Create new variable
|
||||
const tags = options?.tags ?? {};
|
||||
const labels = options?.labels ?? [];
|
||||
|
||||
// Check for tag/label conflicts
|
||||
const tagKeys = Object.keys(tags);
|
||||
for (const key of tagKeys) {
|
||||
if (labels.includes(key)) {
|
||||
throw new TagLabelConflictError(key, "label", "tag");
|
||||
}
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
this.db.exec("BEGIN TRANSACTION");
|
||||
|
||||
try {
|
||||
const stmt = this.db.prepare(`
|
||||
INSERT INTO variables (name, schema, value, created, updated)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
stmt.run(name, schema, value, now, now);
|
||||
|
||||
// Initialise history with this value at position 0
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT INTO variable_history (variable_name, variable_schema, value, position, set_at) VALUES (?, ?, ?, 0, ?)`,
|
||||
)
|
||||
.run(name, schema, value, now);
|
||||
|
||||
// Insert tags
|
||||
if (tagKeys.length > 0) {
|
||||
const tagStmt = this.db.prepare(`
|
||||
INSERT INTO variable_tags (variable_name, variable_schema, key, value)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`);
|
||||
for (const [key, val] of Object.entries(tags)) {
|
||||
tagStmt.run(name, schema, key, val);
|
||||
}
|
||||
}
|
||||
|
||||
// Insert labels
|
||||
if (labels.length > 0) {
|
||||
const labelStmt = this.db.prepare(`
|
||||
INSERT INTO variable_labels (variable_name, variable_schema, name)
|
||||
VALUES (?, ?, ?)
|
||||
`);
|
||||
for (const labelName of labels) {
|
||||
labelStmt.run(name, schema, labelName);
|
||||
}
|
||||
}
|
||||
|
||||
this.db.exec("COMMIT");
|
||||
} catch (e) {
|
||||
this.db.exec("ROLLBACK");
|
||||
throw e;
|
||||
}
|
||||
|
||||
return {
|
||||
name,
|
||||
schema,
|
||||
value,
|
||||
created: now,
|
||||
updated: now,
|
||||
tags,
|
||||
labels: [...labels],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a variable by name, optionally with schema
|
||||
*/
|
||||
/**
|
||||
* Get a variable by name and schema
|
||||
* @param name - Variable name
|
||||
* @param schema - Schema hash (required)
|
||||
* @returns Variable if found, null otherwise
|
||||
*/
|
||||
get(name: string, schema: Hash): Variable | null {
|
||||
// Precise match with schema
|
||||
const stmt = this.db.prepare(`
|
||||
SELECT name, schema, value, created, updated
|
||||
FROM variables
|
||||
WHERE name = ? AND schema = ?
|
||||
`);
|
||||
|
||||
const row = stmt.get(name, schema) as
|
||||
| {
|
||||
name: string;
|
||||
schema: string;
|
||||
value: string;
|
||||
created: number;
|
||||
updated: number;
|
||||
}
|
||||
| undefined
|
||||
| null;
|
||||
|
||||
if (row === undefined || row === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const tags = this.loadTags(row.name, row.schema);
|
||||
const labels = this.loadLabels(row.name, row.schema);
|
||||
|
||||
return {
|
||||
name: row.name,
|
||||
schema: row.schema,
|
||||
value: row.value,
|
||||
created: row.created,
|
||||
updated: row.updated,
|
||||
tags,
|
||||
labels,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a variable's value (with schema validation)
|
||||
*/
|
||||
update(name: string, schema: Hash, value: string): Variable {
|
||||
// Validate name format
|
||||
this.validateName(name);
|
||||
|
||||
const existing = this.get(name, schema);
|
||||
if (existing === null) {
|
||||
throw new VariableNotFoundError(name, schema);
|
||||
}
|
||||
|
||||
const newSchema = this.extractSchema(value);
|
||||
if (newSchema !== existing.schema) {
|
||||
throw new SchemaMismatchError(existing.schema, newSchema);
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
const stmt = this.db.prepare(`
|
||||
UPDATE variables
|
||||
SET value = ?, updated = ?
|
||||
WHERE name = ? AND schema = ?
|
||||
`);
|
||||
|
||||
stmt.run(value, now, name, schema);
|
||||
|
||||
return {
|
||||
...existing,
|
||||
value,
|
||||
updated: now,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a variable (or all variants if schema omitted)
|
||||
*/
|
||||
remove(name: string): Variable[];
|
||||
remove(name: string, schema: Hash): Variable;
|
||||
remove(name: string, schema?: Hash): Variable | Variable[] {
|
||||
if (schema !== undefined) {
|
||||
// Remove specific (name, schema) variant
|
||||
const existing = this.get(name, schema);
|
||||
if (existing === null) {
|
||||
throw new VariableNotFoundError(name, schema);
|
||||
}
|
||||
|
||||
const stmt = this.db.prepare(`
|
||||
DELETE FROM variables WHERE name = ? AND schema = ?
|
||||
`);
|
||||
|
||||
stmt.run(name, schema);
|
||||
|
||||
return existing;
|
||||
}
|
||||
|
||||
// Remove all schema variants for this name
|
||||
const variants = this.list({
|
||||
exactName: name,
|
||||
});
|
||||
|
||||
if (variants.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const stmt = this.db.prepare(`
|
||||
DELETE FROM variables WHERE name = ?
|
||||
`);
|
||||
|
||||
stmt.run(name);
|
||||
|
||||
return variants;
|
||||
}
|
||||
|
||||
/**
|
||||
* List variables with optional filters
|
||||
*/
|
||||
list(options?: {
|
||||
namePrefix?: string;
|
||||
exactName?: string;
|
||||
schema?: Hash;
|
||||
tags?: Record<string, string>;
|
||||
labels?: string[];
|
||||
sort?: ListSort;
|
||||
desc?: boolean;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}): Variable[] {
|
||||
// Validate mutually exclusive options
|
||||
if (options?.namePrefix !== undefined && options?.exactName !== undefined) {
|
||||
throw new Error(
|
||||
"namePrefix and exactName are mutually exclusive - cannot specify both",
|
||||
);
|
||||
}
|
||||
|
||||
const namePrefix = options?.namePrefix ?? "";
|
||||
const exactName = options?.exactName;
|
||||
const schema = options?.schema;
|
||||
const filterTags = options?.tags ?? {};
|
||||
const filterLabels = options?.labels ?? [];
|
||||
const sort = options?.sort ?? "created";
|
||||
const desc = options?.desc ?? false;
|
||||
const limit = options?.limit;
|
||||
const offset = options?.offset ?? 0;
|
||||
|
||||
if (limit !== undefined && limit <= 0) return [];
|
||||
|
||||
// Build query with filters
|
||||
let query = `
|
||||
SELECT DISTINCT v.name, v.schema, v.value, v.created, v.updated
|
||||
FROM variables v
|
||||
`;
|
||||
|
||||
const params: (string | number)[] = [];
|
||||
|
||||
// Tag filters (AND logic)
|
||||
const tagKeys = Object.keys(filterTags);
|
||||
for (let i = 0; i < tagKeys.length; i++) {
|
||||
const key = tagKeys[i] as string;
|
||||
const value = filterTags[key] as string;
|
||||
query += `
|
||||
INNER JOIN variable_tags t${i} ON v.name = t${i}.variable_name
|
||||
AND v.schema = t${i}.variable_schema
|
||||
AND t${i}.key = ? AND t${i}.value = ?
|
||||
`;
|
||||
params.push(key, value);
|
||||
}
|
||||
|
||||
// Label filters (AND logic)
|
||||
for (let i = 0; i < filterLabels.length; i++) {
|
||||
const label = filterLabels[i] as string;
|
||||
query += `
|
||||
INNER JOIN variable_labels l${i} ON v.name = l${i}.variable_name
|
||||
AND v.schema = l${i}.variable_schema
|
||||
AND l${i}.name = ?
|
||||
`;
|
||||
params.push(label);
|
||||
}
|
||||
|
||||
// WHERE clause for name filters and schema
|
||||
const whereClauses: string[] = [];
|
||||
|
||||
if (exactName !== undefined) {
|
||||
whereClauses.push("v.name = ?");
|
||||
params.push(exactName);
|
||||
} else if (namePrefix !== "") {
|
||||
whereClauses.push("v.name LIKE ? || '%'");
|
||||
params.push(namePrefix);
|
||||
}
|
||||
|
||||
if (schema !== undefined) {
|
||||
whereClauses.push("v.schema = ?");
|
||||
params.push(schema);
|
||||
}
|
||||
|
||||
if (whereClauses.length > 0) {
|
||||
query += ` WHERE ${whereClauses.join(" AND ")}`;
|
||||
}
|
||||
|
||||
const sortColumn = sort === "updated" ? "v.updated" : "v.created";
|
||||
const direction = desc ? "DESC" : "ASC";
|
||||
// Tiebreaker: name ASC for stable ordering across same-ms timestamps
|
||||
query += ` ORDER BY ${sortColumn} ${direction}, v.name ASC`;
|
||||
if (limit !== undefined) {
|
||||
query += " LIMIT ? OFFSET ?";
|
||||
params.push(limit, offset);
|
||||
} else if (offset > 0) {
|
||||
// SQLite requires LIMIT when using OFFSET; use -1 to mean "no limit".
|
||||
query += " LIMIT -1 OFFSET ?";
|
||||
params.push(offset);
|
||||
}
|
||||
|
||||
const stmt = this.db.prepare(query);
|
||||
const rows = stmt.all(...params) as Array<{
|
||||
name: string;
|
||||
schema: string;
|
||||
value: string;
|
||||
created: number;
|
||||
updated: number;
|
||||
}>;
|
||||
|
||||
return rows.map((row) => ({
|
||||
name: row.name,
|
||||
schema: row.schema,
|
||||
value: row.value,
|
||||
created: row.created,
|
||||
updated: row.updated,
|
||||
tags: this.loadTags(row.name, row.schema),
|
||||
labels: this.loadLabels(row.name, row.schema),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Add/update/delete tags and labels
|
||||
*/
|
||||
tag(
|
||||
name: string,
|
||||
schema: Hash,
|
||||
operations: {
|
||||
add?: Record<string, string>; // tags to add/update
|
||||
addLabels?: string[]; // labels to add
|
||||
delete?: string[]; // tag keys or label names to delete
|
||||
},
|
||||
): Variable {
|
||||
// Validate name format
|
||||
this.validateName(name);
|
||||
|
||||
const existing = this.get(name, schema);
|
||||
if (existing === null) {
|
||||
throw new VariableNotFoundError(name, schema);
|
||||
}
|
||||
|
||||
const addTags = operations.add ?? {};
|
||||
const addLabels = operations.addLabels ?? [];
|
||||
const deleteNames = operations.delete ?? [];
|
||||
|
||||
// Check for conflicts between tags and labels
|
||||
const newTagKeys = Object.keys(addTags);
|
||||
for (const key of newTagKeys) {
|
||||
// Check if this key is being added as a label in the same operation
|
||||
if (addLabels.includes(key)) {
|
||||
throw new TagLabelConflictError(key, "label", "tag");
|
||||
}
|
||||
// Check if this key already exists as a label (and not being deleted)
|
||||
if (existing.labels.includes(key) && !deleteNames.includes(key)) {
|
||||
throw new TagLabelConflictError(key, "label", "tag");
|
||||
}
|
||||
}
|
||||
|
||||
for (const labelName of addLabels) {
|
||||
// Check if this name is being added as a tag in the same operation
|
||||
if (newTagKeys.includes(labelName)) {
|
||||
throw new TagLabelConflictError(labelName, "tag", "label");
|
||||
}
|
||||
// Check if this name already exists as a tag key (and not being deleted)
|
||||
if (
|
||||
existing.tags[labelName] !== undefined &&
|
||||
!deleteNames.includes(labelName)
|
||||
) {
|
||||
throw new TagLabelConflictError(labelName, "tag", "label");
|
||||
}
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
this.db.exec("BEGIN TRANSACTION");
|
||||
|
||||
try {
|
||||
// Update timestamp
|
||||
const updateStmt = this.db.prepare(`
|
||||
UPDATE variables SET updated = ? WHERE name = ? AND schema = ?
|
||||
`);
|
||||
updateStmt.run(now, name, schema);
|
||||
|
||||
// Delete tags and labels
|
||||
if (deleteNames.length > 0) {
|
||||
const deleteTagStmt = this.db.prepare(`
|
||||
DELETE FROM variable_tags WHERE variable_name = ? AND variable_schema = ? AND key = ?
|
||||
`);
|
||||
const deleteLabelStmt = this.db.prepare(`
|
||||
DELETE FROM variable_labels WHERE variable_name = ? AND variable_schema = ? AND name = ?
|
||||
`);
|
||||
for (const deleteName of deleteNames) {
|
||||
deleteTagStmt.run(name, schema, deleteName);
|
||||
deleteLabelStmt.run(name, schema, deleteName);
|
||||
}
|
||||
}
|
||||
|
||||
// Add or update tags
|
||||
if (newTagKeys.length > 0) {
|
||||
const tagStmt = this.db.prepare(`
|
||||
INSERT OR REPLACE INTO variable_tags (variable_name, variable_schema, key, value)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`);
|
||||
for (const [key, value] of Object.entries(addTags)) {
|
||||
tagStmt.run(name, schema, key, value);
|
||||
}
|
||||
}
|
||||
|
||||
// Add labels (with conflict handling)
|
||||
if (addLabels.length > 0) {
|
||||
const labelStmt = this.db.prepare(`
|
||||
INSERT OR IGNORE INTO variable_labels (variable_name, variable_schema, name)
|
||||
VALUES (?, ?, ?)
|
||||
`);
|
||||
for (const labelName of addLabels) {
|
||||
labelStmt.run(name, schema, labelName);
|
||||
}
|
||||
}
|
||||
|
||||
this.db.exec("COMMIT");
|
||||
} catch (e) {
|
||||
this.db.exec("ROLLBACK");
|
||||
throw e;
|
||||
}
|
||||
|
||||
// Return updated variable
|
||||
const updated = this.get(name, schema);
|
||||
if (updated === null) {
|
||||
throw new VariableNotFoundError(name, schema);
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the value history for a variable, ordered by position.
|
||||
* Index 0 is the current value; subsequent entries are older.
|
||||
* Returns an empty array if the variable does not exist.
|
||||
*/
|
||||
history(name: string, schema: Hash): Hash[] {
|
||||
const rows = this.db
|
||||
.prepare(
|
||||
`SELECT value, position FROM variable_history WHERE variable_name = ? AND variable_schema = ? ORDER BY position ASC`,
|
||||
)
|
||||
.all(name, schema) as Array<{ value: string; position: number }>;
|
||||
return rows.map((r) => r.value as Hash);
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the database connection
|
||||
*/
|
||||
close(): void {
|
||||
this.db.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a variable store
|
||||
*/
|
||||
export function createVariableStore(
|
||||
dbPath: string,
|
||||
casStore: Store,
|
||||
): VariableStore {
|
||||
return new VariableStore(dbPath, casStore);
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import { wrapEnvelope } from "./wrap-envelope.js";
|
||||
|
||||
describe("wrapEnvelope", () => {
|
||||
test("resolves @ocas/output/put alias and returns envelope", async () => {
|
||||
const store = createMemoryStore().cas;
|
||||
const store = createMemoryStore();
|
||||
const aliases = await bootstrap(store);
|
||||
|
||||
const envelope = await wrapEnvelope(
|
||||
@@ -19,7 +19,7 @@ describe("wrapEnvelope", () => {
|
||||
});
|
||||
|
||||
test("resolves @ocas/output/has alias with boolean value", async () => {
|
||||
const store = createMemoryStore().cas;
|
||||
const store = createMemoryStore();
|
||||
const aliases = await bootstrap(store);
|
||||
|
||||
const envelope = await wrapEnvelope(store, "@ocas/output/has", true);
|
||||
@@ -29,7 +29,7 @@ describe("wrapEnvelope", () => {
|
||||
});
|
||||
|
||||
test("resolves @ocas/output/gc alias with object value", async () => {
|
||||
const store = createMemoryStore().cas;
|
||||
const store = createMemoryStore();
|
||||
const aliases = await bootstrap(store);
|
||||
|
||||
const gcStats = { total: 100, reachable: 80, collected: 20, scanned: 5 };
|
||||
@@ -40,7 +40,7 @@ describe("wrapEnvelope", () => {
|
||||
});
|
||||
|
||||
test("resolves primitive alias @ocas/string", async () => {
|
||||
const store = createMemoryStore().cas;
|
||||
const store = createMemoryStore();
|
||||
const aliases = await bootstrap(store);
|
||||
|
||||
const envelope = await wrapEnvelope(store, "@ocas/string", "hello");
|
||||
@@ -50,7 +50,7 @@ describe("wrapEnvelope", () => {
|
||||
});
|
||||
|
||||
test("throws for unknown alias", async () => {
|
||||
const store = createMemoryStore().cas;
|
||||
const store = createMemoryStore();
|
||||
await bootstrap(store);
|
||||
|
||||
await expect(
|
||||
@@ -59,7 +59,7 @@ describe("wrapEnvelope", () => {
|
||||
});
|
||||
|
||||
test("is idempotent — same alias returns same type hash", async () => {
|
||||
const store = createMemoryStore().cas;
|
||||
const store = createMemoryStore();
|
||||
|
||||
const first = await wrapEnvelope(store, "@ocas/output/verify", "ok");
|
||||
const second = await wrapEnvelope(
|
||||
@@ -74,7 +74,7 @@ describe("wrapEnvelope", () => {
|
||||
});
|
||||
|
||||
test("preserves complex object values without mutation", async () => {
|
||||
const store = createMemoryStore().cas;
|
||||
const store = createMemoryStore();
|
||||
await bootstrap(store);
|
||||
|
||||
const original = {
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { bootstrap } from "./bootstrap.js";
|
||||
import type { Hash, Store } from "./types.js";
|
||||
import type { Hash, OcasStore } from "./types.js";
|
||||
|
||||
/**
|
||||
* Resolve a schema alias (e.g. "@ocas/output/put") to its hash via bootstrap,
|
||||
* then return a typed envelope ready for store.put() or direct rendering.
|
||||
* then return a typed envelope ready for store.cas.put() or direct rendering.
|
||||
*/
|
||||
export async function wrapEnvelope(
|
||||
store: Store,
|
||||
store: OcasStore,
|
||||
schemaAlias: string,
|
||||
value: unknown,
|
||||
): Promise<{ type: Hash; value: unknown }> {
|
||||
|
||||
@@ -50,24 +50,24 @@ describe("createFsStore – init and bootstrap", () => {
|
||||
});
|
||||
|
||||
test("bootstrap returns a valid 13-char self-referencing hash", async () => {
|
||||
const store = createFsStore(dir);
|
||||
const store = await openStore(dir);
|
||||
const builtinSchemas = await bootstrap(store);
|
||||
const hash = builtinSchemas["@ocas/schema"] ?? "";
|
||||
|
||||
expect(hash).toHaveLength(13);
|
||||
expect(hash).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
|
||||
|
||||
const node = store.get(hash) as CasNode;
|
||||
const node = store.cas.get(hash) as CasNode;
|
||||
expect(node.type).toBe(hash);
|
||||
});
|
||||
|
||||
test("bootstrap is idempotent across calls", async () => {
|
||||
const store = createFsStore(dir);
|
||||
const store = await openStore(dir);
|
||||
const h1 = await bootstrap(store);
|
||||
const h2 = await bootstrap(store);
|
||||
|
||||
expect(h1).toEqual(h2);
|
||||
expect(store.listByType(h1["@ocas/schema"] ?? "")).toHaveLength(29);
|
||||
expect(store.cas.listByType(h1["@ocas/schema"] ?? "")).toHaveLength(29);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -112,7 +112,7 @@ describe("createFsStore – persistence round-trip", () => {
|
||||
});
|
||||
|
||||
test("bootstrap survives round-trip: self-referencing node reloads correctly", async () => {
|
||||
const store1 = createFsStore(dir);
|
||||
const store1 = await openStore(dir);
|
||||
const builtinSchemas = await bootstrap(store1);
|
||||
const hash = builtinSchemas["@ocas/schema"] ?? "";
|
||||
|
||||
@@ -260,7 +260,7 @@ describe("createFsStore – listByType", () => {
|
||||
});
|
||||
|
||||
test("bootstrap node is listed under its self type after reload", async () => {
|
||||
const store1 = createFsStore(dir);
|
||||
const store1 = await openStore(dir);
|
||||
const builtinSchemas = await bootstrap(store1);
|
||||
const hash = builtinSchemas["@ocas/schema"] ?? "";
|
||||
|
||||
@@ -294,7 +294,7 @@ describe("createFsStore – verify on disk-loaded nodes", () => {
|
||||
});
|
||||
|
||||
test("verify passes on a disk-loaded bootstrap node", async () => {
|
||||
const store1 = createFsStore(dir);
|
||||
const store1 = await openStore(dir);
|
||||
const builtinSchemas = await bootstrap(store1);
|
||||
const hash = builtinSchemas["@ocas/schema"] ?? "";
|
||||
|
||||
@@ -336,8 +336,8 @@ describe("openStore – async with auto-bootstrap", () => {
|
||||
test("openStore returns Promise<Store>", async () => {
|
||||
const store = await openStore(dir);
|
||||
expect(store).toBeDefined();
|
||||
expect(typeof store.put).toBe("function");
|
||||
expect(typeof store.get).toBe("function");
|
||||
expect(typeof store.cas.put).toBe("function");
|
||||
expect(typeof store.cas.get).toBe("function");
|
||||
});
|
||||
|
||||
test("openStore auto-creates directory when it doesn't exist", async () => {
|
||||
@@ -349,19 +349,19 @@ describe("openStore – async with auto-bootstrap", () => {
|
||||
|
||||
// Verify store works
|
||||
const typeHash = await computeSelfHash({ name: "t" });
|
||||
const hash = await store.put(typeHash, { x: 1 });
|
||||
expect(store.has(hash)).toBe(true);
|
||||
const hash = store.cas.put(typeHash, { x: 1 });
|
||||
expect(store.cas.has(hash)).toBe(true);
|
||||
});
|
||||
|
||||
test("openStore works when directory already exists", async () => {
|
||||
// Pre-create the directory
|
||||
const store1 = await openStore(dir);
|
||||
const typeHash = await computeSelfHash({ name: "t" });
|
||||
await store1.put(typeHash, { x: 1 });
|
||||
store1.cas.put(typeHash, { x: 1 });
|
||||
|
||||
// Open again
|
||||
const store2 = await openStore(dir);
|
||||
expect(store2.listByType(typeHash)).toHaveLength(1);
|
||||
expect(store2.cas.listByType(typeHash)).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("openStore throws error when path exists but is not a directory", async () => {
|
||||
@@ -379,25 +379,25 @@ describe("openStore – async with auto-bootstrap", () => {
|
||||
const metaHash = builtinSchemas["@ocas/schema"];
|
||||
|
||||
expect(metaHash).toBeDefined();
|
||||
expect(store.has(metaHash as string)).toBe(true);
|
||||
expect(store.cas.has(metaHash as string)).toBe(true);
|
||||
|
||||
// Verify all core schemas exist
|
||||
expect(store.has(builtinSchemas["@ocas/string"] as string)).toBe(true);
|
||||
expect(store.has(builtinSchemas["@ocas/number"] as string)).toBe(true);
|
||||
expect(store.has(builtinSchemas["@ocas/object"] as string)).toBe(true);
|
||||
expect(store.has(builtinSchemas["@ocas/array"] as string)).toBe(true);
|
||||
expect(store.has(builtinSchemas["@ocas/bool"] as string)).toBe(true);
|
||||
expect(store.has(builtinSchemas["@ocas/schema"] as string)).toBe(true);
|
||||
expect(store.cas.has(builtinSchemas["@ocas/string"] as string)).toBe(true);
|
||||
expect(store.cas.has(builtinSchemas["@ocas/number"] as string)).toBe(true);
|
||||
expect(store.cas.has(builtinSchemas["@ocas/object"] as string)).toBe(true);
|
||||
expect(store.cas.has(builtinSchemas["@ocas/array"] as string)).toBe(true);
|
||||
expect(store.cas.has(builtinSchemas["@ocas/bool"] as string)).toBe(true);
|
||||
expect(store.cas.has(builtinSchemas["@ocas/schema"] as string)).toBe(true);
|
||||
});
|
||||
|
||||
test("openStore bootstrap is idempotent on subsequent opens", async () => {
|
||||
const store1 = await openStore(dir);
|
||||
const schemas1 = await bootstrap(store1);
|
||||
const count1 = store1.listAll().length;
|
||||
const count1 = store1.cas.listAll().length;
|
||||
|
||||
const store2 = await openStore(dir);
|
||||
const schemas2 = await bootstrap(store2);
|
||||
const count2 = store2.listAll().length;
|
||||
const count2 = store2.cas.listAll().length;
|
||||
|
||||
// Same schemas, same count
|
||||
expect(schemas1).toEqual(schemas2);
|
||||
@@ -405,11 +405,11 @@ describe("openStore – async with auto-bootstrap", () => {
|
||||
});
|
||||
|
||||
test("openStore works on already-bootstrapped store", async () => {
|
||||
// Bootstrap manually first
|
||||
const store1 = createFsStore(dir);
|
||||
// Open + bootstrap
|
||||
const store1 = await openStore(dir);
|
||||
const schemas1 = await bootstrap(store1);
|
||||
|
||||
// Open with openStore
|
||||
// Open again
|
||||
const store2 = await openStore(dir);
|
||||
const schemas2 = await bootstrap(store2);
|
||||
|
||||
@@ -417,18 +417,18 @@ describe("openStore – async with auto-bootstrap", () => {
|
||||
});
|
||||
|
||||
test("openStore auto-bootstraps old store without bootstrap", async () => {
|
||||
// Create a store with some data but no bootstrap
|
||||
const store1 = createFsStore(dir);
|
||||
// Create a CAS store with some data but no bootstrap
|
||||
const cas1 = createFsStore(dir);
|
||||
const typeHash = await computeSelfHash({ name: "custom" });
|
||||
await store1.put(typeHash, { data: "old" });
|
||||
cas1.put(typeHash, { data: "old" });
|
||||
|
||||
// Open with openStore - should auto-bootstrap
|
||||
const store2 = await openStore(dir);
|
||||
const schemas = await bootstrap(store2);
|
||||
|
||||
expect(store2.has(schemas["@ocas/schema"] as string)).toBe(true);
|
||||
expect(store2.cas.has(schemas["@ocas/schema"] as string)).toBe(true);
|
||||
// Old data still exists
|
||||
expect(store2.listByType(typeHash)).toHaveLength(1);
|
||||
expect(store2.cas.listByType(typeHash)).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+79
-74
@@ -10,29 +10,32 @@ import {
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import type {
|
||||
BootstrapCapableStore,
|
||||
CasNode,
|
||||
Hash,
|
||||
ListEntry,
|
||||
ListOptions,
|
||||
VariableStore,
|
||||
} from "@ocas/core";
|
||||
|
||||
import {
|
||||
applyListOptions,
|
||||
BOOTSTRAP_STORE,
|
||||
type BootstrapCapableStore,
|
||||
bootstrap,
|
||||
type CasNode,
|
||||
casListEntry,
|
||||
cborEncode,
|
||||
computeHash,
|
||||
computeSelfHash,
|
||||
computeHashSync,
|
||||
computeSelfHashSync,
|
||||
type Hash,
|
||||
initHasher,
|
||||
type ListEntry,
|
||||
type ListOptions,
|
||||
type OcasStore,
|
||||
} from "@ocas/core";
|
||||
import { decode } from "cborg";
|
||||
import { createFsTagStore, createFsVarStoreFor } from "./var-store.js";
|
||||
|
||||
const INDEX_DIR = "_index";
|
||||
const META_FILE = "_meta";
|
||||
|
||||
// Initialise the xxhash WASM instance once at module load so the FS CAS
|
||||
// store can use the synchronous hashing functions.
|
||||
await initHasher();
|
||||
|
||||
function loadDir(dir: string, data: Map<Hash, CasNode>): void {
|
||||
let entries: string[];
|
||||
try {
|
||||
@@ -190,15 +193,24 @@ function hashesToEntries(
|
||||
return result;
|
||||
}
|
||||
|
||||
export function createFsStore(dir: string): BootstrapCapableStore {
|
||||
/**
|
||||
* The CAS sub-store of an FS-backed `OcasStore` — also satisfies the legacy
|
||||
* `BootstrapCapableStore` interface so `bootstrap()` can run against it.
|
||||
*/
|
||||
export type FsCasStore = BootstrapCapableStore & {
|
||||
put(typeHash: Hash, payload: unknown): Hash;
|
||||
delete(hash: Hash): boolean;
|
||||
};
|
||||
|
||||
export function createFsStore(dir: string): FsCasStore {
|
||||
const data = new Map<Hash, CasNode>();
|
||||
loadDir(dir, data);
|
||||
const indexDir = join(dir, INDEX_DIR);
|
||||
const typeIndex = loadOrMigrateTypeIndex(dir, data);
|
||||
const metaSet = loadOrMigrateMetaSet(dir, data);
|
||||
|
||||
async function putSelfReferencing(payload: unknown): Promise<Hash> {
|
||||
const hash = await computeSelfHash(payload);
|
||||
function putSelfReferencing(payload: unknown): Hash {
|
||||
const hash = computeSelfHashSync(payload);
|
||||
if (!data.has(hash)) {
|
||||
const node: CasNode = { type: hash, payload, timestamp: Date.now() };
|
||||
data.set(hash, node);
|
||||
@@ -218,9 +230,9 @@ export function createFsStore(dir: string): BootstrapCapableStore {
|
||||
return hash;
|
||||
}
|
||||
|
||||
const store: BootstrapCapableStore = {
|
||||
async put(typeHash: Hash, payload: unknown): Promise<Hash> {
|
||||
const hash = await computeHash(typeHash, payload);
|
||||
const store: FsCasStore = {
|
||||
put(typeHash: Hash, payload: unknown): Hash {
|
||||
const hash = computeHashSync(typeHash, payload);
|
||||
|
||||
if (!data.has(hash)) {
|
||||
const node: CasNode = {
|
||||
@@ -279,43 +291,43 @@ export function createFsStore(dir: string): BootstrapCapableStore {
|
||||
return applyListOptions(hashesToEntries(data, result), options);
|
||||
},
|
||||
|
||||
delete(hash: Hash): void {
|
||||
delete(hash: Hash): boolean {
|
||||
const node = data.get(hash);
|
||||
if (node) {
|
||||
data.delete(hash);
|
||||
// Delete file
|
||||
try {
|
||||
unlinkSync(join(dir, `${hash}.bin`));
|
||||
} catch {
|
||||
// ignore if file doesn't exist
|
||||
if (!node) return false;
|
||||
data.delete(hash);
|
||||
// Delete file
|
||||
try {
|
||||
unlinkSync(join(dir, `${hash}.bin`));
|
||||
} catch {
|
||||
// ignore if file doesn't exist
|
||||
}
|
||||
// Remove from type index
|
||||
const list = typeIndex.get(node.type);
|
||||
if (list) {
|
||||
const idx = list.indexOf(hash);
|
||||
if (idx !== -1) {
|
||||
list.splice(idx, 1);
|
||||
}
|
||||
// Remove from type index
|
||||
const list = typeIndex.get(node.type);
|
||||
if (list) {
|
||||
const idx = list.indexOf(hash);
|
||||
if (idx !== -1) {
|
||||
list.splice(idx, 1);
|
||||
if (list.length === 0) {
|
||||
typeIndex.delete(node.type);
|
||||
// Delete empty index file
|
||||
try {
|
||||
unlinkSync(join(indexDir, node.type));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
if (list.length === 0) {
|
||||
typeIndex.delete(node.type);
|
||||
// Delete empty index file
|
||||
try {
|
||||
unlinkSync(join(indexDir, node.type));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
} else {
|
||||
// Rewrite index file
|
||||
const body = `${list.join("\n")}\n`;
|
||||
writeFileSync(join(indexDir, node.type), body, "utf8");
|
||||
}
|
||||
}
|
||||
// Remove from meta set if applicable
|
||||
if (metaSet.has(hash)) {
|
||||
metaSet.delete(hash);
|
||||
rewriteMetaSet(indexDir, metaSet);
|
||||
} else {
|
||||
// Rewrite index file
|
||||
const body = `${list.join("\n")}\n`;
|
||||
writeFileSync(join(indexDir, node.type), body, "utf8");
|
||||
}
|
||||
}
|
||||
// Remove from meta set if applicable
|
||||
if (metaSet.has(hash)) {
|
||||
metaSet.delete(hash);
|
||||
rewriteMetaSet(indexDir, metaSet);
|
||||
}
|
||||
return true;
|
||||
},
|
||||
|
||||
[BOOTSTRAP_STORE]: putSelfReferencing,
|
||||
@@ -325,19 +337,16 @@ export function createFsStore(dir: string): BootstrapCapableStore {
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare a filesystem-backed CAS store: create the directory (if needed),
|
||||
* Prepare a filesystem-backed CAS sub-store: create the directory (if needed),
|
||||
* validate that the path is a directory, and instantiate the store. Does NOT
|
||||
* run bootstrap — callers that want bootstrap should either use {@link openStore}
|
||||
* or call `bootstrap` themselves (useful when wiring a varStore before
|
||||
* bootstrap to avoid running it twice).
|
||||
* or call `bootstrap` themselves.
|
||||
*
|
||||
* @param dir - The directory path for the store
|
||||
* @returns A Promise resolving to the BootstrapCapableStore
|
||||
* @returns A Promise resolving to the FsCasStore
|
||||
* @throws Error if the path exists but is not a directory
|
||||
*/
|
||||
export async function prepareStore(
|
||||
dir: string,
|
||||
): Promise<BootstrapCapableStore> {
|
||||
export async function prepareStore(dir: string): Promise<FsCasStore> {
|
||||
// Create directory if it doesn't exist
|
||||
try {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
@@ -374,25 +383,21 @@ export async function prepareStore(
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a filesystem-backed CAS store with automatic directory creation and bootstrap.
|
||||
* This is an async function that:
|
||||
* 1. Creates the directory (with recursive: true) if it doesn't exist
|
||||
* 2. Validates that the path is actually a directory (not a file)
|
||||
* 3. Creates the store
|
||||
* 4. Runs bootstrap (which is idempotent)
|
||||
* Open a filesystem-backed `OcasStore` with automatic directory creation and
|
||||
* bootstrap. The CAS sub-store is FS-backed; the variable and tag sub-stores
|
||||
* are in-memory (provided by `@ocas/core`).
|
||||
*
|
||||
* @param dir - The directory path for the store
|
||||
* @param varStore - Optional variable store; when provided, builtin schema
|
||||
* aliases are written to it during bootstrap
|
||||
* @returns A Promise resolving to the BootstrapCapableStore
|
||||
* @param dir - The directory path for the CAS store
|
||||
* @returns A Promise resolving to the OcasStore
|
||||
* @throws Error if the path exists but is not a directory
|
||||
*/
|
||||
export async function openStore(
|
||||
dir: string,
|
||||
varStore?: VariableStore,
|
||||
): Promise<BootstrapCapableStore> {
|
||||
const store = await prepareStore(dir);
|
||||
// Bootstrap (idempotent)
|
||||
await bootstrap(store, varStore);
|
||||
return store;
|
||||
export async function openStore(dir: string): Promise<OcasStore> {
|
||||
const cas = await prepareStore(dir);
|
||||
const ocas: OcasStore = {
|
||||
cas,
|
||||
var: createFsVarStoreFor(dir, cas),
|
||||
tag: createFsTagStore(dir),
|
||||
};
|
||||
await bootstrap(ocas);
|
||||
return ocas;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,506 @@
|
||||
import {
|
||||
appendFileSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import type {
|
||||
CasStore,
|
||||
Hash,
|
||||
HistoryEntry,
|
||||
Tag,
|
||||
TagStore,
|
||||
Variable,
|
||||
VarListOptions,
|
||||
VarStore,
|
||||
} from "@ocas/core";
|
||||
import {
|
||||
CasNodeNotFoundError,
|
||||
InvalidVariableNameError,
|
||||
MAX_HISTORY,
|
||||
SchemaMismatchError,
|
||||
TagLabelConflictError,
|
||||
VariableNotFoundError,
|
||||
} from "@ocas/core";
|
||||
|
||||
const VARS_FILE = "_vars.jsonl";
|
||||
const TAGS_FILE = "_tags.jsonl";
|
||||
|
||||
function validateName(name: string): void {
|
||||
if (name === "")
|
||||
throw new InvalidVariableNameError(name, "Name cannot be empty");
|
||||
const match = name.match(/^@([a-zA-Z][a-zA-Z0-9]*)\/(.+)$/);
|
||||
if (!match)
|
||||
throw new InvalidVariableNameError(
|
||||
name,
|
||||
"Name must follow @scope/name format (e.g. @myapp/config)",
|
||||
);
|
||||
const rest = match[2] as string;
|
||||
if (rest.endsWith("/"))
|
||||
throw new InvalidVariableNameError(
|
||||
name,
|
||||
"Name cannot end with trailing slash",
|
||||
);
|
||||
for (const segment of rest.split("/")) {
|
||||
if (segment === "")
|
||||
throw new InvalidVariableNameError(
|
||||
name,
|
||||
"Name contains empty segment (consecutive slashes //)",
|
||||
);
|
||||
if (!/^[a-zA-Z0-9._-]+$/.test(segment))
|
||||
throw new InvalidVariableNameError(
|
||||
name,
|
||||
`Segment "${segment}" contains invalid characters (only a-z, A-Z, 0-9, ., _, - allowed)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
type VarRecord = {
|
||||
name: string;
|
||||
schema: Hash;
|
||||
value: Hash;
|
||||
created: number;
|
||||
updated: number;
|
||||
tags: Record<string, string>;
|
||||
labels: string[];
|
||||
history: HistoryEntry[];
|
||||
};
|
||||
|
||||
function cloneVar(rec: VarRecord): Variable {
|
||||
return {
|
||||
name: rec.name,
|
||||
schema: rec.schema,
|
||||
value: rec.value,
|
||||
created: rec.created,
|
||||
updated: rec.updated,
|
||||
tags: { ...rec.tags },
|
||||
labels: [...rec.labels],
|
||||
};
|
||||
}
|
||||
|
||||
export function createFsVarStoreFor(dir: string, cas: CasStore): VarStore {
|
||||
const records = new Map<string, VarRecord>();
|
||||
const byName = new Map<string, Set<string>>();
|
||||
const path = join(dir, VARS_FILE);
|
||||
|
||||
function key(name: string, schema: Hash): string {
|
||||
return `${name}\u0000${schema}`;
|
||||
}
|
||||
function addIndex(name: string, k: string): void {
|
||||
let set = byName.get(name);
|
||||
if (!set) {
|
||||
set = new Set();
|
||||
byName.set(name, set);
|
||||
}
|
||||
set.add(k);
|
||||
}
|
||||
function removeIndex(name: string, k: string): void {
|
||||
const set = byName.get(name);
|
||||
if (!set) return;
|
||||
set.delete(k);
|
||||
if (set.size === 0) byName.delete(name);
|
||||
}
|
||||
|
||||
// Load existing records (last record per key wins)
|
||||
try {
|
||||
const content = readFileSync(path, "utf8");
|
||||
for (const line of content.split("\n")) {
|
||||
if (line.length === 0) continue;
|
||||
try {
|
||||
const rec = JSON.parse(line) as VarRecord & { __op?: string };
|
||||
if (rec.__op === "remove") {
|
||||
const k = key(rec.name, rec.schema);
|
||||
records.delete(k);
|
||||
removeIndex(rec.name, k);
|
||||
} else {
|
||||
const k = key(rec.name, rec.schema);
|
||||
records.set(k, rec);
|
||||
addIndex(rec.name, k);
|
||||
}
|
||||
} catch {
|
||||
// skip malformed
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// file may not exist
|
||||
}
|
||||
|
||||
function persistFull(): void {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
const lines: string[] = [];
|
||||
for (const rec of records.values()) {
|
||||
lines.push(JSON.stringify(rec));
|
||||
}
|
||||
writeFileSync(path, lines.length ? `${lines.join("\n")}\n` : "", "utf8");
|
||||
}
|
||||
|
||||
function appendRecord(rec: VarRecord): void {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
appendFileSync(path, `${JSON.stringify(rec)}\n`, "utf8");
|
||||
}
|
||||
|
||||
function appendRemoval(name: string, schema: Hash): void {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
appendFileSync(
|
||||
path,
|
||||
`${JSON.stringify({ __op: "remove", name, schema })}\n`,
|
||||
"utf8",
|
||||
);
|
||||
}
|
||||
|
||||
function extractSchema(hash: Hash): Hash {
|
||||
const node = cas.get(hash);
|
||||
if (node === null) throw new CasNodeNotFoundError(hash);
|
||||
return node.type;
|
||||
}
|
||||
|
||||
function checkConflict(tags: Record<string, string>, labels: string[]): void {
|
||||
for (const tk of Object.keys(tags)) {
|
||||
if (labels.includes(tk))
|
||||
throw new TagLabelConflictError(tk, "label", "tag");
|
||||
}
|
||||
}
|
||||
|
||||
function pushHistory(rec: VarRecord, value: Hash, now: number): boolean {
|
||||
if (rec.history.length > 0 && rec.history[0]?.value === value) return false;
|
||||
const existingIdx = rec.history.findIndex((e) => e.value === value);
|
||||
if (existingIdx > 0) rec.history.splice(existingIdx, 1);
|
||||
rec.history.unshift({ value, position: 0, setAt: now });
|
||||
if (rec.history.length > MAX_HISTORY) rec.history.length = MAX_HISTORY;
|
||||
for (let i = 0; i < rec.history.length; i++) {
|
||||
const entry = rec.history[i];
|
||||
if (entry !== undefined) entry.position = i;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return {
|
||||
set(name, hash, options) {
|
||||
validateName(name);
|
||||
const schema = extractSchema(hash);
|
||||
const k = key(name, schema);
|
||||
const existing = records.get(k);
|
||||
const now = Date.now();
|
||||
if (existing) {
|
||||
const tags = options?.tags ?? existing.tags;
|
||||
const labels = options?.labels ?? existing.labels;
|
||||
if (options !== undefined) checkConflict(tags, labels);
|
||||
const changed = pushHistory(existing, hash, now);
|
||||
if (changed) {
|
||||
existing.value = hash;
|
||||
existing.updated = now;
|
||||
}
|
||||
if (options !== undefined) {
|
||||
existing.tags = { ...tags };
|
||||
existing.labels = [...labels];
|
||||
}
|
||||
persistFull();
|
||||
return cloneVar(existing);
|
||||
}
|
||||
const tags = options?.tags ?? {};
|
||||
const labels = options?.labels ?? [];
|
||||
checkConflict(tags, labels);
|
||||
const rec: VarRecord = {
|
||||
name,
|
||||
schema,
|
||||
value: hash,
|
||||
created: now,
|
||||
updated: now,
|
||||
tags: { ...tags },
|
||||
labels: [...labels],
|
||||
history: [{ value: hash, position: 0, setAt: now }],
|
||||
};
|
||||
records.set(k, rec);
|
||||
addIndex(name, k);
|
||||
appendRecord(rec);
|
||||
return cloneVar(rec);
|
||||
},
|
||||
|
||||
get(name, schema) {
|
||||
if (schema !== undefined) {
|
||||
const rec = records.get(key(name, schema));
|
||||
return rec ? cloneVar(rec) : null;
|
||||
}
|
||||
const set = byName.get(name);
|
||||
if (!set || set.size !== 1) return null;
|
||||
const onlyKey = set.values().next().value;
|
||||
if (onlyKey === undefined) return null;
|
||||
const rec = records.get(onlyKey);
|
||||
return rec ? cloneVar(rec) : null;
|
||||
},
|
||||
|
||||
remove(name, schema) {
|
||||
if (schema !== undefined) {
|
||||
const k = key(name, schema);
|
||||
const rec = records.get(k);
|
||||
if (!rec) return [];
|
||||
records.delete(k);
|
||||
removeIndex(name, k);
|
||||
appendRemoval(name, schema);
|
||||
return [cloneVar(rec)];
|
||||
}
|
||||
const set = byName.get(name);
|
||||
if (!set) return [];
|
||||
const removed: Variable[] = [];
|
||||
for (const k of [...set]) {
|
||||
const rec = records.get(k);
|
||||
if (rec) {
|
||||
removed.push(cloneVar(rec));
|
||||
records.delete(k);
|
||||
appendRemoval(rec.name, rec.schema);
|
||||
}
|
||||
}
|
||||
byName.delete(name);
|
||||
return removed;
|
||||
},
|
||||
|
||||
update(name, hash, options) {
|
||||
validateName(name);
|
||||
const newSchema = extractSchema(hash);
|
||||
const set = byName.get(name);
|
||||
if (!set || set.size === 0)
|
||||
throw new VariableNotFoundError(name, newSchema);
|
||||
const k = key(name, newSchema);
|
||||
const existing = records.get(k);
|
||||
if (!existing) {
|
||||
for (const ek of set) {
|
||||
const erec = records.get(ek);
|
||||
if (erec) throw new SchemaMismatchError(erec.schema, newSchema);
|
||||
}
|
||||
throw new VariableNotFoundError(name, newSchema);
|
||||
}
|
||||
const now = Date.now();
|
||||
const tags = options?.tags ?? existing.tags;
|
||||
const labels = options?.labels ?? existing.labels;
|
||||
if (options !== undefined) checkConflict(tags, labels);
|
||||
const changed = pushHistory(existing, hash, now);
|
||||
if (changed) {
|
||||
existing.value = hash;
|
||||
existing.updated = now;
|
||||
}
|
||||
if (options !== undefined) {
|
||||
existing.tags = { ...tags };
|
||||
existing.labels = [...labels];
|
||||
}
|
||||
persistFull();
|
||||
return cloneVar(existing);
|
||||
},
|
||||
|
||||
list(options?: VarListOptions) {
|
||||
if (
|
||||
options?.namePrefix !== undefined &&
|
||||
options?.exactName !== undefined
|
||||
) {
|
||||
throw new Error(
|
||||
"namePrefix and exactName are mutually exclusive - cannot specify both",
|
||||
);
|
||||
}
|
||||
const namePrefix = options?.namePrefix;
|
||||
const exactName = options?.exactName;
|
||||
const schema = options?.schema;
|
||||
const filterTags = options?.tags ?? {};
|
||||
const filterLabels = options?.labels ?? [];
|
||||
const sort = options?.sort ?? "created";
|
||||
const desc = options?.desc ?? false;
|
||||
const limit = options?.limit;
|
||||
const offset = options?.offset ?? 0;
|
||||
if (limit !== undefined && limit <= 0) return [];
|
||||
|
||||
let results: VarRecord[] = [];
|
||||
for (const rec of records.values()) {
|
||||
if (exactName !== undefined && rec.name !== exactName) continue;
|
||||
if (namePrefix !== undefined && !rec.name.startsWith(namePrefix))
|
||||
continue;
|
||||
if (schema !== undefined && rec.schema !== schema) continue;
|
||||
let ok = true;
|
||||
for (const [tk, tv] of Object.entries(filterTags)) {
|
||||
if (rec.tags[tk] !== tv) {
|
||||
ok = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!ok) continue;
|
||||
for (const lb of filterLabels) {
|
||||
if (!rec.labels.includes(lb)) {
|
||||
ok = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!ok) continue;
|
||||
results.push(rec);
|
||||
}
|
||||
results.sort((a, b) => {
|
||||
const av = sort === "updated" ? a.updated : a.created;
|
||||
const bv = sort === "updated" ? b.updated : b.created;
|
||||
if (av !== bv) return desc ? bv - av : av - bv;
|
||||
return a.name < b.name ? -1 : a.name > b.name ? 1 : 0;
|
||||
});
|
||||
if (offset > 0) results = results.slice(offset);
|
||||
if (limit !== undefined) results = results.slice(0, limit);
|
||||
return results.map(cloneVar);
|
||||
},
|
||||
|
||||
history(name, schema) {
|
||||
if (schema !== undefined) {
|
||||
const rec = records.get(key(name, schema));
|
||||
return rec ? rec.history.map((e) => ({ ...e })) : [];
|
||||
}
|
||||
const set = byName.get(name);
|
||||
if (!set || set.size !== 1) return [];
|
||||
const onlyKey = set.values().next().value;
|
||||
if (onlyKey === undefined) return [];
|
||||
const rec = records.get(onlyKey);
|
||||
return rec ? rec.history.map((e) => ({ ...e })) : [];
|
||||
},
|
||||
|
||||
close() {
|
||||
// no-op (synchronous file ops)
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
type StoredTag = {
|
||||
key: string;
|
||||
value: string | null;
|
||||
target: Hash;
|
||||
created: number;
|
||||
};
|
||||
|
||||
export function createFsTagStore(dir: string): TagStore {
|
||||
const byTarget = new Map<Hash, Map<string, Tag>>();
|
||||
const byKey = new Map<string, Set<Hash>>();
|
||||
const path = join(dir, TAGS_FILE);
|
||||
|
||||
function addKeyIndex(k: string, target: Hash): void {
|
||||
let set = byKey.get(k);
|
||||
if (!set) {
|
||||
set = new Set();
|
||||
byKey.set(k, set);
|
||||
}
|
||||
set.add(target);
|
||||
}
|
||||
function removeKeyIndex(k: string, target: Hash): void {
|
||||
const set = byKey.get(k);
|
||||
if (!set) return;
|
||||
const tmap = byTarget.get(target);
|
||||
if (tmap?.has(k)) return;
|
||||
set.delete(target);
|
||||
if (set.size === 0) byKey.delete(k);
|
||||
}
|
||||
|
||||
// Load
|
||||
try {
|
||||
const content = readFileSync(path, "utf8");
|
||||
for (const line of content.split("\n")) {
|
||||
if (line.length === 0) continue;
|
||||
try {
|
||||
const ent = JSON.parse(line) as
|
||||
| (StoredTag & { __op?: "set" | "untag" })
|
||||
| { __op: "untag"; target: Hash; key: string };
|
||||
if ((ent as { __op?: string }).__op === "untag") {
|
||||
const e = ent as { target: Hash; key: string };
|
||||
const tm = byTarget.get(e.target);
|
||||
if (tm) {
|
||||
tm.delete(e.key);
|
||||
removeKeyIndex(e.key, e.target);
|
||||
if (tm.size === 0) byTarget.delete(e.target);
|
||||
}
|
||||
} else {
|
||||
const t = ent as StoredTag;
|
||||
let tm = byTarget.get(t.target);
|
||||
if (!tm) {
|
||||
tm = new Map();
|
||||
byTarget.set(t.target, tm);
|
||||
}
|
||||
tm.set(t.key, {
|
||||
key: t.key,
|
||||
value: t.value,
|
||||
target: t.target,
|
||||
created: t.created,
|
||||
});
|
||||
addKeyIndex(t.key, t.target);
|
||||
}
|
||||
} catch {
|
||||
// skip
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// none
|
||||
}
|
||||
|
||||
function append(line: object): void {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
appendFileSync(path, `${JSON.stringify(line)}\n`, "utf8");
|
||||
}
|
||||
|
||||
return {
|
||||
tag(target, ops) {
|
||||
let tm = byTarget.get(target);
|
||||
if (!tm) {
|
||||
tm = new Map();
|
||||
byTarget.set(target, tm);
|
||||
}
|
||||
const now = Date.now();
|
||||
for (const op of ops) {
|
||||
if (op.op === "set") {
|
||||
const existing = tm.get(op.key);
|
||||
const tag: Tag = {
|
||||
key: op.key,
|
||||
value: op.value ?? null,
|
||||
target,
|
||||
created: existing?.created ?? now,
|
||||
};
|
||||
tm.set(op.key, tag);
|
||||
addKeyIndex(op.key, target);
|
||||
append(tag);
|
||||
} else {
|
||||
tm.delete(op.key);
|
||||
removeKeyIndex(op.key, target);
|
||||
append({ __op: "untag", target, key: op.key });
|
||||
}
|
||||
}
|
||||
return [...tm.values()].sort((a, b) =>
|
||||
a.key < b.key ? -1 : a.key > b.key ? 1 : 0,
|
||||
);
|
||||
},
|
||||
untag(target, keys) {
|
||||
const tm = byTarget.get(target);
|
||||
if (!tm) return;
|
||||
for (const k of keys) {
|
||||
tm.delete(k);
|
||||
removeKeyIndex(k, target);
|
||||
append({ __op: "untag", target, key: k });
|
||||
}
|
||||
if (tm.size === 0) byTarget.delete(target);
|
||||
},
|
||||
tags(target) {
|
||||
const tm = byTarget.get(target);
|
||||
if (!tm) return [];
|
||||
return [...tm.values()].sort((a, b) =>
|
||||
a.key < b.key ? -1 : a.key > b.key ? 1 : 0,
|
||||
);
|
||||
},
|
||||
listByTag(tag, _options) {
|
||||
let key = tag;
|
||||
let value: string | null | undefined;
|
||||
const eqIdx = tag.indexOf("=");
|
||||
if (eqIdx >= 0) {
|
||||
key = tag.slice(0, eqIdx);
|
||||
value = tag.slice(eqIdx + 1);
|
||||
}
|
||||
const targets = byKey.get(key);
|
||||
if (!targets) return [];
|
||||
const result: Hash[] = [];
|
||||
for (const t of targets) {
|
||||
const tm = byTarget.get(t);
|
||||
if (!tm) continue;
|
||||
const tagEntry = tm.get(key);
|
||||
if (!tagEntry) continue;
|
||||
if (value !== undefined && tagEntry.value !== value) continue;
|
||||
result.push(t);
|
||||
}
|
||||
return result;
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user