Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cf716c5115 | |||
| 98dc91e848 | |||
| 064c9afa1e | |||
| 1ea058a7a6 |
@@ -10,11 +10,12 @@
|
|||||||
"@changesets/cli": "^2.31.0",
|
"@changesets/cli": "^2.31.0",
|
||||||
"bun-types": "^1.3.14",
|
"bun-types": "^1.3.14",
|
||||||
"typescript": "^5.8.0",
|
"typescript": "^5.8.0",
|
||||||
|
"ulidx": "^2.4.1",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"packages/cli-json-cas": {
|
"packages/cli-json-cas": {
|
||||||
"name": "@uncaged/cli-json-cas",
|
"name": "@uncaged/cli-json-cas",
|
||||||
"version": "0.5.0",
|
"version": "0.5.3",
|
||||||
"bin": {
|
"bin": {
|
||||||
"json-cas": "./src/index.ts",
|
"json-cas": "./src/index.ts",
|
||||||
},
|
},
|
||||||
@@ -25,7 +26,7 @@
|
|||||||
},
|
},
|
||||||
"packages/json-cas": {
|
"packages/json-cas": {
|
||||||
"name": "@uncaged/json-cas",
|
"name": "@uncaged/json-cas",
|
||||||
"version": "0.5.0",
|
"version": "0.5.3",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ajv": "^8.20.0",
|
"ajv": "^8.20.0",
|
||||||
"cborg": "^4.2.3",
|
"cborg": "^4.2.3",
|
||||||
@@ -34,19 +35,12 @@
|
|||||||
},
|
},
|
||||||
"packages/json-cas-fs": {
|
"packages/json-cas-fs": {
|
||||||
"name": "@uncaged/json-cas-fs",
|
"name": "@uncaged/json-cas-fs",
|
||||||
"version": "0.5.0",
|
"version": "0.5.3",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@uncaged/json-cas": "workspace:^",
|
"@uncaged/json-cas": "workspace:^",
|
||||||
"cborg": "^4.2.3",
|
"cborg": "^4.2.3",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"packages/json-cas-workflow": {
|
|
||||||
"name": "@uncaged/json-cas-workflow",
|
|
||||||
"version": "0.5.0",
|
|
||||||
"dependencies": {
|
|
||||||
"@uncaged/json-cas": "workspace:^",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
"packages": {
|
"packages": {
|
||||||
"@babel/runtime": ["@babel/runtime@7.29.2", "", {}, "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g=="],
|
"@babel/runtime": ["@babel/runtime@7.29.2", "", {}, "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g=="],
|
||||||
@@ -127,8 +121,6 @@
|
|||||||
|
|
||||||
"@uncaged/json-cas-fs": ["@uncaged/json-cas-fs@workspace:packages/json-cas-fs"],
|
"@uncaged/json-cas-fs": ["@uncaged/json-cas-fs@workspace:packages/json-cas-fs"],
|
||||||
|
|
||||||
"@uncaged/json-cas-workflow": ["@uncaged/json-cas-workflow@workspace:packages/json-cas-workflow"],
|
|
||||||
|
|
||||||
"ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="],
|
"ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="],
|
||||||
|
|
||||||
"ansi-colors": ["ansi-colors@4.1.3", "", {}, "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw=="],
|
"ansi-colors": ["ansi-colors@4.1.3", "", {}, "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw=="],
|
||||||
@@ -209,6 +201,8 @@
|
|||||||
|
|
||||||
"jsonfile": ["jsonfile@4.0.0", "", { "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg=="],
|
"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=="],
|
"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=="],
|
"lodash.startcase": ["lodash.startcase@4.4.0", "", {}, "sha512-+WKqsK294HMSc2jEbNgpHpd0JfIBhp7rEV4aqXWqFr6AlXov+SlcgB1Fv01y2kGe3Gc8nMW7VA0SrGuSkRfIEg=="],
|
||||||
@@ -291,6 +285,8 @@
|
|||||||
|
|
||||||
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
"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=="],
|
"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=="],
|
"universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="],
|
||||||
|
|||||||
+2
-1
@@ -9,7 +9,8 @@
|
|||||||
"@changesets/changelog-github": "^0.7.0",
|
"@changesets/changelog-github": "^0.7.0",
|
||||||
"@changesets/cli": "^2.31.0",
|
"@changesets/cli": "^2.31.0",
|
||||||
"bun-types": "^1.3.14",
|
"bun-types": "^1.3.14",
|
||||||
"typescript": "^5.8.0"
|
"typescript": "^5.8.0",
|
||||||
|
"ulidx": "^2.4.1"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc --build packages/json-cas packages/json-cas-fs",
|
"build": "tsc --build packages/json-cas packages/json-cas-fs",
|
||||||
|
|||||||
@@ -3,13 +3,18 @@
|
|||||||
import { mkdirSync, readFileSync } from "node:fs";
|
import { mkdirSync, readFileSync } from "node:fs";
|
||||||
import { homedir } from "node:os";
|
import { homedir } from "node:os";
|
||||||
import { join, resolve } from "node:path";
|
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 {
|
import {
|
||||||
bootstrap,
|
bootstrap,
|
||||||
|
CasNodeNotFoundError,
|
||||||
computeHash,
|
computeHash,
|
||||||
|
createVariableStore,
|
||||||
getSchema,
|
getSchema,
|
||||||
|
InvalidScopeError,
|
||||||
putSchema,
|
putSchema,
|
||||||
refs,
|
refs,
|
||||||
|
SchemaMismatchError,
|
||||||
|
VariableNotFoundError,
|
||||||
validate,
|
validate,
|
||||||
verify,
|
verify,
|
||||||
walk,
|
walk,
|
||||||
@@ -21,7 +26,7 @@ import { createFsStore } from "@uncaged/json-cas-fs";
|
|||||||
type Flags = Record<string, string | boolean>;
|
type Flags = Record<string, string | boolean>;
|
||||||
|
|
||||||
/** Flags that consume the next token as their value. All others are 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[] } {
|
function parseArgs(argv: string[]): { flags: Flags; positional: string[] } {
|
||||||
const flags: Flags = {};
|
const flags: Flags = {};
|
||||||
@@ -57,6 +62,10 @@ const storePath =
|
|||||||
typeof flags.store === "string" ? flags.store : defaultStorePath;
|
typeof flags.store === "string" ? flags.store : defaultStorePath;
|
||||||
const compact = flags.json === true;
|
const compact = flags.json === true;
|
||||||
|
|
||||||
|
const defaultVarDbPath = join(defaultStorePath, "variables.db");
|
||||||
|
const varDbPath =
|
||||||
|
typeof flags["var-db"] === "string" ? flags["var-db"] : defaultVarDbPath;
|
||||||
|
|
||||||
// ---- Helpers ----
|
// ---- Helpers ----
|
||||||
|
|
||||||
function out(data: unknown): void {
|
function out(data: unknown): void {
|
||||||
@@ -80,6 +89,12 @@ function openStore(): Store {
|
|||||||
return createFsStore(resolve(storePath));
|
return createFsStore(resolve(storePath));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openVarStore(): VariableStore {
|
||||||
|
const store = openStore();
|
||||||
|
mkdirSync(resolve(storePath), { recursive: true });
|
||||||
|
return createVariableStore(resolve(varDbPath), store);
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Commands ----
|
// ---- Commands ----
|
||||||
|
|
||||||
async function cmdInit(): Promise<void> {
|
async function cmdInit(): Promise<void> {
|
||||||
@@ -250,6 +265,91 @@ 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);
|
||||||
|
out(variable);
|
||||||
|
} 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}`);
|
||||||
|
}
|
||||||
|
out(variable);
|
||||||
|
} 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);
|
||||||
|
out(variable);
|
||||||
|
} 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);
|
||||||
|
out(variable);
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof VariableNotFoundError) {
|
||||||
|
die(`Error: ${e.message}`);
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
} finally {
|
||||||
|
varStore.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function printUsage(): void {
|
function printUsage(): void {
|
||||||
console.log(`\
|
console.log(`\
|
||||||
Usage: json-cas [--store <path>] [--json] <command> [args]
|
Usage: json-cas [--store <path>] [--json] <command> [args]
|
||||||
@@ -269,9 +369,14 @@ Commands:
|
|||||||
walk <hash> [--format tree] Recursive traversal
|
walk <hash> [--format tree] Recursive traversal
|
||||||
hash <type-hash> <file.json> Compute hash without storing (dry run)
|
hash <type-hash> <file.json> Compute hash without storing (dry run)
|
||||||
cat <hash> [--payload] Output node (--payload for payload only)
|
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
|
||||||
|
|
||||||
Flags:
|
Flags:
|
||||||
--store <path> Store directory (default: ~/.uncaged/json-cas)
|
--store <path> Store directory (default: ~/.uncaged/json-cas)
|
||||||
|
--var-db <path> Variable database path (default: <store>/variables.db)
|
||||||
--json Compact JSON output`);
|
--json Compact JSON output`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -346,6 +451,27 @@ switch (cmd) {
|
|||||||
await cmdCat(rest);
|
await cmdCat(rest);
|
||||||
break;
|
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;
|
||||||
|
default:
|
||||||
|
die(`Unknown var subcommand: ${sub ?? "(none)"}`);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
default:
|
default:
|
||||||
die(`Unknown command: ${cmd}`);
|
die(`Unknown command: ${cmd}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,584 @@
|
|||||||
|
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;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
// Create temporary paths
|
||||||
|
storePath = join(tmpdir(), `test-cli-store-${Date.now()}`);
|
||||||
|
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(output.id).toMatch(/^[0-9A-HJKMNP-TV-Z]{26}$/);
|
||||||
|
expect(output.scope).toBe("uwf/thread/");
|
||||||
|
expect(output.value).toBe(hashA);
|
||||||
|
expect(output.schema).toBe(schemaHash);
|
||||||
|
expect(output.created).toBeGreaterThan(Date.now() - 5000);
|
||||||
|
expect(output.updated).toBe(output.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);
|
||||||
|
|
||||||
|
// 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(output.id).toBe(created.id);
|
||||||
|
expect(output.scope).toBe("uwf/thread/");
|
||||||
|
expect(output.value).toBe(hashA);
|
||||||
|
expect(output.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);
|
||||||
|
|
||||||
|
// 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(output.id).toBe(created.id);
|
||||||
|
expect(output.value).toBe(hashB);
|
||||||
|
expect(output.schema).toBe(schemaHash);
|
||||||
|
expect(output.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);
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
|
||||||
|
// 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(output.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 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);
|
||||||
|
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);
|
||||||
|
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);
|
||||||
|
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);
|
||||||
|
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);
|
||||||
|
|
||||||
|
const createResult2 = spawnSync(
|
||||||
|
"bun",
|
||||||
|
[
|
||||||
|
cliPath,
|
||||||
|
"--store",
|
||||||
|
storePath,
|
||||||
|
"var",
|
||||||
|
"create",
|
||||||
|
"--scope",
|
||||||
|
"uwf/thread/",
|
||||||
|
"--value",
|
||||||
|
hashB,
|
||||||
|
],
|
||||||
|
{ encoding: "utf-8" },
|
||||||
|
);
|
||||||
|
const var2 = JSON.parse(createResult2.stdout);
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
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);
|
||||||
|
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);
|
||||||
|
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);
|
||||||
|
|
||||||
|
const createResult2 = spawnSync(
|
||||||
|
"bun",
|
||||||
|
[
|
||||||
|
cliPath,
|
||||||
|
"--store",
|
||||||
|
storePath,
|
||||||
|
"var",
|
||||||
|
"create",
|
||||||
|
"--scope",
|
||||||
|
"uwf/thread/",
|
||||||
|
"--value",
|
||||||
|
hashA,
|
||||||
|
],
|
||||||
|
{ encoding: "utf-8" },
|
||||||
|
);
|
||||||
|
const var2 = JSON.parse(createResult2.stdout);
|
||||||
|
|
||||||
|
const createResult3 = spawnSync(
|
||||||
|
"bun",
|
||||||
|
[
|
||||||
|
cliPath,
|
||||||
|
"--store",
|
||||||
|
storePath,
|
||||||
|
"var",
|
||||||
|
"create",
|
||||||
|
"--scope",
|
||||||
|
"uwf/workflow/",
|
||||||
|
"--value",
|
||||||
|
hashA,
|
||||||
|
],
|
||||||
|
{ encoding: "utf-8" },
|
||||||
|
);
|
||||||
|
const var3 = JSON.parse(createResult3.stdout);
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -14,4 +14,13 @@ export {
|
|||||||
} from "./schema.js";
|
} from "./schema.js";
|
||||||
export { createMemoryStore } from "./store.js";
|
export { createMemoryStore } from "./store.js";
|
||||||
export type { CasNode, Hash, Store } from "./types.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";
|
export { verify } from "./verify.js";
|
||||||
|
|||||||
@@ -0,0 +1,310 @@
|
|||||||
|
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 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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);
|
||||||
|
}
|
||||||
@@ -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 interface 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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user