diff --git a/bun.lock b/bun.lock index 96e609d..9db05b7 100644 --- a/bun.lock +++ b/bun.lock @@ -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=="], diff --git a/package.json b/package.json index eb53bcb..0579992 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/cli-json-cas/src/index.ts b/packages/cli-json-cas/src/index.ts index eb7ebef..aad4f8c 100644 --- a/packages/cli-json-cas/src/index.ts +++ b/packages/cli-json-cas/src/index.ts @@ -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; /** 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(defaultStorePath, "variables.db"); +const varDbPath = + typeof flags["var-db"] === "string" ? flags["var-db"] : defaultVarDbPath; + // ---- Helpers ---- function out(data: unknown): void { @@ -80,6 +89,12 @@ function openStore(): Store { return createFsStore(resolve(storePath)); } +function openVarStore(): VariableStore { + const store = openStore(); + mkdirSync(resolve(storePath), { recursive: true }); + return createVariableStore(resolve(varDbPath), store); +} + // ---- Commands ---- async function cmdInit(): Promise { @@ -250,6 +265,91 @@ async function cmdCat(args: string[]): Promise { } } +async function cmdVarCreate(_args: string[]): Promise { + const scope = flags.scope as string | undefined; + const value = flags.value as string | undefined; + + if (!scope) die("Usage: json-cas var create --scope --value "); + if (!value) die("Usage: json-cas var create --scope --value "); + + 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 { + const id = args[0]; + if (!id) die("Usage: json-cas var get "); + + 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 { + const id = args[0]; + const value = args[1]; + + if (!id || !value) { + die("Usage: json-cas var update "); + } + + 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 { + const id = args[0]; + if (!id) die("Usage: json-cas var delete "); + + 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 { console.log(`\ Usage: json-cas [--store ] [--json] [args] @@ -269,9 +369,14 @@ Commands: walk [--format tree] Recursive traversal hash Compute hash without storing (dry run) cat [--payload] Output node (--payload for payload only) + var create --scope --value Create a variable + var get Get a variable by ID + var update Update variable value + var delete Delete a variable Flags: --store Store directory (default: ~/.uncaged/json-cas) + --var-db Variable database path (default: /variables.db) --json Compact JSON output`); } @@ -346,6 +451,27 @@ 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; + default: + die(`Unknown var subcommand: ${sub ?? "(none)"}`); + } + break; + } + default: die(`Unknown command: ${cmd}`); } diff --git a/packages/cli-json-cas/src/var.test.ts b/packages/cli-json-cas/src/var.test.ts new file mode 100644 index 0000000..8ba8785 --- /dev/null +++ b/packages/cli-json-cas/src/var.test.ts @@ -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); + }); + }); +}); diff --git a/packages/json-cas/src/index.ts b/packages/json-cas/src/index.ts index 2ac89cf..ba0e19e 100644 --- a/packages/json-cas/src/index.ts +++ b/packages/json-cas/src/index.ts @@ -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"; diff --git a/packages/json-cas/src/variable-store.test.ts b/packages/json-cas/src/variable-store.test.ts new file mode 100644 index 0000000..c3ec63d --- /dev/null +++ b/packages/json-cas/src/variable-store.test.ts @@ -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(); + }); + }); +}); diff --git a/packages/json-cas/src/variable-store.ts b/packages/json-cas/src/variable-store.ts new file mode 100644 index 0000000..e78e5bc --- /dev/null +++ b/packages/json-cas/src/variable-store.ts @@ -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); +} diff --git a/packages/json-cas/src/variable.ts b/packages/json-cas/src/variable.ts new file mode 100644 index 0000000..8b568d5 --- /dev/null +++ b/packages/json-cas/src/variable.ts @@ -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 +}