Compare commits

..

3 Commits

Author SHA1 Message Date
xiaoju aefd93c33e chore: Phase 1 code style fixes and missing features
Fixes #27

Changes:
1. Variable uses type instead of interface
2. Add JSON envelope output {type, value} to all CLI var commands
3. Add list method with scope prefix matching to VariableStore and CLI
4. Fix var-db path to default to <storePath>/variables.db instead of <defaultStorePath>/variables.db

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-30 06:42:00 +00:00
xiaonuo 76dab6737c Merge pull request 'feat: RFC-20 Phase 1 - Variable CRUD Operations' (#25) from fix/21-variable-crud into main 2026-05-30 06:22:44 +00:00
xiaoju cf716c5115 feat: implement RFC-20 Phase 1 variable CRUD operations
Add a complete variable system for json-cas that provides mutable named
bindings to immutable CAS nodes.

Features:
- ULID-based variable identifiers (26-char Crockford Base32)
- Hierarchical scope validation (must end with /)
- Schema validation on update (prevents type mismatches)
- SQLite persistence (~/.uncaged/json-cas/variables.db)
- CLI commands: var create, get, update, delete

Implementation:
- Core types in variable.ts (Variable, VariableId)
- VariableStore class with SQLite backend
- Custom error types (VariableNotFoundError, SchemaMismatchError, etc.)
- Comprehensive unit tests (16 tests)
- CLI integration tests (12 tests)
- All outputs use JSON format

Test coverage:
- Variable creation with scope validation
- CRUD operations (create, read, update, delete)
- Schema consistency enforcement
- Error handling for all edge cases
- Full lifecycle integration tests

All tests pass (158 total), build clean, lint clean.

Fixes #21

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-30 05:57:05 +00:00
10 changed files with 1711 additions and 38 deletions
+5
View File
@@ -10,6 +10,7 @@
"@changesets/cli": "^2.31.0",
"bun-types": "^1.3.14",
"typescript": "^5.8.0",
"ulidx": "^2.4.1",
},
},
"packages/cli-json-cas": {
@@ -200,6 +201,8 @@
"jsonfile": ["jsonfile@4.0.0", "", { "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg=="],
"layerr": ["layerr@3.0.0", "", {}, "sha512-tv754Ki2dXpPVApOrjTyRo4/QegVb9eVFq4mjqp4+NM5NaX7syQvN5BBNfV/ZpAHCEHV24XdUVrBAoka4jt3pA=="],
"locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="],
"lodash.startcase": ["lodash.startcase@4.4.0", "", {}, "sha512-+WKqsK294HMSc2jEbNgpHpd0JfIBhp7rEV4aqXWqFr6AlXov+SlcgB1Fv01y2kGe3Gc8nMW7VA0SrGuSkRfIEg=="],
@@ -282,6 +285,8 @@
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"ulidx": ["ulidx@2.4.1", "", { "dependencies": { "layerr": "^3.0.0" } }, "sha512-xY7c8LPyzvhvew0Fn+Ek3wBC9STZAuDI/Y5andCKi9AX6/jvfaX45PhsDX8oxgPL0YFp0Jhr8qWMbS/p9375Xg=="],
"undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="],
"universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="],
+2 -1
View File
@@ -9,7 +9,8 @@
"@changesets/changelog-github": "^0.7.0",
"@changesets/cli": "^2.31.0",
"bun-types": "^1.3.14",
"typescript": "^5.8.0"
"typescript": "^5.8.0",
"ulidx": "^2.4.1"
},
"scripts": {
"build": "tsc --build packages/json-cas packages/json-cas-fs",
+1 -2
View File
@@ -3,8 +3,7 @@
"version": "0.5.3",
"type": "module",
"bin": {
"json-cas": "./src/index.ts",
"ucas": "./src/index.ts"
"json-cas": "./src/index.ts"
},
"scripts": {
"test": "bun test",
-33
View File
@@ -1,33 +0,0 @@
import { describe, expect, test } from "bun:test";
import { resolve } from "node:path";
const pkgPath = resolve(import.meta.dir, "../package.json");
describe("ucas command alias", () => {
test("T1: ucas bin entry exists in package.json", async () => {
const pkg = await Bun.file(pkgPath).json();
expect(pkg.bin.ucas).toBe("./src/index.ts");
});
test("T2: json-cas bin entry is preserved in package.json", async () => {
const pkg = await Bun.file(pkgPath).json();
expect(pkg.bin["json-cas"]).toBe("./src/index.ts");
});
test("T3: ucas command is executable and shows help", async () => {
const entrypoint = resolve(import.meta.dir, "index.ts");
const proc = Bun.spawn(["bun", entrypoint, "--help"], {
stdout: "pipe",
stderr: "pipe",
});
const exitCode = await proc.exited;
const stdout = await new Response(proc.stdout).text();
expect(exitCode).toBe(0);
expect(stdout.length).toBeGreaterThan(0);
});
test("T4: both commands point to the same entrypoint", async () => {
const pkg = await Bun.file(pkgPath).json();
expect(pkg.bin.ucas).toBe(pkg.bin["json-cas"]);
});
});
+195 -2
View File
@@ -3,13 +3,18 @@
import { mkdirSync, readFileSync } from "node:fs";
import { homedir } from "node:os";
import { join, resolve } from "node:path";
import type { Hash, JSONSchema, Store } from "@uncaged/json-cas";
import type { Hash, JSONSchema, Store, VariableStore } from "@uncaged/json-cas";
import {
bootstrap,
CasNodeNotFoundError,
computeHash,
createVariableStore,
getSchema,
InvalidScopeError,
putSchema,
refs,
SchemaMismatchError,
VariableNotFoundError,
validate,
verify,
walk,
@@ -21,7 +26,7 @@ import { createFsStore } from "@uncaged/json-cas-fs";
type Flags = Record<string, string | boolean>;
/** Flags that consume the next token as their value. All others are boolean. */
const VALUE_FLAGS = new Set(["store", "format"]);
const VALUE_FLAGS = new Set(["store", "format", "scope", "value", "var-db"]);
function parseArgs(argv: string[]): { flags: Flags; positional: string[] } {
const flags: Flags = {};
@@ -57,6 +62,10 @@ const storePath =
typeof flags.store === "string" ? flags.store : defaultStorePath;
const compact = flags.json === true;
const defaultVarDbPath = join(storePath, "variables.db");
const varDbPath =
typeof flags["var-db"] === "string" ? flags["var-db"] : defaultVarDbPath;
// ---- Helpers ----
function out(data: unknown): void {
@@ -80,6 +89,52 @@ function openStore(): Store {
return createFsStore(resolve(storePath));
}
function openVarStore(): VariableStore {
const store = openStore();
mkdirSync(resolve(storePath), { recursive: true });
return createVariableStore(resolve(varDbPath), store);
}
/**
* Get the Variable schema's CAS hash
* This is the type hash used in JSON envelopes
*/
async function getVariableSchemaHash(): Promise<Hash> {
const store = openStore();
// Define the Variable JSON Schema (simple version for envelope)
const variableSchema: JSONSchema = {
title: "Variable",
type: "object",
properties: {
id: { type: "string" },
scope: { type: "string" },
value: { type: "string" },
schema: { type: "string" },
created: { type: "number" },
updated: { type: "number" },
},
required: ["id", "scope", "value", "schema", "created", "updated"],
};
// Compute hash or retrieve from store
const hash = await putSchema(store, variableSchema);
return hash;
}
/**
* Wrap Variable output in JSON envelope
*/
async function wrapVariableEnvelope(
variable: unknown,
): Promise<{ type: Hash; value: unknown }> {
const typeHash = await getVariableSchemaHash();
return {
type: typeHash,
value: variable,
};
}
// ---- Commands ----
async function cmdInit(): Promise<void> {
@@ -250,6 +305,114 @@ async function cmdCat(args: string[]): Promise<void> {
}
}
async function cmdVarCreate(_args: string[]): Promise<void> {
const scope = flags.scope as string | undefined;
const value = flags.value as string | undefined;
if (!scope) die("Usage: json-cas var create --scope <scope> --value <hash>");
if (!value) die("Usage: json-cas var create --scope <scope> --value <hash>");
const varStore = openVarStore();
try {
const variable = varStore.create(scope, value);
const envelope = await wrapVariableEnvelope(variable);
out(envelope);
} catch (e) {
if (e instanceof InvalidScopeError || e instanceof CasNodeNotFoundError) {
die(`Error: ${e.message}`);
}
throw e;
} finally {
varStore.close();
}
}
async function cmdVarGet(args: string[]): Promise<void> {
const id = args[0];
if (!id) die("Usage: json-cas var get <id>");
const varStore = openVarStore();
try {
const variable = varStore.get(id);
if (variable === null) {
die(`Error: Variable not found: ${id}`);
}
const envelope = await wrapVariableEnvelope(variable);
out(envelope);
} finally {
varStore.close();
}
}
async function cmdVarUpdate(args: string[]): Promise<void> {
const id = args[0];
const value = args[1];
if (!id || !value) {
die("Usage: json-cas var update <id> <hash>");
}
const varStore = openVarStore();
try {
const variable = varStore.update(id, value);
const envelope = await wrapVariableEnvelope(variable);
out(envelope);
} catch (e) {
if (
e instanceof VariableNotFoundError ||
e instanceof SchemaMismatchError ||
e instanceof CasNodeNotFoundError
) {
die(`Error: ${e.message}`);
}
throw e;
} finally {
varStore.close();
}
}
async function cmdVarDelete(args: string[]): Promise<void> {
const id = args[0];
if (!id) die("Usage: json-cas var delete <id>");
const varStore = openVarStore();
try {
const variable = varStore.delete(id);
const envelope = await wrapVariableEnvelope(variable);
out(envelope);
} catch (e) {
if (e instanceof VariableNotFoundError) {
die(`Error: ${e.message}`);
}
throw e;
} finally {
varStore.close();
}
}
async function cmdVarList(_args: string[]): Promise<void> {
const scope = (flags.scope as string | undefined) ?? "";
const varStore = openVarStore();
try {
const variables = varStore.list({ scope });
const envelope = await wrapVariableEnvelope(variables);
out(envelope);
} catch (e) {
if (e instanceof InvalidScopeError) {
die(`Error: ${e.message}`);
}
throw e;
} finally {
varStore.close();
}
}
function printUsage(): void {
console.log(`\
Usage: json-cas [--store <path>] [--json] <command> [args]
@@ -269,9 +432,15 @@ Commands:
walk <hash> [--format tree] Recursive traversal
hash <type-hash> <file.json> Compute hash without storing (dry run)
cat <hash> [--payload] Output node (--payload for payload only)
var create --scope <s> --value <h> Create a variable
var get <id> Get a variable by ID
var update <id> <hash> Update variable value
var delete <id> Delete a variable
var list [--scope <prefix>] List variables (optionally filter by scope prefix)
Flags:
--store <path> Store directory (default: ~/.uncaged/json-cas)
--var-db <path> Variable database path (default: <store>/variables.db)
--json Compact JSON output`);
}
@@ -346,6 +515,30 @@ switch (cmd) {
await cmdCat(rest);
break;
case "var": {
const [sub, ...subRest] = rest;
switch (sub) {
case "create":
await cmdVarCreate(subRest);
break;
case "get":
await cmdVarGet(subRest);
break;
case "update":
await cmdVarUpdate(subRest);
break;
case "delete":
await cmdVarDelete(subRest);
break;
case "list":
await cmdVarList(subRest);
break;
default:
die(`Unknown var subcommand: ${sub ?? "(none)"}`);
}
break;
}
default:
die(`Unknown command: ${cmd}`);
}
+822
View File
@@ -0,0 +1,822 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { spawnSync } from "node:child_process";
import { unlinkSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
describe("CLI var commands", () => {
let storePath: string;
let varDbPath: string;
let cliPath: string;
let schemaHash: string;
let hashA: string;
let hashB: string;
let testCounter = 0;
beforeEach(async () => {
// Create temporary paths with counter to ensure uniqueness
testCounter++;
storePath = join(tmpdir(), `test-cli-store-${Date.now()}-${testCounter}`);
varDbPath = join(storePath, "variables.db");
cliPath = join(import.meta.dir, "index.ts");
// Initialize store and create test data
const initResult = spawnSync(
"bun",
[cliPath, "--store", storePath, "init"],
{
encoding: "utf-8",
},
);
expect(initResult.status).toBe(0);
// Create a schema
const schemaFile = join(tmpdir(), `schema-${Date.now()}.json`);
await Bun.write(
schemaFile,
JSON.stringify({
type: "object",
properties: { name: { type: "string" } },
}),
);
const schemaPutResult = spawnSync(
"bun",
[cliPath, "--store", storePath, "schema", "put", schemaFile],
{ encoding: "utf-8" },
);
expect(schemaPutResult.status).toBe(0);
schemaHash = schemaPutResult.stdout.trim();
// Create test CAS nodes
const dataFileA = join(tmpdir(), `data-a-${Date.now()}.json`);
await Bun.write(dataFileA, JSON.stringify({ name: "hello" }));
const putResultA = spawnSync(
"bun",
[cliPath, "--store", storePath, "put", schemaHash, dataFileA],
{ encoding: "utf-8" },
);
expect(putResultA.status).toBe(0);
hashA = putResultA.stdout.trim();
const dataFileB = join(tmpdir(), `data-b-${Date.now()}.json`);
await Bun.write(dataFileB, JSON.stringify({ name: "world" }));
const putResultB = spawnSync(
"bun",
[cliPath, "--store", storePath, "put", schemaHash, dataFileB],
{ encoding: "utf-8" },
);
expect(putResultB.status).toBe(0);
hashB = putResultB.stdout.trim();
});
afterEach(() => {
// Cleanup
try {
unlinkSync(varDbPath);
} catch {
// Ignore
}
});
describe("Test Group 1: Variable Creation", () => {
test("1.1: Create variable with valid scope", () => {
const result = spawnSync(
"bun",
[
cliPath,
"--store",
storePath,
"var",
"create",
"--scope",
"uwf/thread/",
"--value",
hashA,
],
{ encoding: "utf-8" },
);
expect(result.status).toBe(0);
const output = JSON.parse(result.stdout);
// Expect envelope format
expect(output.type).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
expect(output.value).toBeDefined();
// Check the actual variable in the value field
const variable = output.value;
expect(variable.id).toMatch(/^[0-9A-HJKMNP-TV-Z]{26}$/);
expect(variable.scope).toBe("uwf/thread/");
expect(variable.value).toBe(hashA);
expect(variable.schema).toBe(schemaHash);
expect(variable.created).toBeGreaterThan(Date.now() - 5000);
expect(variable.updated).toBe(variable.created);
});
test("1.2: Create variable fails with scope not ending in /", () => {
const result = spawnSync(
"bun",
[
cliPath,
"--store",
storePath,
"var",
"create",
"--scope",
"uwf/thread",
"--value",
hashA,
],
{ encoding: "utf-8" },
);
expect(result.status).not.toBe(0);
expect(result.stderr).toContain("scope must end with /");
});
test("1.3: Create variable fails with non-existent CAS node", () => {
const fakeHash = "FAKEHASH00000";
const result = spawnSync(
"bun",
[
cliPath,
"--store",
storePath,
"var",
"create",
"--scope",
"uwf/",
"--value",
fakeHash,
],
{ encoding: "utf-8" },
);
expect(result.status).not.toBe(0);
expect(result.stderr).toContain("CAS node not found");
});
});
describe("Test Group 2: Variable Retrieval", () => {
test("2.1: Get existing variable", () => {
// Create a variable first
const createResult = spawnSync(
"bun",
[
cliPath,
"--store",
storePath,
"var",
"create",
"--scope",
"uwf/thread/",
"--value",
hashA,
],
{ encoding: "utf-8" },
);
const created = JSON.parse(createResult.stdout).value;
// Get the variable
const result = spawnSync(
"bun",
[cliPath, "--store", storePath, "var", "get", created.id],
{ encoding: "utf-8" },
);
expect(result.status).toBe(0);
const output = JSON.parse(result.stdout);
// Expect envelope format
expect(output.type).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
expect(output.value).toBeDefined();
// Check the actual variable in the value field
const variable = output.value;
expect(variable.id).toBe(created.id);
expect(variable.scope).toBe("uwf/thread/");
expect(variable.value).toBe(hashA);
expect(variable.schema).toBe(schemaHash);
});
test("2.2: Get non-existent variable", () => {
const fakeId = "01ARZ3NDEKTSV4RRFFQ69G5FAV";
const result = spawnSync(
"bun",
[cliPath, "--store", storePath, "var", "get", fakeId],
{ encoding: "utf-8" },
);
expect(result.status).not.toBe(0);
expect(result.stderr).toContain("Variable not found");
});
});
describe("Test Group 3: Variable Update (Schema Consistent)", () => {
test("3.1: Update variable with matching schema", async () => {
// Create a variable
const createResult = spawnSync(
"bun",
[
cliPath,
"--store",
storePath,
"var",
"create",
"--scope",
"uwf/thread/",
"--value",
hashA,
],
{ encoding: "utf-8" },
);
const created = JSON.parse(createResult.stdout).value;
// Wait a bit to ensure different timestamp
await new Promise((resolve) => setTimeout(resolve, 10));
// Update the variable
const result = spawnSync(
"bun",
[cliPath, "--store", storePath, "var", "update", created.id, hashB],
{ encoding: "utf-8" },
);
expect(result.status).toBe(0);
const output = JSON.parse(result.stdout);
// Expect envelope format
expect(output.type).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
expect(output.value).toBeDefined();
// Check the actual variable in the value field
const variable = output.value;
expect(variable.id).toBe(created.id);
expect(variable.value).toBe(hashB);
expect(variable.schema).toBe(schemaHash);
expect(variable.updated).toBeGreaterThan(created.created);
});
});
describe("Test Group 4: Variable Update (Schema Mismatch)", () => {
test("4.1: Update variable fails with schema mismatch", async () => {
// Create another schema
const schema2File = join(tmpdir(), `schema2-${Date.now()}.json`);
await Bun.write(
schema2File,
JSON.stringify({
type: "object",
properties: { count: { type: "number" } },
}),
);
const schema2PutResult = spawnSync(
"bun",
[cliPath, "--store", storePath, "schema", "put", schema2File],
{ encoding: "utf-8" },
);
const schemaHash2 = schema2PutResult.stdout.trim();
// Create a node with the second schema
const dataFileC = join(tmpdir(), `data-c-${Date.now()}.json`);
await Bun.write(dataFileC, JSON.stringify({ count: 42 }));
const putResultC = spawnSync(
"bun",
[cliPath, "--store", storePath, "put", schemaHash2, dataFileC],
{ encoding: "utf-8" },
);
const hashC = putResultC.stdout.trim();
// Create a variable with first schema
const createResult = spawnSync(
"bun",
[
cliPath,
"--store",
storePath,
"var",
"create",
"--scope",
"uwf/thread/",
"--value",
hashA,
],
{ encoding: "utf-8" },
);
const created = JSON.parse(createResult.stdout).value;
// Try to update with different schema
const result = spawnSync(
"bun",
[cliPath, "--store", storePath, "var", "update", created.id, hashC],
{ encoding: "utf-8" },
);
expect(result.status).not.toBe(0);
expect(result.stderr.toLowerCase()).toContain("schema mismatch");
});
});
describe("Test Group 5: Variable Deletion", () => {
test("5.1: Delete existing variable", () => {
// Create a variable
const createResult = spawnSync(
"bun",
[
cliPath,
"--store",
storePath,
"var",
"create",
"--scope",
"uwf/thread/",
"--value",
hashA,
],
{ encoding: "utf-8" },
);
const created = JSON.parse(createResult.stdout).value;
// Delete the variable
const result = spawnSync(
"bun",
[cliPath, "--store", storePath, "var", "delete", created.id],
{ encoding: "utf-8" },
);
expect(result.status).toBe(0);
const output = JSON.parse(result.stdout);
// Expect envelope format
expect(output.type).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
expect(output.value).toBeDefined();
// Check the actual variable in the value field
const variable = output.value;
expect(variable.id).toBe(created.id);
// Verify it's deleted
const getResult = spawnSync(
"bun",
[cliPath, "--store", storePath, "var", "get", created.id],
{ encoding: "utf-8" },
);
expect(getResult.status).not.toBe(0);
});
test("5.3: Delete non-existent variable", () => {
const fakeId = "01ARZ3NDEKTSV4RRFFQ69G5FAV";
const result = spawnSync(
"bun",
[cliPath, "--store", storePath, "var", "delete", fakeId],
{ encoding: "utf-8" },
);
expect(result.status).not.toBe(0);
expect(result.stderr).toContain("Variable not found");
});
});
describe("Test Group 6: Variable Listing", () => {
test("6.1: List variables with scope prefix", () => {
// Create variables with different scopes
const createResult1 = spawnSync(
"bun",
[
cliPath,
"--store",
storePath,
"var",
"create",
"--scope",
"uwf/thread/",
"--value",
hashA,
],
{ encoding: "utf-8" },
);
expect(createResult1.status).toBe(0);
const var1 = JSON.parse(createResult1.stdout).value;
const createResult2 = spawnSync(
"bun",
[
cliPath,
"--store",
storePath,
"var",
"create",
"--scope",
"uwf/thread/",
"--value",
hashB,
],
{ encoding: "utf-8" },
);
expect(createResult2.status).toBe(0);
const var2 = JSON.parse(createResult2.stdout).value;
const createResult3 = spawnSync(
"bun",
[
cliPath,
"--store",
storePath,
"var",
"create",
"--scope",
"uwf/agent/",
"--value",
hashA,
],
{ encoding: "utf-8" },
);
expect(createResult3.status).toBe(0);
const var3 = JSON.parse(createResult3.stdout).value;
const createResult4 = spawnSync(
"bun",
[
cliPath,
"--store",
storePath,
"var",
"create",
"--scope",
"app/config/",
"--value",
hashA,
],
{ encoding: "utf-8" },
);
expect(createResult4.status).toBe(0);
// List all variables with uwf/ prefix
const listResult = spawnSync(
"bun",
[cliPath, "--store", storePath, "var", "list", "--scope", "uwf/"],
{ encoding: "utf-8" },
);
expect(listResult.status).toBe(0);
const output = JSON.parse(listResult.stdout);
// Expect envelope format
expect(output.type).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
expect(output.value).toBeDefined();
expect(Array.isArray(output.value)).toBe(true);
// Check the actual variables in the value field
const variables = output.value;
expect(variables).toHaveLength(3);
expect(
variables.every((v: { scope: string }) => v.scope.startsWith("uwf/")),
).toBe(true);
// Verify ordering by created timestamp
expect(variables[0].id).toBe(var1.id);
expect(variables[1].id).toBe(var2.id);
expect(variables[2].id).toBe(var3.id);
});
test("6.2: List all variables when no scope specified", () => {
// Create variables with different scopes
const createResult1 = spawnSync(
"bun",
[
cliPath,
"--store",
storePath,
"var",
"create",
"--scope",
"uwf/thread/",
"--value",
hashA,
],
{ encoding: "utf-8" },
);
expect(createResult1.status).toBe(0);
const createResult2 = spawnSync(
"bun",
[
cliPath,
"--store",
storePath,
"var",
"create",
"--scope",
"app/config/",
"--value",
hashB,
],
{ encoding: "utf-8" },
);
expect(createResult2.status).toBe(0);
// List all variables without scope filter
const listResult = spawnSync(
"bun",
[cliPath, "--store", storePath, "var", "list"],
{ encoding: "utf-8" },
);
expect(listResult.status).toBe(0);
const output = JSON.parse(listResult.stdout);
// Expect envelope format
expect(output.type).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
expect(output.value).toBeDefined();
expect(Array.isArray(output.value)).toBe(true);
const variables = output.value;
expect(variables).toHaveLength(2);
});
test("6.3: List returns empty array when no matches", () => {
// Create a variable
const createResult = spawnSync(
"bun",
[
cliPath,
"--store",
storePath,
"var",
"create",
"--scope",
"uwf/thread/",
"--value",
hashA,
],
{ encoding: "utf-8" },
);
expect(createResult.status).toBe(0);
// List with non-matching scope
const listResult = spawnSync(
"bun",
[
cliPath,
"--store",
storePath,
"var",
"list",
"--scope",
"nonexistent/",
],
{ encoding: "utf-8" },
);
expect(listResult.status).toBe(0);
const output = JSON.parse(listResult.stdout);
expect(output.type).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
expect(output.value).toBeDefined();
expect(Array.isArray(output.value)).toBe(true);
expect(output.value).toHaveLength(0);
});
test("6.4: List fails with invalid scope format", () => {
const listResult = spawnSync(
"bun",
[cliPath, "--store", storePath, "var", "list", "--scope", "uwf"],
{ encoding: "utf-8" },
);
expect(listResult.status).not.toBe(0);
expect(listResult.stderr).toContain("scope must end with /");
});
});
describe("Test Group 7: Integration Tests", () => {
test("7.1: Full lifecycle workflow", async () => {
// Create variable
const createResult = spawnSync(
"bun",
[
cliPath,
"--store",
storePath,
"var",
"create",
"--scope",
"uwf/thread/",
"--value",
hashA,
],
{ encoding: "utf-8" },
);
expect(createResult.status).toBe(0);
const var1 = JSON.parse(createResult.stdout).value;
expect(var1.value).toBe(hashA);
// Get variable
const getResult1 = spawnSync(
"bun",
[cliPath, "--store", storePath, "var", "get", var1.id],
{ encoding: "utf-8" },
);
expect(getResult1.status).toBe(0);
const retrieved1 = JSON.parse(getResult1.stdout).value;
expect(retrieved1.value).toBe(hashA);
// Wait to ensure different timestamp
await new Promise((resolve) => setTimeout(resolve, 10));
// Update variable
const updateResult = spawnSync(
"bun",
[cliPath, "--store", storePath, "var", "update", var1.id, hashB],
{ encoding: "utf-8" },
);
expect(updateResult.status).toBe(0);
const updated = JSON.parse(updateResult.stdout).value;
expect(updated.value).toBe(hashB);
// Get updated variable
const getResult2 = spawnSync(
"bun",
[cliPath, "--store", storePath, "var", "get", var1.id],
{ encoding: "utf-8" },
);
expect(getResult2.status).toBe(0);
const retrieved2 = JSON.parse(getResult2.stdout).value;
expect(retrieved2.value).toBe(hashB);
// Delete variable
const deleteResult = spawnSync(
"bun",
[cliPath, "--store", storePath, "var", "delete", var1.id],
{ encoding: "utf-8" },
);
expect(deleteResult.status).toBe(0);
// Verify deletion
const getResult3 = spawnSync(
"bun",
[cliPath, "--store", storePath, "var", "get", var1.id],
{ encoding: "utf-8" },
);
expect(getResult3.status).not.toBe(0);
});
test("7.2: Multiple variables with same scope", () => {
// Create two variables
const createResult1 = spawnSync(
"bun",
[
cliPath,
"--store",
storePath,
"var",
"create",
"--scope",
"uwf/thread/",
"--value",
hashA,
],
{ encoding: "utf-8" },
);
const var1 = JSON.parse(createResult1.stdout).value;
const createResult2 = spawnSync(
"bun",
[
cliPath,
"--store",
storePath,
"var",
"create",
"--scope",
"uwf/thread/",
"--value",
hashB,
],
{ encoding: "utf-8" },
);
const var2 = JSON.parse(createResult2.stdout).value;
// Verify independence
expect(var1.id).not.toBe(var2.id);
const getResult1 = spawnSync(
"bun",
[cliPath, "--store", storePath, "var", "get", var1.id],
{ encoding: "utf-8" },
);
const retrieved1 = JSON.parse(getResult1.stdout).value;
expect(retrieved1.value).toBe(hashA);
const getResult2 = spawnSync(
"bun",
[cliPath, "--store", storePath, "var", "get", var2.id],
{ encoding: "utf-8" },
);
const retrieved2 = JSON.parse(getResult2.stdout).value;
expect(retrieved2.value).toBe(hashB);
// Delete var1, verify var2 still exists
spawnSync("bun", [
cliPath,
"--store",
storePath,
"var",
"delete",
var1.id,
]);
const getResult2Final = spawnSync(
"bun",
[cliPath, "--store", storePath, "var", "get", var2.id],
{ encoding: "utf-8" },
);
expect(getResult2Final.status).toBe(0);
const retrieved2Final = JSON.parse(getResult2Final.stdout).value;
expect(retrieved2Final.value).toBe(hashB);
});
test("7.3: Variables with hierarchical scopes", () => {
const createResult1 = spawnSync(
"bun",
[
cliPath,
"--store",
storePath,
"var",
"create",
"--scope",
"uwf/",
"--value",
hashA,
],
{ encoding: "utf-8" },
);
const var1 = JSON.parse(createResult1.stdout).value;
const createResult2 = spawnSync(
"bun",
[
cliPath,
"--store",
storePath,
"var",
"create",
"--scope",
"uwf/thread/",
"--value",
hashA,
],
{ encoding: "utf-8" },
);
const var2 = JSON.parse(createResult2.stdout).value;
const createResult3 = spawnSync(
"bun",
[
cliPath,
"--store",
storePath,
"var",
"create",
"--scope",
"uwf/workflow/",
"--value",
hashA,
],
{ encoding: "utf-8" },
);
const var3 = JSON.parse(createResult3.stdout).value;
expect(var1.scope).toBe("uwf/");
expect(var2.scope).toBe("uwf/thread/");
expect(var3.scope).toBe("uwf/workflow/");
// Verify all exist
const get1 = spawnSync(
"bun",
[cliPath, "--store", storePath, "var", "get", var1.id],
{ encoding: "utf-8" },
);
expect(get1.status).toBe(0);
const get2 = spawnSync(
"bun",
[cliPath, "--store", storePath, "var", "get", var2.id],
{ encoding: "utf-8" },
);
expect(get2.status).toBe(0);
const get3 = spawnSync(
"bun",
[cliPath, "--store", storePath, "var", "get", var3.id],
{ encoding: "utf-8" },
);
expect(get3.status).toBe(0);
});
});
});
+9
View File
@@ -14,4 +14,13 @@ export {
} from "./schema.js";
export { createMemoryStore } from "./store.js";
export type { CasNode, Hash, Store } from "./types.js";
export type { Variable, VariableId } from "./variable.js";
export {
CasNodeNotFoundError,
createVariableStore,
InvalidScopeError,
SchemaMismatchError,
VariableNotFoundError,
VariableStore,
} from "./variable-store.js";
export { verify } from "./verify.js";
@@ -0,0 +1,403 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { unlinkSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createMemoryStore } from "./store.js";
import type { Store } from "./types.js";
import {
CasNodeNotFoundError,
InvalidScopeError,
SchemaMismatchError,
VariableNotFoundError,
VariableStore,
} from "./variable-store.js";
describe("VariableStore", () => {
let store: Store;
let varStore: VariableStore;
let dbPath: string;
let schemaA: string;
let schemaB: string;
let hashA: string;
let hashB: string;
let hashC: string;
beforeEach(async () => {
// Create a temporary database
dbPath = join(tmpdir(), `test-variables-${Date.now()}.db`);
// Create a CAS store with test data
store = createMemoryStore();
// Create two different schemas
schemaA = await store.put("BOOTSTRAPHASH", {
type: "object",
properties: { name: { type: "string" } },
});
schemaB = await store.put("BOOTSTRAPHASH", {
type: "object",
properties: { count: { type: "number" } },
});
// Create CAS nodes with different schemas
hashA = await store.put(schemaA, { name: "hello" });
hashB = await store.put(schemaA, { name: "world" });
hashC = await store.put(schemaB, { count: 42 });
// Create variable store
varStore = new VariableStore(dbPath, store);
});
afterEach(() => {
varStore.close();
try {
unlinkSync(dbPath);
} catch {
// Ignore cleanup errors
}
});
describe("Test Group 1: Variable Creation", () => {
test("1.1: Create variable with valid scope", () => {
const variable = varStore.create("uwf/thread/", hashA);
expect(variable.id).toMatch(/^[0-9A-HJKMNP-TV-Z]{26}$/);
expect(variable.scope).toBe("uwf/thread/");
expect(variable.value).toBe(hashA);
expect(variable.schema).toBe(schemaA);
expect(variable.created).toBeGreaterThan(Date.now() - 5000);
expect(variable.created).toBeLessThanOrEqual(Date.now());
expect(variable.updated).toBe(variable.created);
// Verify persistence
const retrieved = varStore.get(variable.id);
expect(retrieved).not.toBeNull();
expect(retrieved?.id).toBe(variable.id);
expect(retrieved?.scope).toBe(variable.scope);
expect(retrieved?.value).toBe(variable.value);
});
test("1.2: Create variable fails with scope not ending in /", () => {
expect(() => varStore.create("uwf/thread", hashA)).toThrow(
InvalidScopeError,
);
expect(() => varStore.create("uwf/thread", hashA)).toThrow(
"scope must end with /",
);
});
test("1.3: Create variable fails with non-existent CAS node", () => {
const fakeHash = "FAKEHASH00000";
expect(() => varStore.create("uwf/", fakeHash)).toThrow(
CasNodeNotFoundError,
);
expect(() => varStore.create("uwf/", fakeHash)).toThrow(
`CAS node not found: ${fakeHash}`,
);
});
});
describe("Test Group 2: Variable Retrieval", () => {
test("2.1: Get existing variable", () => {
const created = varStore.create("uwf/thread/", hashA);
const retrieved = varStore.get(created.id);
expect(retrieved).not.toBeNull();
expect(retrieved?.id).toBe(created.id);
expect(retrieved?.scope).toBe("uwf/thread/");
expect(retrieved?.value).toBe(hashA);
expect(retrieved?.schema).toBe(schemaA);
expect(retrieved?.created).toBe(created.created);
expect(retrieved?.updated).toBe(created.updated);
});
test("2.2: Get non-existent variable", () => {
const fakeId = "01ARZ3NDEKTSV4RRFFQ69G5FAV";
const result = varStore.get(fakeId);
expect(result).toBeNull();
});
});
describe("Test Group 3: Variable Update (Schema Consistent)", () => {
test("3.1: Update variable with matching schema", async () => {
const created = varStore.create("uwf/thread/", hashA);
const t1 = created.created;
// Wait a bit to ensure different timestamp
await new Promise((resolve) => setTimeout(resolve, 10));
const updated = varStore.update(created.id, hashB);
expect(updated.id).toBe(created.id);
expect(updated.scope).toBe("uwf/thread/");
expect(updated.value).toBe(hashB);
expect(updated.schema).toBe(schemaA);
expect(updated.created).toBe(t1);
expect(updated.updated).toBeGreaterThan(t1);
expect(updated.updated).toBeGreaterThan(Date.now() - 5000);
expect(updated.updated).toBeLessThanOrEqual(Date.now());
// Verify persistence
const retrieved = varStore.get(created.id);
expect(retrieved?.value).toBe(hashB);
expect(retrieved?.updated).toBe(updated.updated);
});
test("3.2: Update variable to same value is idempotent", () => {
const created = varStore.create("uwf/thread/", hashA);
const updated = varStore.update(created.id, hashA);
expect(updated.value).toBe(hashA);
expect(updated.schema).toBe(schemaA);
// Updated timestamp may change, this is implementation-defined
});
});
describe("Test Group 4: Variable Update (Schema Mismatch)", () => {
test("4.1: Update variable fails with schema mismatch", () => {
const created = varStore.create("uwf/thread/", hashA);
expect(() => varStore.update(created.id, hashC)).toThrow(
SchemaMismatchError,
);
const error = (() => {
try {
varStore.update(created.id, hashC);
return null;
} catch (e) {
return e as SchemaMismatchError;
}
})();
expect(error).not.toBeNull();
expect(error?.expected).toBe(schemaA);
expect(error?.actual).toBe(schemaB);
expect(error?.message.toLowerCase()).toContain("schema mismatch");
// Verify variable is unchanged
const retrieved = varStore.get(created.id);
expect(retrieved?.value).toBe(hashA);
});
test("4.2: Update variable fails with non-existent CAS node", () => {
const created = varStore.create("uwf/thread/", hashA);
const fakeHash = "FAKEHASH00000";
expect(() => varStore.update(created.id, fakeHash)).toThrow(
CasNodeNotFoundError,
);
});
test("4.3: Update non-existent variable", () => {
const fakeId = "01ARZ3NDEKTSV4RRFFQ69G5FAV";
expect(() => varStore.update(fakeId, hashA)).toThrow(
VariableNotFoundError,
);
expect(() => varStore.update(fakeId, hashA)).toThrow(
`Variable not found: ${fakeId}`,
);
});
});
describe("Test Group 5: Variable Deletion", () => {
test("5.1: Delete existing variable", () => {
const created = varStore.create("uwf/thread/", hashA);
const deleted = varStore.delete(created.id);
expect(deleted.id).toBe(created.id);
expect(deleted.scope).toBe(created.scope);
expect(deleted.value).toBe(created.value);
expect(deleted.schema).toBe(created.schema);
// Verify it's removed from database
const retrieved = varStore.get(created.id);
expect(retrieved).toBeNull();
});
test("5.2: Get deleted variable", () => {
const created = varStore.create("uwf/thread/", hashA);
varStore.delete(created.id);
const retrieved = varStore.get(created.id);
expect(retrieved).toBeNull();
});
test("5.3: Delete non-existent variable", () => {
const fakeId = "01ARZ3NDEKTSV4RRFFQ69G5FAV";
expect(() => varStore.delete(fakeId)).toThrow(VariableNotFoundError);
expect(() => varStore.delete(fakeId)).toThrow(
`Variable not found: ${fakeId}`,
);
});
});
describe("Test Group 6: Variable Listing", () => {
test("6.1: list() returns all variables with matching scope prefix", async () => {
const var1 = varStore.create("uwf/thread/", hashA);
const var2 = varStore.create("uwf/thread/", hashB);
const var3 = varStore.create("uwf/agent/", hashA);
varStore.create("app/config/", hashA);
// Wait a bit to ensure different timestamps
await new Promise((resolve) => setTimeout(resolve, 10));
const results = varStore.list({ scope: "uwf/" });
expect(results).toHaveLength(3);
expect(results.every((v) => v.scope.startsWith("uwf/"))).toBe(true);
// Verify ordering by created timestamp
expect(results[0]?.id).toBe(var1.id);
expect(results[1]?.id).toBe(var2.id);
expect(results[2]?.id).toBe(var3.id);
});
test("6.2: list() returns empty array when no matches", () => {
varStore.create("uwf/thread/", hashA);
const results = varStore.list({ scope: "nonexistent/" });
expect(results).toHaveLength(0);
expect(Array.isArray(results)).toBe(true);
});
test("6.3: list() returns all variables when scope is empty string", () => {
const var1 = varStore.create("uwf/thread/", hashA);
const var2 = varStore.create("app/config/", hashB);
const var3 = varStore.create("test/", hashC);
const results = varStore.list({ scope: "" });
expect(results).toHaveLength(3);
expect(results.map((v) => v.id)).toContain(var1.id);
expect(results.map((v) => v.id)).toContain(var2.id);
expect(results.map((v) => v.id)).toContain(var3.id);
});
test("6.4: list() validates scope format (must end with /)", () => {
varStore.create("uwf/thread/", hashA);
expect(() => varStore.list({ scope: "uwf" })).toThrow(InvalidScopeError);
expect(() => varStore.list({ scope: "uwf" })).toThrow(
"scope must end with /",
);
});
test("6.5: list() returns exact scope match and sub-scopes", () => {
varStore.create("uwf/thread/", hashA);
varStore.create("uwf/thread/active/", hashB);
const results = varStore.list({ scope: "uwf/thread/" });
expect(results).toHaveLength(2);
expect(results[0]?.scope).toBe("uwf/thread/");
expect(results[1]?.scope).toBe("uwf/thread/active/");
});
test("6.6: list() result ordering is deterministic", async () => {
// Create 5 variables with the same scope prefix
const var1 = varStore.create("test/", hashA);
await new Promise((resolve) => setTimeout(resolve, 2));
const var2 = varStore.create("test/", hashB);
await new Promise((resolve) => setTimeout(resolve, 2));
const var3 = varStore.create("test/", hashA);
await new Promise((resolve) => setTimeout(resolve, 2));
const var4 = varStore.create("test/", hashB);
await new Promise((resolve) => setTimeout(resolve, 2));
const var5 = varStore.create("test/", hashA);
// Call list multiple times
const results1 = varStore.list({ scope: "test/" });
const results2 = varStore.list({ scope: "test/" });
const results3 = varStore.list({ scope: "test/" });
// All results should be identical
expect(results1.map((v) => v.id)).toEqual(results2.map((v) => v.id));
expect(results2.map((v) => v.id)).toEqual(results3.map((v) => v.id));
// Verify ordering by created timestamp (oldest first)
expect(results1[0]?.id).toBe(var1.id);
expect(results1[1]?.id).toBe(var2.id);
expect(results1[2]?.id).toBe(var3.id);
expect(results1[3]?.id).toBe(var4.id);
expect(results1[4]?.id).toBe(var5.id);
});
});
describe("Test Group 7: Integration Tests", () => {
test("7.1: Full lifecycle workflow", async () => {
// Create variable
const var1 = varStore.create("uwf/thread/", hashA);
expect(var1.value).toBe(hashA);
// Get variable
const retrieved1 = varStore.get(var1.id);
expect(retrieved1?.value).toBe(hashA);
// Wait to ensure different timestamp
await new Promise((resolve) => setTimeout(resolve, 10));
// Update variable
const updated = varStore.update(var1.id, hashB);
expect(updated.value).toBe(hashB);
expect(updated.updated).toBeGreaterThan(var1.created);
// Get updated variable
const retrieved2 = varStore.get(var1.id);
expect(retrieved2?.value).toBe(hashB);
// Delete variable
const deleted = varStore.delete(var1.id);
expect(deleted.value).toBe(hashB);
// Verify deletion
const retrieved3 = varStore.get(var1.id);
expect(retrieved3).toBeNull();
});
test("7.2: Multiple variables with same scope", () => {
const var1 = varStore.create("uwf/thread/", hashA);
const var2 = varStore.create("uwf/thread/", hashB);
// Verify independence
expect(var1.id).not.toBe(var2.id);
const retrieved1 = varStore.get(var1.id);
const retrieved2 = varStore.get(var2.id);
expect(retrieved1?.value).toBe(hashA);
expect(retrieved2?.value).toBe(hashB);
// Update var1, verify var2 is unaffected
varStore.update(var1.id, hashB);
const retrieved2After = varStore.get(var2.id);
expect(retrieved2After?.value).toBe(hashB);
expect(retrieved2After?.updated).toBe(var2.updated);
// Delete var1, verify var2 still exists
varStore.delete(var1.id);
const retrieved2Final = varStore.get(var2.id);
expect(retrieved2Final).not.toBeNull();
expect(retrieved2Final?.value).toBe(hashB);
});
test("7.3: Variables with hierarchical scopes", () => {
const var1 = varStore.create("uwf/", hashA);
const var2 = varStore.create("uwf/thread/", hashA);
const var3 = varStore.create("uwf/workflow/", hashA);
expect(var1.scope).toBe("uwf/");
expect(var2.scope).toBe("uwf/thread/");
expect(var3.scope).toBe("uwf/workflow/");
// All should exist independently
expect(varStore.get(var1.id)).not.toBeNull();
expect(varStore.get(var2.id)).not.toBeNull();
expect(varStore.get(var3.id)).not.toBeNull();
});
});
});
+256
View File
@@ -0,0 +1,256 @@
import { Database } from "bun:sqlite";
import { ulid } from "ulidx";
import type { Store } from "./types.js";
import type { Variable, VariableId } from "./variable.js";
/**
* Custom error types for variable operations
*/
export class VariableNotFoundError extends Error {
constructor(id: VariableId) {
super(`Variable not found: ${id}`);
this.name = "VariableNotFoundError";
}
}
export class SchemaMismatchError extends Error {
constructor(
public expected: string,
public actual: string,
) {
super(`Schema mismatch: expected ${expected}, got ${actual}`);
this.name = "SchemaMismatchError";
}
}
export class InvalidScopeError extends Error {
constructor(scope: string) {
super(`Invalid scope: scope must end with / (got: ${scope})`);
this.name = "InvalidScopeError";
}
}
export class CasNodeNotFoundError extends Error {
constructor(hash: string) {
super(`CAS node not found: ${hash}`);
this.name = "CasNodeNotFoundError";
}
}
/**
* Variable store with SQLite backend
*/
export class VariableStore {
private db: Database;
constructor(
dbPath: string,
private casStore: Store,
) {
this.db = new Database(dbPath, { create: true });
this.initDb();
}
private initDb(): void {
this.db.exec(`
CREATE TABLE IF NOT EXISTS variables (
id TEXT PRIMARY KEY,
scope TEXT NOT NULL,
value TEXT NOT NULL,
schema TEXT NOT NULL,
created INTEGER NOT NULL,
updated INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_var_scope ON variables(scope);
CREATE INDEX IF NOT EXISTS idx_var_value ON variables(value);
CREATE INDEX IF NOT EXISTS idx_var_schema ON variables(schema);
`);
}
/**
* Validate that scope ends with /
*/
private validateScope(scope: string): void {
if (!scope.endsWith("/")) {
throw new InvalidScopeError(scope);
}
}
/**
* 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;
}
/**
* Create a new variable
*/
create(scope: string, value: string): Variable {
this.validateScope(scope);
const schema = this.extractSchema(value);
const id = ulid();
const now = Date.now();
const stmt = this.db.prepare(`
INSERT INTO variables (id, scope, value, schema, created, updated)
VALUES (?, ?, ?, ?, ?, ?)
`);
stmt.run(id, scope, value, schema, now, now);
return {
id,
scope,
value,
schema,
created: now,
updated: now,
};
}
/**
* Get a variable by ID
*/
get(id: VariableId): Variable | null {
const stmt = this.db.prepare(`
SELECT id, scope, value, schema, created, updated
FROM variables
WHERE id = ?
`);
const row = stmt.get(id) as
| {
id: string;
scope: string;
value: string;
schema: string;
created: number;
updated: number;
}
| undefined
| null;
if (row === undefined || row === null) {
return null;
}
return {
id: row.id,
scope: row.scope,
value: row.value,
schema: row.schema,
created: row.created,
updated: row.updated,
};
}
/**
* Update a variable's value (with schema validation)
*/
update(id: VariableId, value: string): Variable {
const existing = this.get(id);
if (existing === null) {
throw new VariableNotFoundError(id);
}
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 id = ?
`);
stmt.run(value, now, id);
return {
...existing,
value,
updated: now,
};
}
/**
* Delete a variable
*/
delete(id: VariableId): Variable {
const existing = this.get(id);
if (existing === null) {
throw new VariableNotFoundError(id);
}
const stmt = this.db.prepare(`
DELETE FROM variables WHERE id = ?
`);
stmt.run(id);
return existing;
}
/**
* List variables matching a scope prefix
*/
list(options?: { scope?: string }): Variable[] {
const scope = options?.scope ?? "";
// Validate scope format (must end with / if non-empty)
if (scope !== "" && !scope.endsWith("/")) {
throw new InvalidScopeError(scope);
}
const stmt = this.db.prepare(`
SELECT id, scope, value, schema, created, updated
FROM variables
WHERE scope LIKE ? || '%'
ORDER BY created ASC
`);
const rows = stmt.all(scope) as Array<{
id: string;
scope: string;
value: string;
schema: string;
created: number;
updated: number;
}>;
return rows.map((row) => ({
id: row.id,
scope: row.scope,
value: row.value,
schema: row.schema,
created: row.created,
updated: row.updated,
}));
}
/**
* 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);
}
+18
View File
@@ -0,0 +1,18 @@
import type { Hash } from "./types.js";
/**
* ULID identifier (26-character Crockford Base32)
*/
export type VariableId = string;
/**
* Variable: mutable binding to an immutable CAS node
*/
export type Variable = {
id: VariableId;
scope: string; // hierarchical path, must end with /
value: Hash; // CAS node hash
schema: Hash; // extracted from value's CAS node.type
created: number; // epoch ms
updated: number; // epoch ms
};