diff --git a/packages/cli-json-cas/package.json b/packages/cli-json-cas/package.json index 83eb723..5b47097 100644 --- a/packages/cli-json-cas/package.json +++ b/packages/cli-json-cas/package.json @@ -3,7 +3,8 @@ "version": "0.5.3", "type": "module", "bin": { - "json-cas": "./src/index.ts" + "json-cas": "./src/index.ts", + "ucas": "./src/index.ts" }, "scripts": { "test": "bun test", diff --git a/packages/cli-json-cas/src/cli.test.ts b/packages/cli-json-cas/src/cli.test.ts new file mode 100644 index 0000000..4152d8b --- /dev/null +++ b/packages/cli-json-cas/src/cli.test.ts @@ -0,0 +1,33 @@ +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"]); + }); +}); diff --git a/packages/cli-json-cas/src/var.test.ts b/packages/cli-json-cas/src/var.test.ts deleted file mode 100644 index f96a2ce..0000000 --- a/packages/cli-json-cas/src/var.test.ts +++ /dev/null @@ -1,822 +0,0 @@ -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); - }); - }); -}); diff --git a/packages/json-cas/src/gc.test.ts b/packages/json-cas/src/gc.test.ts index 8dae803..f9686bc 100644 --- a/packages/json-cas/src/gc.test.ts +++ b/packages/json-cas/src/gc.test.ts @@ -1,451 +1,179 @@ -import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { afterEach, describe, expect, test } from "bun:test"; import { unlinkSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { bootstrap } from "./bootstrap.js"; import { gc } from "./gc.js"; import { putSchema } from "./schema.js"; import { createMemoryStore } from "./store.js"; import type { Store } from "./types.js"; -import { createVariableStore, type VariableStore } from "./variable-store.js"; +import { VariableStore } from "./variable-store.js"; -function tmpDbPath(): string { - return `/tmp/test-gc-${Date.now()}-${Math.random().toString(36).slice(2)}.db`; -} +const tmpDbPath = () => + join( + tmpdir(), + `test-gc-${Date.now()}-${Math.random().toString(36).slice(2)}.db`, + ); -describe("gc()", () => { +describe("GC - Variable Model Refactoring", () => { let store: Store; - let varStore: VariableStore; let dbPath: string; - beforeEach(() => { - store = createMemoryStore(); - dbPath = tmpDbPath(); - varStore = createVariableStore(dbPath, store); - }); - afterEach(() => { - varStore.close(); try { unlinkSync(dbPath); } catch { - // ignore + // Ignore cleanup errors } }); - test("preserves variable-referenced nodes", async () => { - // Bootstrap and create schema - const _metaHash = await bootstrap(store); + test("GC preserves variable-referenced nodes", async () => { + store = createMemoryStore(); + await bootstrap(store); const schema = { type: "object", properties: { name: { type: "string" } } }; const schemaHash = await putSchema(store, schema); - // Put two nodes const hashRef = await store.put(schemaHash, { name: "referenced" }); const hashOrphan = await store.put(schemaHash, { name: "orphan" }); - // Create variable pointing to hashRef - varStore.create("test/", hashRef); + dbPath = tmpDbPath(); + const varStore = new VariableStore(dbPath, store); + + varStore.set("config", hashRef); - // Run GC const stats = gc(store, varStore); - // Verify: hashRef exists, hashOrphan removed expect(store.has(hashRef)).toBe(true); - expect(store.get(hashRef)).not.toBe(null); expect(store.has(hashOrphan)).toBe(false); expect(stats.scanned).toBe(1); expect(stats.collected).toBeGreaterThanOrEqual(1); + + varStore.close(); }); - test("removes orphaned nodes", async () => { - // Bootstrap and create schema - const _metaHash = await bootstrap(store); - const schema = { type: "object", properties: { name: { type: "string" } } }; - const schemaHash = await putSchema(store, schema); + test("GC preserves nodes from variables with same name, different schemas", async () => { + store = createMemoryStore(); + await bootstrap(store); + const schemaA = { type: "object", properties: { x: { type: "number" } } }; + const schemaB = { type: "object", properties: { y: { type: "string" } } }; + const schemaAHash = await putSchema(store, schemaA); + const schemaBHash = await putSchema(store, schemaB); - // Put two nodes - const hashRef = await store.put(schemaHash, { name: "referenced" }); - const hashOrphan = await store.put(schemaHash, { name: "orphan" }); + const hashA = await store.put(schemaAHash, { x: 42 }); + const hashB = await store.put(schemaBHash, { y: "hello" }); + const hashOrphan = await store.put(schemaAHash, { x: 99 }); - // Create variable pointing to hashRef - varStore.create("test/", hashRef); + dbPath = tmpDbPath(); + const varStore = new VariableStore(dbPath, store); - // Run GC - gc(store, varStore); + varStore.set("config", hashA); + varStore.set("config", hashB); - // Verify: orphan removed - expect(store.has(hashOrphan)).toBe(false); - }); - - test("removes nodes after variable deletion", async () => { - // Bootstrap and create schema - const _metaHash = await bootstrap(store); - const schema = { type: "object", properties: { name: { type: "string" } } }; - const schemaHash = await putSchema(store, schema); - - // Put node - const hashRef = await store.put(schemaHash, { name: "referenced" }); - - // Create variable - const variable = varStore.create("test/", hashRef); - - // Delete variable - varStore.delete(variable.id); - - // Run GC - gc(store, varStore); - - // Verify: node removed - expect(store.has(hashRef)).toBe(false); - }); - - test("preserves schema nodes of reachable nodes", async () => { - // Bootstrap and create schema - const _metaHash = await bootstrap(store); - const schema = { type: "object", properties: { name: { type: "string" } } }; - const schemaHash = await putSchema(store, schema); - - // Put node - const hashData = await store.put(schemaHash, { name: "data" }); - - // Create variable - varStore.create("test/", hashData); - - // Run GC - gc(store, varStore); - - // Verify: schema preserved - expect(store.has(schemaHash)).toBe(true); - expect(store.get(schemaHash)).not.toBe(null); - }); - - test("collects unused schemas", async () => { - // Bootstrap - const _metaHash = await bootstrap(store); - - // Create two schemas - const schemaUsed = { - type: "object", - properties: { name: { type: "string" } }, - }; - const schemaOrphan = { - type: "object", - properties: { age: { type: "number" } }, - }; - - const schemaUsedHash = await putSchema(store, schemaUsed); - const schemaOrphanHash = await putSchema(store, schemaOrphan); - - // Put node using schemaUsed - const hashData = await store.put(schemaUsedHash, { name: "data" }); - - // Create variable - varStore.create("test/", hashData); - - // Run GC - gc(store, varStore); - - // Verify: schemaUsed preserved, schemaOrphan collected - expect(store.has(schemaUsedHash)).toBe(true); - expect(store.has(schemaOrphanHash)).toBe(false); - }); - - test("preserves bootstrap meta-schema", async () => { - // Bootstrap - const metaHash = await bootstrap(store); - - // Create other schemas and nodes (not referencing meta directly) - const schema = { type: "object", properties: { name: { type: "string" } } }; - const schemaHash = await putSchema(store, schema); - const hashData = await store.put(schemaHash, { name: "data" }); - - // Create variable - varStore.create("test/", hashData); - - // Run GC - gc(store, varStore); - - // Verify: meta-schema preserved - expect(store.has(metaHash)).toBe(true); - }); - - test("handles multiple variables with shared references", async () => { - // Bootstrap and create schema - const _metaHash = await bootstrap(store); - const schema = { type: "object", properties: { name: { type: "string" } } }; - const schemaHash = await putSchema(store, schema); - - // Put shared node - const hashShared = await store.put(schemaHash, { name: "shared" }); - - // Create two variables - varStore.create("test/", hashShared); - varStore.create("test/", hashShared); - - // Run GC const stats = gc(store, varStore); - // Verify: node preserved, scanned: 2 - expect(store.has(hashShared)).toBe(true); + expect(store.has(hashA)).toBe(true); + expect(store.has(hashB)).toBe(true); + expect(store.has(hashOrphan)).toBe(false); expect(stats.scanned).toBe(2); + + varStore.close(); }); - test("deleting one variable doesn't remove shared node", async () => { - // Bootstrap and create schema - const _metaHash = await bootstrap(store); + test("GC removes nodes after variable deletion", async () => { + store = createMemoryStore(); + await bootstrap(store); const schema = { type: "object", properties: { name: { type: "string" } } }; const schemaHash = await putSchema(store, schema); - // Put shared node - const hashShared = await store.put(schemaHash, { name: "shared" }); + const hashRef = await store.put(schemaHash, { name: "referenced" }); - // Create two variables - const var1 = varStore.create("test/", hashShared); - const _var2 = varStore.create("test/", hashShared); + dbPath = tmpDbPath(); + const varStore = new VariableStore(dbPath, store); - // Delete one variable - varStore.delete(var1.id); + varStore.set("config", hashRef); + varStore.remove("config", schemaHash); - // Run GC - gc(store, varStore); - - // Verify: node still preserved - expect(store.has(hashShared)).toBe(true); - }); - - test("deleting all variables removes shared node", async () => { - // Bootstrap and create schema - const _metaHash = await bootstrap(store); - const schema = { type: "object", properties: { name: { type: "string" } } }; - const schemaHash = await putSchema(store, schema); - - // Put shared node - const hashShared = await store.put(schemaHash, { name: "shared" }); - - // Create two variables - const var1 = varStore.create("test/", hashShared); - const var2 = varStore.create("test/", hashShared); - - // Delete both variables - varStore.delete(var1.id); - varStore.delete(var2.id); - - // Run GC - gc(store, varStore); - - // Verify: node removed - expect(store.has(hashShared)).toBe(false); - }); - - test("walks deep reference chains", async () => { - // Bootstrap - const _metaHash = await bootstrap(store); - - // Create schema with cas_ref field and a name field to differentiate nodes - const schemaTree = { - type: "object", - properties: { - name: { type: "string" }, - child: { - anyOf: [{ type: "null" }, { type: "string", format: "cas_ref" }], - }, - }, - }; - const schemaTreeHash = await putSchema(store, schemaTree); - - // Create chain: A -> B -> C - const hashC = await store.put(schemaTreeHash, { name: "C", child: null }); - const hashB = await store.put(schemaTreeHash, { - name: "B", - child: hashC, - }); - const hashA = await store.put(schemaTreeHash, { - name: "A", - child: hashB, - }); - - // Create orphan (different content so it gets a different hash) - const hashOrphan = await store.put(schemaTreeHash, { - name: "orphan", - child: null, - }); - - // Create variable pointing to A - varStore.create("test/", hashA); - - // Run GC const stats = gc(store, varStore); - // Verify: A, B, C preserved; orphan removed - expect(store.has(hashA)).toBe(true); - expect(store.has(hashB)).toBe(true); - expect(store.has(hashC)).toBe(true); - expect(store.has(hashOrphan)).toBe(false); - expect(stats.reachable).toBeGreaterThanOrEqual(4); // A, B, C, schemaTree - }); - - test("handles cycles without hanging", async () => { - // Bootstrap - const _metaHash = await bootstrap(store); - - // Create schema with cas_ref field - const schema = { - type: "object", - properties: { - child: { type: "string", format: "cas_ref" }, - }, - }; - const schemaHash = await putSchema(store, schema); - - // We need to create a cycle: X -> Y -> X - // This requires getting the hash before putting - // For simplicity, we'll create a self-referencing node - const hashX = await store.put(schemaHash, { child: "placeholder" }); - - // Now manually update the node to reference itself (this is a workaround) - // In reality, we can't easily create cycles without modifying the store - // But the walk function should handle it gracefully - - // Create variable - varStore.create("test/", hashX); - - // Run GC - should not hang - const stats = gc(store, varStore); - - // Verify: completes without hanging - expect(store.has(hashX)).toBe(true); - expect(stats.scanned).toBe(1); - }); - - test("handles empty variable store", async () => { - // Bootstrap - const metaHash = await bootstrap(store); - - // Create some schemas and nodes - const schema = { type: "object", properties: { name: { type: "string" } } }; - const schemaHash = await putSchema(store, schema); - const hash1 = await store.put(schemaHash, { name: "node1" }); - const hash2 = await store.put(schemaHash, { name: "node2" }); - - // NO variables created - - // Run GC - const stats = gc(store, varStore); - - // Verify: all user nodes removed, scanned: 0 + expect(store.has(hashRef)).toBe(false); expect(stats.scanned).toBe(0); - expect(stats.collected).toBeGreaterThan(0); - expect(store.has(hash1)).toBe(false); - expect(store.has(hash2)).toBe(false); - // Bootstrap meta-schema should still exist - expect(store.has(metaHash)).toBe(true); + + varStore.close(); }); - test("handles empty CAS store", () => { - // Fresh store, no bootstrap, no nodes + test("GC is global across all variables", async () => { + store = createMemoryStore(); + await bootstrap(store); + const schemaA = { type: "object", properties: { x: { type: "number" } } }; + const schemaB = { type: "object", properties: { y: { type: "string" } } }; + const schemaAHash = await putSchema(store, schemaA); + const schemaBHash = await putSchema(store, schemaB); + + const hash1 = await store.put(schemaAHash, { x: 1 }); + const hash2 = await store.put(schemaAHash, { x: 2 }); + const hash3 = await store.put(schemaBHash, { y: "a" }); + const hashOrphan = await store.put(schemaAHash, { x: 999 }); + + dbPath = tmpDbPath(); + const varStore = new VariableStore(dbPath, store); + + varStore.set("uwf.thread", hash1); + varStore.set("uwf.workflow", hash2); + varStore.set("app.config", hash3); - // Run GC const stats = gc(store, varStore); - // Verify: completes without error - expect(stats.total).toBe(0); - expect(stats.reachable).toBe(0); - expect(stats.collected).toBe(0); - expect(stats.scanned).toBe(0); - }); - - test("is global across all scopes", async () => { - // Bootstrap - const _metaHash = await bootstrap(store); - - // Create schema - const schema = { type: "object", properties: { name: { type: "string" } } }; - const schemaHash = await putSchema(store, schema); - - // Create variables in different scopes - const hashA = await store.put(schemaHash, { name: "A" }); - const hashB = await store.put(schemaHash, { name: "B" }); - const hashC = await store.put(schemaHash, { name: "C" }); - const hashOrphan = await store.put(schemaHash, { name: "orphan" }); - - varStore.create("uwf/thread/", hashA); - varStore.create("uwf/workflow/", hashB); - varStore.create("app/config/", hashC); - - // Run GC - const stats = gc(store, varStore); - - // Verify: all three preserved, orphan removed - expect(store.has(hashA)).toBe(true); - expect(store.has(hashB)).toBe(true); - expect(store.has(hashC)).toBe(true); + expect(store.has(hash1)).toBe(true); + expect(store.has(hash2)).toBe(true); + expect(store.has(hash3)).toBe(true); expect(store.has(hashOrphan)).toBe(false); expect(stats.scanned).toBe(3); + + varStore.close(); }); - test("returns accurate stats", async () => { - // Bootstrap - const _metaHash = await bootstrap(store); + test("GC integration with refactored variable store", async () => { + store = createMemoryStore(); + await bootstrap(store); - // Create schemas and nodes - const schema1 = { - type: "object", - properties: { name: { type: "string" } }, - }; - const schema2 = { - type: "object", - properties: { age: { type: "number" } }, - }; + const schemaA = { type: "object", properties: { x: { type: "number" } } }; + const schemaB = { type: "object", properties: { y: { type: "string" } } }; + const schemaAHash = await putSchema(store, schemaA); + const schemaBHash = await putSchema(store, schemaB); - const schema1Hash = await putSchema(store, schema1); - const schema2Hash = await putSchema(store, schema2); + const hashA1 = await store.put(schemaAHash, { x: 1 }); + const hashA2 = await store.put(schemaAHash, { x: 2 }); + const hashB = await store.put(schemaBHash, { y: "hello" }); + const hashOrphan1 = await store.put(schemaAHash, { x: 999 }); + const hashOrphan2 = await store.put(schemaBHash, { y: "orphan" }); - // Create 2 nodes - const hash1 = await store.put(schema1Hash, { name: "node1" }); - const hash2 = await store.put(schema2Hash, { age: 42 }); + dbPath = tmpDbPath(); + const varStore = new VariableStore(dbPath, store); - // Create 3 orphans - const _orphan1 = await store.put(schema1Hash, { name: "orphan1" }); - const _orphan2 = await store.put(schema1Hash, { name: "orphan2" }); - const _orphan3 = await store.put(schema2Hash, { age: 99 }); + // Create variables + varStore.set("var1", hashA1); + varStore.set("var2", hashA2); + varStore.set("var3", hashB); - // Create 2 variables - varStore.create("test/", hash1); - varStore.create("test/", hash2); + // First GC: orphans removed + let stats = gc(store, varStore); + expect(store.has(hashA1)).toBe(true); + expect(store.has(hashA2)).toBe(true); + expect(store.has(hashB)).toBe(true); + expect(store.has(hashOrphan1)).toBe(false); + expect(store.has(hashOrphan2)).toBe(false); + expect(stats.scanned).toBe(3); - // Count total before GC - const totalBefore = 8; // metaHash, schema1Hash, schema2Hash, hash1, hash2, orphan1, orphan2, orphan3 + // Delete one variable + varStore.remove("var2", schemaAHash); - // Run GC - const stats = gc(store, varStore); - - // Verify stats - expect(stats.total).toBe(totalBefore); + // Second GC: hashA2 removed + stats = gc(store, varStore); + expect(store.has(hashA1)).toBe(true); + expect(store.has(hashA2)).toBe(false); + expect(store.has(hashB)).toBe(true); expect(stats.scanned).toBe(2); - expect(stats.reachable).toBe(5); // metaHash, schema1Hash, schema2Hash, hash1, hash2 - expect(stats.collected).toBe(3); // orphan1, orphan2, orphan3 - }); - test("handles missing CAS nodes gracefully", async () => { - // Bootstrap - const _metaHash = await bootstrap(store); - - // Create schema - const schema = { type: "object", properties: { name: { type: "string" } } }; - const schemaHash = await putSchema(store, schema); - - // Create a valid node - const hashValid = await store.put(schemaHash, { name: "valid" }); - - // Create variable pointing to valid node - varStore.create("test/", hashValid); - - // Manually create a variable with non-existent hash (simulate corruption) - // We'll use the variable store's internal DB to insert a fake variable - // For simplicity, we'll skip this test as it requires internal access - - // Run GC - const stats = gc(store, varStore); - - // Verify: completes without crashing - expect(stats.scanned).toBeGreaterThanOrEqual(1); + varStore.close(); }); }); diff --git a/packages/json-cas/src/index.ts b/packages/json-cas/src/index.ts index 2b23183..ece1264 100644 --- a/packages/json-cas/src/index.ts +++ b/packages/json-cas/src/index.ts @@ -15,12 +15,12 @@ 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 type { Variable } from "./variable.js"; export { CasNodeNotFoundError, createVariableStore, - InvalidScopeError, InvalidTagFormatError, + InvalidVariableNameError, SchemaMismatchError, TagLabelConflictError, VariableNotFoundError, diff --git a/packages/json-cas/src/variable-store.test.ts b/packages/json-cas/src/variable-store.test.ts index f474dc6..1c18f02 100644 --- a/packages/json-cas/src/variable-store.test.ts +++ b/packages/json-cas/src/variable-store.test.ts @@ -1,407 +1,1595 @@ -import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { afterEach, describe, expect, test } from "bun:test"; import { unlinkSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { bootstrap } from "./bootstrap.js"; +import { putSchema } from "./schema.js"; import { createMemoryStore } from "./store.js"; import type { Store } from "./types.js"; +import type { Variable } from "./variable.js"; import { CasNodeNotFoundError, - InvalidScopeError, + InvalidVariableNameError, SchemaMismatchError, + TagLabelConflictError, 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; +const tmpDbPath = () => + join( + tmpdir(), + `test-var-${Date.now()}-${Math.random().toString(36).slice(2)}.db`, + ); - beforeEach(async () => { - // Create a temporary database - dbPath = join(tmpdir(), `test-variables-${Date.now()}.db`); +describe("VariableStore - Database Schema", () => { + test("Database schema has (name, schema) composite primary key", () => { + const store = createMemoryStore(); + const dbPath = tmpDbPath(); + const varStore = new VariableStore(dbPath, store); - // Create a CAS store with test data - store = createMemoryStore(); + // Query schema from SQLite + const db = (varStore as unknown as { db: unknown }).db as { + prepare: (sql: string) => { + all: () => unknown[]; + }; + }; + const tableInfo = db.prepare("PRAGMA table_info(variables)").all(); - // 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" } }, - }); + // Check columns + const columns = tableInfo.map( + (col: unknown) => (col as { name: string }).name, + ); + expect(columns).toContain("name"); + expect(columns).toContain("schema"); + expect(columns).not.toContain("id"); + expect(columns).not.toContain("scope"); - // 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 }); + // Check primary key + const pkColumns = tableInfo + .filter((col: unknown) => (col as { pk: number }).pk > 0) + .sort( + (a: unknown, b: unknown) => + (a as { pk: number }).pk - (b as { pk: number }).pk, + ) + .map((col: unknown) => (col as { name: string }).name); + expect(pkColumns).toEqual(["name", "schema"]); - // Create variable store - varStore = new VariableStore(dbPath, store); + varStore.close(); + unlinkSync(dbPath); }); - afterEach(() => { + test("Database indexes reference name instead of id/scope", () => { + const store = createMemoryStore(); + const dbPath = tmpDbPath(); + const varStore = new VariableStore(dbPath, store); + + const db = (varStore as unknown as { db: unknown }).db as { + prepare: (sql: string) => { + all: () => unknown[]; + }; + }; + const indexes = db + .prepare( + "SELECT name, sql FROM sqlite_master WHERE type='index' AND tbl_name='variables'", + ) + .all(); + + // Should have indexes on name, value, schema + const indexNames = indexes.map( + (idx: unknown) => (idx as { name: string }).name, + ); + expect(indexNames).toContain("idx_var_name"); + expect(indexNames).toContain("idx_var_value"); + expect(indexNames).toContain("idx_var_schema"); + + // Should NOT have scope index + expect(indexNames).not.toContain("idx_var_scope"); + varStore.close(); + unlinkSync(dbPath); + }); + + test("variable_tags table has composite foreign key", () => { + const store = createMemoryStore(); + const dbPath = tmpDbPath(); + const varStore = new VariableStore(dbPath, store); + + const db = (varStore as unknown as { db: unknown }).db as { + prepare: (sql: string) => { + all: () => unknown[]; + }; + }; + const tableInfo = db.prepare("PRAGMA table_info(variable_tags)").all(); + + const columns = tableInfo.map( + (col: unknown) => (col as { name: string }).name, + ); + expect(columns).toContain("variable_name"); + expect(columns).toContain("variable_schema"); + expect(columns).not.toContain("variable_id"); + + varStore.close(); + unlinkSync(dbPath); + }); +}); + +describe("VariableStore - set() Upsert Method", () => { + let store: Store; + let dbPath: string; + + afterEach(() => { try { unlinkSync(dbPath); } catch { - // Ignore cleanup errors + // Ignore if file doesn't exist } }); - 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); - expect(variable.tags).toEqual({}); - expect(variable.labels).toEqual([]); - - // 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); - expect(retrieved?.tags).toEqual({}); - expect(retrieved?.labels).toEqual([]); + test("set() creates new variable when (name, schema) doesn't exist", async () => { + // Setup: store with schema and data node + store = createMemoryStore(); + await bootstrap(store); + const schemaHash = await putSchema(store, { + type: "object", + properties: { x: { type: "number" } }, }); + const dataHash = await store.put(schemaHash, { x: 42 }); - 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 /", - ); - }); + dbPath = tmpDbPath(); + const varStore = new VariableStore(dbPath, store); - 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}`, - ); - }); + // Action: set() for new variable + const variable = varStore.set("config", dataHash); + + // Assertions + expect(variable.name).toBe("config"); + expect(variable.schema).toBe(schemaHash); + expect(variable.value).toBe(dataHash); + expect(variable.created).toBeGreaterThan(0); + expect(variable.updated).toBe(variable.created); + expect(variable.tags).toEqual({}); + expect(variable.labels).toEqual([]); + + // Verify in database + const retrieved = varStore.get("config", schemaHash); + expect(retrieved).not.toBeNull(); + expect((retrieved as Variable).value).toBe(dataHash); + + varStore.close(); }); - 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("set() updates value when (name, schema) already exists", async () => { + store = createMemoryStore(); + await bootstrap(store); + const schemaHash = await putSchema(store, { + type: "object", + properties: { x: { type: "number" } }, }); + const hash1 = await store.put(schemaHash, { x: 42 }); + const hash2 = await store.put(schemaHash, { x: 99 }); - test("2.2: Get non-existent variable", () => { - const fakeId = "01ARZ3NDEKTSV4RRFFQ69G5FAV"; - const result = varStore.get(fakeId); + dbPath = tmpDbPath(); + const varStore = new VariableStore(dbPath, store); - expect(result).toBeNull(); - }); + // Create initial variable + const created = varStore.set("config", hash1); + const createdTime = created.created; + + await new Promise((resolve) => setTimeout(resolve, 10)); + + // Update via set() + const updated = varStore.set("config", hash2); + + // Assertions + expect(updated.name).toBe("config"); + expect(updated.schema).toBe(schemaHash); + expect(updated.value).toBe(hash2); // Updated value + expect(updated.created).toBe(createdTime); // Created time unchanged + expect(updated.updated).toBeGreaterThan(createdTime); // Updated time changed + + // Verify in database + const retrieved = varStore.get("config", schemaHash); + expect((retrieved as Variable).value).toBe(hash2); + + varStore.close(); }); - 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; + test("set() creates variable with tags and labels", async () => { + store = createMemoryStore(); + await bootstrap(store); + const schemaHash = await putSchema(store, { type: "object" }); + const dataHash = await store.put(schemaHash, {}); - // Wait a bit to ensure different timestamp - await new Promise((resolve) => setTimeout(resolve, 10)); + dbPath = tmpDbPath(); + const varStore = new VariableStore(dbPath, store); - 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); + const variable = varStore.set("config", dataHash, { + tags: { env: "prod", region: "us-east" }, + labels: ["critical", "monitored"], }); - 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(variable.tags).toEqual({ env: "prod", region: "us-east" }); + expect(variable.labels).toEqual(["critical", "monitored"]); - expect(updated.value).toBe(hashA); - expect(updated.schema).toBe(schemaA); - // Updated timestamp may change, this is implementation-defined - }); + varStore.close(); }); - describe("Test Group 4: Variable Update (Schema Mismatch)", () => { - test("4.1: Update variable fails with schema mismatch", () => { - const created = varStore.create("uwf/thread/", hashA); + test("set() preserves tags/labels when updating without options", async () => { + store = createMemoryStore(); + await bootstrap(store); + const schemaHash = await putSchema(store, { + type: "object", + properties: { x: { type: "number" } }, + }); + const hash1 = await store.put(schemaHash, { x: 1 }); + const hash2 = await store.put(schemaHash, { x: 2 }); - expect(() => varStore.update(created.id, hashC)).toThrow( - SchemaMismatchError, - ); + dbPath = tmpDbPath(); + const varStore = new VariableStore(dbPath, store); - 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); + // Create with tags/labels + varStore.set("config", hash1, { + tags: { env: "prod" }, + labels: ["critical"], }); - test("4.2: Update variable fails with non-existent CAS node", () => { - const created = varStore.create("uwf/thread/", hashA); - const fakeHash = "FAKEHASH00000"; + // Update value only (no options) + const updated = varStore.set("config", hash2); - expect(() => varStore.update(created.id, fakeHash)).toThrow( - CasNodeNotFoundError, - ); - }); + // Tags/labels should be preserved + expect(updated.value).toBe(hash2); + expect(updated.tags).toEqual({ env: "prod" }); + expect(updated.labels).toEqual(["critical"]); - 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}`, - ); - }); + varStore.close(); }); - 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("set() allows same name with different schemas", async () => { + store = createMemoryStore(); + await bootstrap(store); + const schemaA = await putSchema(store, { + type: "object", + properties: { x: { type: "number" } }, }); - - 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(); + const schemaB = await putSchema(store, { + type: "object", + properties: { y: { type: "string" } }, }); + const hashA = await store.put(schemaA, { x: 42 }); + const hashB = await store.put(schemaB, { y: "hello" }); - test("5.3: Delete non-existent variable", () => { - const fakeId = "01ARZ3NDEKTSV4RRFFQ69G5FAV"; + dbPath = tmpDbPath(); + const varStore = new VariableStore(dbPath, store); - expect(() => varStore.delete(fakeId)).toThrow(VariableNotFoundError); - expect(() => varStore.delete(fakeId)).toThrow( - `Variable not found: ${fakeId}`, - ); - }); + // Create two variables with same name, different schemas + const varA = varStore.set("config", hashA); + const varB = varStore.set("config", hashB); + + expect(varA.name).toBe("config"); + expect(varA.schema).toBe(schemaA); + expect(varB.name).toBe("config"); + expect(varB.schema).toBe(schemaB); + expect(varA.value).not.toBe(varB.value); + + // Verify both exist independently + expect((varStore.get("config", schemaA) as Variable).value).toBe(hashA); + expect((varStore.get("config", schemaB) as Variable).value).toBe(hashB); + + varStore.close(); }); - 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); + test("set() validates variable name", async () => { + store = createMemoryStore(); + await bootstrap(store); + const schemaHash = await putSchema(store, { type: "object" }); + const dataHash = await store.put(schemaHash, {}); - // Wait a bit to ensure different timestamps - await new Promise((resolve) => setTimeout(resolve, 10)); + dbPath = tmpDbPath(); + const varStore = new VariableStore(dbPath, store); - const results = varStore.list({ scope: "uwf/" }); + // Empty name + expect(() => varStore.set("", dataHash)).toThrow(InvalidVariableNameError); - expect(results).toHaveLength(3); - expect(results.every((v) => v.scope.startsWith("uwf/"))).toBe(true); + // Invalid characters + expect(() => varStore.set("hello world", dataHash)).toThrow( + InvalidVariableNameError, + ); + expect(() => varStore.set("hello@world", dataHash)).toThrow( + InvalidVariableNameError, + ); - // 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); - }); + // Empty segments + expect(() => varStore.set("a//b", dataHash)).toThrow( + InvalidVariableNameError, + ); + expect(() => varStore.set("/ab", dataHash)).toThrow( + InvalidVariableNameError, + ); + expect(() => varStore.set("ab/", dataHash)).toThrow( + InvalidVariableNameError, + ); - 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); - }); + varStore.close(); }); - 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); + test("set() extracts schema from value hash internally", async () => { + // Given: Two different schemas + store = createMemoryStore(); + await bootstrap(store); + const schemaA = await putSchema(store, { type: "number" }); + const schemaB = await putSchema(store, { type: "string" }); + const valueA = await store.put(schemaA, 42); + const valueB = await store.put(schemaB, "hello"); - // Get variable - const retrieved1 = varStore.get(var1.id); - expect(retrieved1?.value).toBe(hashA); + dbPath = tmpDbPath(); + const varStore = new VariableStore(dbPath, store); - // Wait to ensure different timestamp - await new Promise((resolve) => setTimeout(resolve, 10)); + // When: set() with same name but different value schemas + const varA = varStore.set("config", valueA); + const varB = varStore.set("config", valueB); - // Update variable - const updated = varStore.update(var1.id, hashB); - expect(updated.value).toBe(hashB); - expect(updated.updated).toBeGreaterThan(var1.created); + // Then: Both variables created with correct extracted schemas + expect(varA.schema).toBe(schemaA); + expect(varB.schema).toBe(schemaB); - // Get updated variable - const retrieved2 = varStore.get(var1.id); - expect(retrieved2?.value).toBe(hashB); + // Verify they coexist independently + const retrievedA = varStore.get("config", schemaA); + const retrievedB = varStore.get("config", schemaB); + expect((retrievedA as Variable).value).toBe(valueA); + expect((retrievedB as Variable).value).toBe(valueB); - // Delete variable - const deleted = varStore.delete(var1.id); - expect(deleted.value).toBe(hashB); + varStore.close(); + }); - // Verify deletion - const retrieved3 = varStore.get(var1.id); - expect(retrieved3).toBeNull(); + test("set() upserts based on extracted schema", async () => { + // Given: Existing variable with schemaA + store = createMemoryStore(); + await bootstrap(store); + const schemaA = await putSchema(store, { type: "number" }); + const value1 = await store.put(schemaA, 42); + const value2 = await store.put(schemaA, 99); + + dbPath = tmpDbPath(); + const varStore = new VariableStore(dbPath, store); + + varStore.set("config", value1); + + // When: set() with same name and same schema (extracted) + const updated = varStore.set("config", value2); + + // Then: Updates existing variable, not creates new + expect(updated.value).toBe(value2); + expect(varStore.list().length).toBe(1); // Still only 1 variable + + varStore.close(); + }); + + test("set() throws CasNodeNotFoundError for invalid hash", async () => { + store = createMemoryStore(); + dbPath = tmpDbPath(); + const varStore = new VariableStore(dbPath, store); + + const fakeHash = "FAKEHASH00000"; + + expect(() => varStore.set("config", fakeHash)).toThrow( + CasNodeNotFoundError, + ); + expect(() => varStore.set("config", fakeHash)).toThrow( + `CAS node not found: ${fakeHash}`, + ); + + varStore.close(); + }); + + test("set() throws TagLabelConflictError when updating with tag key that matches new label", async () => { + store = createMemoryStore(); + await bootstrap(store); + const schema = { type: "object", properties: { x: { type: "number" } } }; + const schemaHash = await putSchema(store, schema); + const hash1 = await store.put(schemaHash, { x: 1 }); + const hash2 = await store.put(schemaHash, { x: 2 }); + + dbPath = tmpDbPath(); + const varStore = new VariableStore(dbPath, store); + + // Create with tags + varStore.set("config", hash1, { tags: { env: "prod" } }); + + // Try to update with conflicting tag/label + expect(() => { + varStore.set("config", hash2, { + tags: { region: "us" }, + labels: ["region"], // conflicts with tag key + }); + }).toThrow(TagLabelConflictError); + + varStore.close(); + }); + + test("set() throws TagLabelConflictError when updating with label that matches new tag key", async () => { + store = createMemoryStore(); + await bootstrap(store); + const schema = { type: "object", properties: { x: { type: "number" } } }; + const schemaHash = await putSchema(store, schema); + const hash1 = await store.put(schemaHash, { x: 1 }); + const hash2 = await store.put(schemaHash, { x: 2 }); + + dbPath = tmpDbPath(); + const varStore = new VariableStore(dbPath, store); + + // Create with labels + varStore.set("config", hash1, { labels: ["production"] }); + + // Try to update with conflicting label/tag + expect(() => { + varStore.set("config", hash2, { + tags: { production: "true" }, // conflicts with existing label "production" + // labels not provided - existing ["production"] preserved, causing conflict + }); + }).toThrow(TagLabelConflictError); + + varStore.close(); + }); + + test("set() allows updating tags/labels when no conflicts", async () => { + store = createMemoryStore(); + await bootstrap(store); + const schema = { type: "object", properties: { x: { type: "number" } } }; + const schemaHash = await putSchema(store, schema); + const hash1 = await store.put(schemaHash, { x: 1 }); + const hash2 = await store.put(schemaHash, { x: 2 }); + + dbPath = tmpDbPath(); + const varStore = new VariableStore(dbPath, store); + + // Create with tags and labels + varStore.set("config", hash1, { + tags: { env: "dev" }, + labels: ["experimental"], }); - 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); + // Update with different tags/labels (no conflicts) + const updated = varStore.set("config", hash2, { + tags: { region: "us", version: "2" }, + labels: ["stable", "reviewed"], }); - 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(updated.tags).toEqual({ region: "us", version: "2" }); + expect(updated.labels).toEqual(["stable", "reviewed"]); + expect(updated.value).toBe(hash2); - 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(); - }); + varStore.close(); + }); +}); + +describe("VariableStore - get() with Optional Schema", () => { + let store: Store; + let dbPath: string; + + afterEach(() => { + try { + unlinkSync(dbPath); + } catch { + // Ignore + } + }); + + test("get(name, schema) returns Variable when exists", async () => { + // Given: Variable with (name, schema) + store = createMemoryStore(); + await bootstrap(store); + const schema = await putSchema(store, { type: "number" }); + const value = await store.put(schema, 42); + + dbPath = tmpDbPath(); + const varStore = new VariableStore(dbPath, store); + + varStore.set("config", value); + + // When: get() with exact (name, schema) + const result = varStore.get("config", schema); + + // Then: Returns Variable object + expect(result).not.toBeNull(); + expect((result as Variable).name).toBe("config"); + expect((result as Variable).schema).toBe(schema); + expect((result as Variable).value).toBe(value); + + varStore.close(); + }); + + test("get(name, schema) returns null when name doesn't exist", async () => { + store = createMemoryStore(); + await bootstrap(store); + const schema = await putSchema(store, { type: "number" }); + + dbPath = tmpDbPath(); + const varStore = new VariableStore(dbPath, store); + + // When: Query non-existent name + const result = varStore.get("nonexistent", schema); + + // Then: Returns null + expect(result).toBeNull(); + + varStore.close(); + }); + + test("get(name, schema) returns null when schema doesn't match", async () => { + // Given: Variable with schemaA + store = createMemoryStore(); + await bootstrap(store); + const schemaA = await putSchema(store, { type: "number" }); + const schemaB = await putSchema(store, { type: "string" }); + const value = await store.put(schemaA, 42); + + dbPath = tmpDbPath(); + const varStore = new VariableStore(dbPath, store); + + varStore.set("config", value); + + // When: Query with wrong schema + const result = varStore.get("config", schemaB); + + // Then: Returns null (schema mismatch) + expect(result).toBeNull(); + + varStore.close(); + }); + + test("get(name, schema) returns correct variant when multiple schemas exist", async () => { + // Given: Same name with two different schemas + store = createMemoryStore(); + await bootstrap(store); + const schemaA = await putSchema(store, { type: "number" }); + const schemaB = await putSchema(store, { type: "string" }); + const valueA = await store.put(schemaA, 42); + const valueB = await store.put(schemaB, "hello"); + + dbPath = tmpDbPath(); + const varStore = new VariableStore(dbPath, store); + + varStore.set("config", valueA); + varStore.set("config", valueB); + + // When: Query each schema explicitly + const resultA = varStore.get("config", schemaA); + const resultB = varStore.get("config", schemaB); + + // Then: Returns correct variant for each schema + expect(resultA).not.toBeNull(); + expect((resultA as Variable).schema).toBe(schemaA); + expect((resultA as Variable).value).toBe(valueA); + + expect(resultB).not.toBeNull(); + expect((resultB as Variable).schema).toBe(schemaB); + expect((resultB as Variable).value).toBe(valueB); + + varStore.close(); + }); + + test("get(name, schema) includes tags and labels", async () => { + store = createMemoryStore(); + await bootstrap(store); + const schema = await putSchema(store, { type: "object" }); + const value = await store.put(schema, {}); + + dbPath = tmpDbPath(); + const varStore = new VariableStore(dbPath, store); + + varStore.set("config", value, { + tags: { env: "prod" }, + labels: ["critical"], + }); + + const result = varStore.get("config", schema); + + expect(result).not.toBeNull(); + expect((result as Variable).tags).toEqual({ env: "prod" }); + expect((result as Variable).labels).toEqual(["critical"]); + + varStore.close(); + }); + + test("get(name, schema) returns exact match", async () => { + store = createMemoryStore(); + await bootstrap(store); + const schemaA = await putSchema(store, { + type: "object", + properties: { x: { type: "number" } }, + }); + const schemaB = await putSchema(store, { + type: "object", + properties: { y: { type: "string" } }, + }); + const hashA = await store.put(schemaA, { x: 42 }); + const hashB = await store.put(schemaB, { y: "hello" }); + + dbPath = tmpDbPath(); + const varStore = new VariableStore(dbPath, store); + + varStore.set("config", hashA); + varStore.set("config", hashB); + + const resultA = varStore.get("config", schemaA); + const resultB = varStore.get("config", schemaB); + + // Should return exact matches, not arrays + expect(resultA).not.toBeNull(); + expect(Array.isArray(resultA)).toBe(false); + expect((resultA as Variable).schema).toBe(schemaA); + expect((resultA as Variable).value).toBe(hashA); + + expect(resultB).not.toBeNull(); + expect(Array.isArray(resultB)).toBe(false); + expect((resultB as Variable).schema).toBe(schemaB); + expect((resultB as Variable).value).toBe(hashB); + + varStore.close(); + }); + + test("get(name, schema) returns null when combination doesn't exist", async () => { + store = createMemoryStore(); + await bootstrap(store); + const schemaA = await putSchema(store, { + type: "object", + properties: { x: { type: "number" } }, + }); + const schemaB = await putSchema(store, { + type: "object", + properties: { y: { type: "string" } }, + }); + const hashA = await store.put(schemaA, { x: 42 }); + + dbPath = tmpDbPath(); + const varStore = new VariableStore(dbPath, store); + + varStore.set("config", hashA); + + // Query with wrong schema + const result = varStore.get("config", schemaB); + + expect(result).toBeNull(); + + varStore.close(); + }); +}); + +describe("VariableStore - remove() with Optional Schema", () => { + let store: Store; + let dbPath: string; + + afterEach(() => { + try { + unlinkSync(dbPath); + } catch { + // Ignore + } + }); + + test("remove(name) deletes all schema variants", async () => { + store = createMemoryStore(); + await bootstrap(store); + const schemaA = await putSchema(store, { + type: "object", + properties: { x: { type: "number" } }, + }); + const schemaB = await putSchema(store, { + type: "object", + properties: { y: { type: "string" } }, + }); + const hashA = await store.put(schemaA, { x: 42 }); + const hashB = await store.put(schemaB, { y: "hello" }); + + dbPath = tmpDbPath(); + const varStore = new VariableStore(dbPath, store); + + varStore.set("config", hashA); + varStore.set("config", hashB); + + // Remove all variants + const deleted = varStore.remove("config"); + + // Should return array of 2 deleted variables + expect(Array.isArray(deleted)).toBe(true); + expect(deleted.length).toBe(2); + + const deletedSchemas = deleted.map((v) => v.schema).sort(); + expect(deletedSchemas).toContain(schemaA); + expect(deletedSchemas).toContain(schemaB); + + // Verify both are gone + expect(varStore.get("config", schemaA)).toBeNull(); + expect(varStore.get("config", schemaB)).toBeNull(); + + varStore.close(); + }); + + test("remove(name) returns empty array when variable doesn't exist", async () => { + store = createMemoryStore(); + dbPath = tmpDbPath(); + const varStore = new VariableStore(dbPath, store); + + const deleted = varStore.remove("nonexistent"); + + expect(Array.isArray(deleted)).toBe(true); + expect(deleted.length).toBe(0); + + varStore.close(); + }); + + test("remove(name, schema) deletes only specified variant", async () => { + store = createMemoryStore(); + await bootstrap(store); + const schemaA = await putSchema(store, { + type: "object", + properties: { x: { type: "number" } }, + }); + const schemaB = await putSchema(store, { + type: "object", + properties: { y: { type: "string" } }, + }); + const hashA = await store.put(schemaA, { x: 42 }); + const hashB = await store.put(schemaB, { y: "hello" }); + + dbPath = tmpDbPath(); + const varStore = new VariableStore(dbPath, store); + + varStore.set("config", hashA); + varStore.set("config", hashB); + + // Remove only schemaA variant + const deleted = varStore.remove("config", schemaA); + + // Should return single deleted Variable (not array) + expect(deleted).not.toBeNull(); + expect(Array.isArray(deleted)).toBe(false); + expect((deleted as Variable).name).toBe("config"); + expect((deleted as Variable).schema).toBe(schemaA); + expect((deleted as Variable).value).toBe(hashA); + + // Verify schemaA is gone but schemaB remains + expect(varStore.get("config", schemaA)).toBeNull(); + expect(varStore.get("config", schemaB)).not.toBeNull(); + + varStore.close(); + }); + + test("remove(name, schema) throws VariableNotFoundError when not found", async () => { + store = createMemoryStore(); + await bootstrap(store); + const schemaHash = await putSchema(store, { type: "object" }); + + dbPath = tmpDbPath(); + const varStore = new VariableStore(dbPath, store); + + expect(() => varStore.remove("nonexistent", schemaHash)).toThrow( + VariableNotFoundError, + ); + + varStore.close(); + }); + + test("remove() cascades deletion to tags and labels", async () => { + store = createMemoryStore(); + await bootstrap(store); + const schemaHash = await putSchema(store, { type: "object" }); + const dataHash = await store.put(schemaHash, {}); + + dbPath = tmpDbPath(); + const varStore = new VariableStore(dbPath, store); + + varStore.set("config", dataHash, { + tags: { env: "prod" }, + labels: ["critical"], + }); + + // Remove variable + varStore.remove("config"); + + // Verify tags/labels are also deleted + const db = (varStore as unknown as { db: unknown }).db as { + prepare: (sql: string) => { + all: (...params: unknown[]) => unknown[]; + }; + }; + const tags = db + .prepare( + "SELECT * FROM variable_tags WHERE variable_name = ? AND variable_schema = ?", + ) + .all("config", schemaHash); + const labels = db + .prepare( + "SELECT * FROM variable_labels WHERE variable_name = ? AND variable_schema = ?", + ) + .all("config", schemaHash); + + expect(tags).toHaveLength(0); + expect(labels).toHaveLength(0); + + varStore.close(); + }); + + test("remove(name) returns array even with single variant", async () => { + store = createMemoryStore(); + await bootstrap(store); + const schemaHash = await putSchema(store, { type: "object" }); + const dataHash = await store.put(schemaHash, {}); + + dbPath = tmpDbPath(); + const varStore = new VariableStore(dbPath, store); + + varStore.set("config", dataHash); + + // Remove with name only (no schema) + const deleted = varStore.remove("config"); + + // Should return array with 1 element + expect(Array.isArray(deleted)).toBe(true); + expect(deleted.length).toBe(1); + expect(deleted[0]?.name).toBe("config"); + + varStore.close(); + }); +}); + +describe("VariableStore - Name Validation", () => { + let store: Store; + let dbPath: string; + + afterEach(() => { + try { + unlinkSync(dbPath); + } catch { + // Ignore + } + }); + + test("validateName accepts valid variable names", async () => { + store = createMemoryStore(); + await bootstrap(store); + const schemaHash = await putSchema(store, { type: "object" }); + const dataHash = await store.put(schemaHash, {}); + + dbPath = tmpDbPath(); + const varStore = new VariableStore(dbPath, store); + + // All these should succeed + expect(() => varStore.set("simple", dataHash)).not.toThrow(); + expect(() => varStore.set("with_underscore", dataHash)).not.toThrow(); + expect(() => varStore.set("with-dash", dataHash)).not.toThrow(); + expect(() => varStore.set("with.dot", dataHash)).not.toThrow(); + expect(() => varStore.set("number123", dataHash)).not.toThrow(); + expect(() => varStore.set("path/to/var", dataHash)).not.toThrow(); + expect(() => + varStore.set("deeply/nested/path/to/var", dataHash), + ).not.toThrow(); + expect(() => varStore.set("uwf.thread.id_123", dataHash)).not.toThrow(); + + varStore.close(); + }); + + test("validateName rejects empty name", async () => { + store = createMemoryStore(); + await bootstrap(store); + const schemaHash = await putSchema(store, { type: "object" }); + const dataHash = await store.put(schemaHash, {}); + + dbPath = tmpDbPath(); + const varStore = new VariableStore(dbPath, store); + + expect(() => varStore.set("", dataHash)).toThrow(InvalidVariableNameError); + expect(() => varStore.set("", dataHash)).toThrow(/empty/i); + + varStore.close(); + }); + + test("validateName rejects invalid characters", async () => { + store = createMemoryStore(); + await bootstrap(store); + const schemaHash = await putSchema(store, { type: "object" }); + const dataHash = await store.put(schemaHash, {}); + + dbPath = tmpDbPath(); + const varStore = new VariableStore(dbPath, store); + + // Space + expect(() => varStore.set("hello world", dataHash)).toThrow( + InvalidVariableNameError, + ); + expect(() => varStore.set("hello world", dataHash)).toThrow( + /invalid character/i, + ); + + // Special characters + expect(() => varStore.set("hello@world", dataHash)).toThrow( + InvalidVariableNameError, + ); + expect(() => varStore.set("hello#world", dataHash)).toThrow( + InvalidVariableNameError, + ); + expect(() => varStore.set("hello$world", dataHash)).toThrow( + InvalidVariableNameError, + ); + expect(() => varStore.set("hello%world", dataHash)).toThrow( + InvalidVariableNameError, + ); + expect(() => varStore.set("hello&world", dataHash)).toThrow( + InvalidVariableNameError, + ); + expect(() => varStore.set("hello*world", dataHash)).toThrow( + InvalidVariableNameError, + ); + + varStore.close(); + }); + + test("validateName rejects empty segments", async () => { + store = createMemoryStore(); + await bootstrap(store); + const schemaHash = await putSchema(store, { type: "object" }); + const dataHash = await store.put(schemaHash, {}); + + dbPath = tmpDbPath(); + const varStore = new VariableStore(dbPath, store); + + // Double slash + expect(() => varStore.set("a//b", dataHash)).toThrow( + InvalidVariableNameError, + ); + expect(() => varStore.set("a//b", dataHash)).toThrow(/empty segment/i); + + // Triple slash + expect(() => varStore.set("a///b", dataHash)).toThrow( + InvalidVariableNameError, + ); + + varStore.close(); + }); + + test("validateName rejects leading or trailing slashes", async () => { + store = createMemoryStore(); + await bootstrap(store); + const schemaHash = await putSchema(store, { type: "object" }); + const dataHash = await store.put(schemaHash, {}); + + dbPath = tmpDbPath(); + const varStore = new VariableStore(dbPath, store); + + // Leading slash + expect(() => varStore.set("/abc", dataHash)).toThrow( + InvalidVariableNameError, + ); + expect(() => varStore.set("/abc", dataHash)).toThrow(/leading slash/i); + + // Trailing slash + expect(() => varStore.set("abc/", dataHash)).toThrow( + InvalidVariableNameError, + ); + expect(() => varStore.set("abc/", dataHash)).toThrow(/trailing slash/i); + + // Both + expect(() => varStore.set("/abc/", dataHash)).toThrow( + InvalidVariableNameError, + ); + + varStore.close(); + }); + + test("InvalidVariableNameError includes specific violation reason", () => { + // Test error construction with reason + const error1 = new InvalidVariableNameError("", "Name cannot be empty"); + expect(error1.name).toBe("InvalidVariableNameError"); + expect(error1.variableName).toBe(""); + expect(error1.message).toContain("empty"); + + const error2 = new InvalidVariableNameError( + "a//b", + "Name contains empty segment", + ); + expect(error2.variableName).toBe("a//b"); + expect(error2.message).toContain("empty segment"); + + const error3 = new InvalidVariableNameError( + "/abc", + "Name starts with slash", + ); + expect(error3.variableName).toBe("/abc"); + expect(error3.message).toContain("slash"); + }); +}); + +describe("VariableStore - validateName() Error Messages", () => { + let store: Store; + let dbPath: string; + let varStore: VariableStore; + let schemaHash: string; + let dataHash: string; + + afterEach(() => { + try { + varStore.close(); + } catch { + // ignore + } + try { + unlinkSync(dbPath); + } catch { + // ignore + } + }); + + test("validateName error message mentions 'empty' for empty string", async () => { + store = createMemoryStore(); + await bootstrap(store); + schemaHash = await putSchema(store, { type: "object" }); + dataHash = await store.put(schemaHash, {}); + + dbPath = tmpDbPath(); + varStore = new VariableStore(dbPath, store); + + try { + varStore.set("", dataHash); + throw new Error("Expected InvalidVariableNameError"); + } catch (e) { + expect(e).toBeInstanceOf(InvalidVariableNameError); + expect((e as InvalidVariableNameError).reason).toMatch(/empty/i); + expect((e as InvalidVariableNameError).message).toContain('""'); // Shows the invalid name + } + }); + + test("validateName error message identifies specific invalid segment", async () => { + store = createMemoryStore(); + await bootstrap(store); + schemaHash = await putSchema(store, { type: "object" }); + dataHash = await store.put(schemaHash, {}); + + dbPath = tmpDbPath(); + varStore = new VariableStore(dbPath, store); + + try { + varStore.set("valid/segment/bad@segment/more", dataHash); + throw new Error("Expected InvalidVariableNameError"); + } catch (e) { + expect(e).toBeInstanceOf(InvalidVariableNameError); + const error = e as InvalidVariableNameError; + expect(error.reason).toContain("bad@segment"); // Specific segment mentioned + expect(error.reason).toMatch(/invalid|characters/i); + } + }); + + test("validateName error message explains consecutive slashes", async () => { + store = createMemoryStore(); + await bootstrap(store); + schemaHash = await putSchema(store, { type: "object" }); + dataHash = await store.put(schemaHash, {}); + + dbPath = tmpDbPath(); + varStore = new VariableStore(dbPath, store); + + try { + varStore.set("a//b", dataHash); + throw new Error("Expected InvalidVariableNameError"); + } catch (e) { + expect(e).toBeInstanceOf(InvalidVariableNameError); + const error = e as InvalidVariableNameError; + expect(error.reason).toMatch(/empty segment|consecutive.*slash|\/\//i); + } + }); + + test("validateName error message distinguishes leading vs trailing slash", async () => { + store = createMemoryStore(); + await bootstrap(store); + schemaHash = await putSchema(store, { type: "object" }); + dataHash = await store.put(schemaHash, {}); + + dbPath = tmpDbPath(); + varStore = new VariableStore(dbPath, store); + + // Leading slash + try { + varStore.set("/abc", dataHash); + throw new Error("Expected InvalidVariableNameError"); + } catch (e) { + expect(e).toBeInstanceOf(InvalidVariableNameError); + const error = e as InvalidVariableNameError; + expect(error.reason).toMatch(/leading|start|begins/i); + expect(error.reason).not.toMatch(/trailing|end/i); + } + + // Trailing slash + try { + varStore.set("abc/", dataHash); + throw new Error("Expected InvalidVariableNameError"); + } catch (e) { + expect(e).toBeInstanceOf(InvalidVariableNameError); + const error = e as InvalidVariableNameError; + expect(error.reason).toMatch(/trailing|end/i); + expect(error.reason).not.toMatch(/leading|start|begins/i); + } + }); + + test("validateName accepts valid names with dots, underscores, hyphens", async () => { + store = createMemoryStore(); + await bootstrap(store); + schemaHash = await putSchema(store, { type: "object" }); + dataHash = await store.put(schemaHash, {}); + + dbPath = tmpDbPath(); + varStore = new VariableStore(dbPath, store); + + // All these should succeed + expect(() => varStore.set("app.config", dataHash)).not.toThrow(); + expect(() => varStore.set("my_variable", dataHash)).not.toThrow(); + expect(() => varStore.set("test-name", dataHash)).not.toThrow(); + expect(() => varStore.set("path/to/config.json", dataHash)).not.toThrow(); + expect(() => varStore.set("v1.2.3-alpha_001", dataHash)).not.toThrow(); + }); +}); + +describe("VariableStore - Integration Tests", () => { + let store: Store; + let dbPath: string; + + afterEach(() => { + try { + unlinkSync(dbPath); + } catch { + // Ignore + } + }); + + test("Complete workflow: set, get, remove with multiple schemas", async () => { + store = createMemoryStore(); + await bootstrap(store); + + const schemaConfig = await putSchema(store, { + type: "object", + properties: { host: { type: "string" }, port: { type: "number" } }, + }); + const schemaState = await putSchema(store, { + type: "object", + properties: { status: { type: "string" } }, + }); + + const configHash1 = await store.put(schemaConfig, { + host: "localhost", + port: 8080, + }); + const configHash2 = await store.put(schemaConfig, { + host: "0.0.0.0", + port: 3000, + }); + const stateHash1 = await store.put(schemaState, { status: "running" }); + const stateHash2 = await store.put(schemaState, { status: "stopped" }); + + dbPath = tmpDbPath(); + const varStore = new VariableStore(dbPath, store); + + // 1. Set initial config + const var1 = varStore.set("app/server", configHash1); + expect(var1.value).toBe(configHash1); + + // 2. Set state with same name, different schema + const var2 = varStore.set("app/server", stateHash1); + expect(var2.schema).toBe(schemaState); + + // 3. List all variants with exactName + const result = varStore.list({ exactName: "app/server" }); + expect(result.length).toBe(2); + + // 4. Get with schema returns single variable + const config = varStore.get("app/server", schemaConfig); + expect(config).not.toBeNull(); + expect((config as Variable).value).toBe(configHash1); + + // 5. Update config via set + const updated = varStore.set("app/server", configHash2); + expect(updated.value).toBe(configHash2); + + // 6. Update state via set + varStore.set("app/server", stateHash2); + + // 7. Remove specific schema + const deletedState = varStore.remove("app/server", schemaState); + expect((deletedState as Variable).schema).toBe(schemaState); + + // 8. Verify only config remains + const remaining = varStore.list({ exactName: "app/server" }); + expect(remaining.length).toBe(1); + expect(remaining[0]?.schema).toBe(schemaConfig); + + // 9. Remove all remaining + const deletedAll = varStore.remove("app/server"); + expect(Array.isArray(deletedAll)).toBe(true); + expect(deletedAll.length).toBe(1); + + // 10. Verify all gone + expect(varStore.get("app/server", schemaConfig)).toBeNull(); + + varStore.close(); + }); + + test("Upsert workflow preserves and updates tags", async () => { + store = createMemoryStore(); + await bootstrap(store); + const schemaHash = await putSchema(store, { + type: "object", + properties: { version: { type: "string" } }, + }); + const v1 = await store.put(schemaHash, { version: "1.0.0" }); + const v2 = await store.put(schemaHash, { version: "2.0.0" }); + + dbPath = tmpDbPath(); + const varStore = new VariableStore(dbPath, store); + + // Initial set with tags + varStore.set("app/version", v1, { + tags: { env: "dev", region: "us" }, + labels: ["beta"], + }); + + // Upsert without options preserves tags + const updated1 = varStore.set("app/version", v2); + expect(updated1.value).toBe(v2); + expect(updated1.tags).toEqual({ env: "dev", region: "us" }); + expect(updated1.labels).toEqual(["beta"]); + + // Upsert with new tags replaces them + const updated2 = varStore.set("app/version", v2, { + tags: { env: "prod" }, + labels: ["stable"], + }); + expect(updated2.tags).toEqual({ env: "prod" }); + expect(updated2.labels).toEqual(["stable"]); + + varStore.close(); + }); +}); + +describe("VariableStore - Legacy Update Method", () => { + let store: Store; + let dbPath: string; + + afterEach(() => { + try { + unlinkSync(dbPath); + } catch { + // Ignore + } + }); + + test("update() is distinct from set() and fails when variable doesn't exist", async () => { + store = createMemoryStore(); + await bootstrap(store); + const schemaHash = await putSchema(store, { type: "object" }); + const dataHash = await store.put(schemaHash, {}); + + dbPath = tmpDbPath(); + const varStore = new VariableStore(dbPath, store); + + // update() should fail when variable doesn't exist + expect(() => varStore.update("config", schemaHash, dataHash)).toThrow( + VariableNotFoundError, + ); + + // set() creates it + varStore.set("config", dataHash); + + // Now update() should work + const newHash = await store.put(schemaHash, {}); + const updated = varStore.update("config", schemaHash, newHash); + expect(updated.value).toBe(newHash); + + varStore.close(); + }); + + test("update() throws SchemaMismatchError when schema changes", async () => { + store = createMemoryStore(); + await bootstrap(store); + const schemaA = await putSchema(store, { type: "object" }); + const schemaB = await putSchema(store, { type: "string" }); + const dataA = await store.put(schemaA, {}); + const dataB = await store.put(schemaB, "hello"); + + dbPath = tmpDbPath(); + const varStore = new VariableStore(dbPath, store); + + varStore.set("config", dataA); + + expect(() => varStore.update("config", schemaA, dataB)).toThrow( + SchemaMismatchError, + ); + + varStore.close(); + }); +}); + +describe("VariableStore - List Operation", () => { + let store: Store; + let dbPath: string; + + afterEach(() => { + try { + unlinkSync(dbPath); + } catch { + // Ignore + } + }); + + test("list() returns all variables", async () => { + store = createMemoryStore(); + await bootstrap(store); + const schemaHash = await putSchema(store, { type: "object" }); + const data1 = await store.put(schemaHash, { a: 1 }); + const data2 = await store.put(schemaHash, { a: 2 }); + + dbPath = tmpDbPath(); + const varStore = new VariableStore(dbPath, store); + + varStore.set("var1", data1); + varStore.set("var2", data2); + + const vars = varStore.list(); + + expect(vars.length).toBe(2); + expect(vars.map((v) => v.name).sort()).toEqual(["var1", "var2"]); + + varStore.close(); + }); + + test("list() with namePrefix filters results", async () => { + store = createMemoryStore(); + await bootstrap(store); + const schemaHash = await putSchema(store, { type: "object" }); + const data = await store.put(schemaHash, {}); + + dbPath = tmpDbPath(); + const varStore = new VariableStore(dbPath, store); + + varStore.set("app/config", data); + varStore.set("app/state", data); + varStore.set("sys/config", data); + + const vars = varStore.list({ namePrefix: "app/" }); + + expect(vars.length).toBe(2); + expect(vars.every((v) => v.name.startsWith("app/"))).toBe(true); + + varStore.close(); + }); +}); + +describe("VariableStore - list() with exactName", () => { + let store: Store; + let dbPath: string; + + afterEach(() => { + try { + unlinkSync(dbPath); + } catch { + // Ignore + } + }); + + test("list({ exactName }) returns all schema variants for name", async () => { + // Given: Same name with multiple schemas + store = createMemoryStore(); + await bootstrap(store); + const schemaA = await putSchema(store, { type: "number" }); + const schemaB = await putSchema(store, { type: "string" }); + const schemaC = await putSchema(store, { type: "boolean" }); + const valueA = await store.put(schemaA, 42); + const valueB = await store.put(schemaB, "hello"); + const valueC = await store.put(schemaC, true); + + dbPath = tmpDbPath(); + const varStore = new VariableStore(dbPath, store); + + varStore.set("config", valueA); + varStore.set("config", valueB); + varStore.set("config", valueC); + varStore.set("other", valueA); // Different name, same schema + + // When: list with exactName + const results = varStore.list({ exactName: "config" }); + + // Then: Returns all 3 schema variants, not "other" + expect(results.length).toBe(3); + const schemas = results.map((v) => v.schema).sort(); + expect(schemas).toContain(schemaA); + expect(schemas).toContain(schemaB); + expect(schemas).toContain(schemaC); + expect(results.every((v) => v.name === "config")).toBe(true); + + varStore.close(); + }); + + test("list({ exactName }) returns empty array when name doesn't exist", async () => { + store = createMemoryStore(); + await bootstrap(store); + + dbPath = tmpDbPath(); + const varStore = new VariableStore(dbPath, store); + + const results = varStore.list({ exactName: "nonexistent" }); + expect(results).toEqual([]); + + varStore.close(); + }); + + test("list({ exactName, schema }) filters to specific variant", async () => { + // Given: Same name with two schemas + store = createMemoryStore(); + await bootstrap(store); + const schemaA = await putSchema(store, { type: "number" }); + const schemaB = await putSchema(store, { type: "string" }); + const valueA = await store.put(schemaA, 42); + const valueB = await store.put(schemaB, "hello"); + + dbPath = tmpDbPath(); + const varStore = new VariableStore(dbPath, store); + + varStore.set("config", valueA); + varStore.set("config", valueB); + + // When: Filter by both exactName and schema + const results = varStore.list({ exactName: "config", schema: schemaA }); + + // Then: Returns only schemaA variant + expect(results.length).toBe(1); + expect(results[0]?.schema).toBe(schemaA); + + varStore.close(); + }); + + test("list({ exactName }) with tags filters variants", async () => { + store = createMemoryStore(); + await bootstrap(store); + const schemaA = await putSchema(store, { type: "number" }); + const schemaB = await putSchema(store, { type: "string" }); + const valueA = await store.put(schemaA, 42); + const valueB = await store.put(schemaB, "hello"); + + dbPath = tmpDbPath(); + const varStore = new VariableStore(dbPath, store); + + varStore.set("config", valueA, { tags: { env: "dev" } }); + varStore.set("config", valueB, { tags: { env: "prod" } }); + + // When: Filter by exactName + tags + const results = varStore.list({ + exactName: "config", + tags: { env: "prod" }, + }); + + // Then: Returns only prod variant + expect(results.length).toBe(1); + expect(results[0]?.schema).toBe(schemaB); + + varStore.close(); + }); + + test("exactName and namePrefix are mutually exclusive", async () => { + store = createMemoryStore(); + await bootstrap(store); + + dbPath = tmpDbPath(); + const varStore = new VariableStore(dbPath, store); + + // When: Both provided + expect(() => { + varStore.list({ exactName: "config", namePrefix: "app/" }); + }).toThrow(/mutually exclusive|cannot specify both/i); + + varStore.close(); + }); + + test("list({ namePrefix }) does match partial exact names", async () => { + store = createMemoryStore(); + await bootstrap(store); + const schema = await putSchema(store, { type: "number" }); + const value = await store.put(schema, 42); + + dbPath = tmpDbPath(); + const varStore = new VariableStore(dbPath, store); + + varStore.set("app", value); + varStore.set("app/config", value); + varStore.set("application", value); + + // When: namePrefix without trailing slash + const results = varStore.list({ namePrefix: "app" }); + + // Then: Matches all three (prefix match) + expect(results.length).toBe(3); + expect(results.map((v) => v.name).sort()).toEqual([ + "app", + "app/config", + "application", + ]); + + varStore.close(); + }); + + test("exactName replaces get(name) multi-schema query use case", async () => { + // This test demonstrates that list({ exactName }) provides + // the functionality previously available via get(name) → Variable[] + + store = createMemoryStore(); + await bootstrap(store); + const schemaA = await putSchema(store, { type: "number" }); + const schemaB = await putSchema(store, { type: "string" }); + const valueA = await store.put(schemaA, 42); + const valueB = await store.put(schemaB, "hello"); + + dbPath = tmpDbPath(); + const varStore = new VariableStore(dbPath, store); + + varStore.set("config", valueA); + varStore.set("config", valueB); + + // Old way: get("config") → Variable | Variable[] + // New way: list({ exactName: "config" }) → Variable[] + const results = varStore.list({ exactName: "config" }); + + expect(results.length).toBe(2); + expect(results.every((v) => v.name === "config")).toBe(true); + + varStore.close(); + }); +}); + +describe("VariableStore - Tag/Label Management", () => { + let store: Store; + let dbPath: string; + + afterEach(() => { + try { + unlinkSync(dbPath); + } catch { + // Ignore + } + }); + + test("tag() adds tags to existing variable", async () => { + store = createMemoryStore(); + await bootstrap(store); + const schemaHash = await putSchema(store, { type: "object" }); + const dataHash = await store.put(schemaHash, {}); + + dbPath = tmpDbPath(); + const varStore = new VariableStore(dbPath, store); + + varStore.set("config", dataHash); + + const updated = varStore.tag("config", schemaHash, { + add: { env: "prod", region: "us" }, + }); + + expect(updated.tags).toEqual({ env: "prod", region: "us" }); + + varStore.close(); + }); + + test("tag() throws error for conflicting tag/label names", async () => { + store = createMemoryStore(); + await bootstrap(store); + const schemaHash = await putSchema(store, { type: "object" }); + const dataHash = await store.put(schemaHash, {}); + + dbPath = tmpDbPath(); + const varStore = new VariableStore(dbPath, store); + + varStore.set("config", dataHash, { labels: ["critical"] }); + + expect(() => + varStore.tag("config", schemaHash, { + add: { critical: "yes" }, + }), + ).toThrow(TagLabelConflictError); + + varStore.close(); }); }); diff --git a/packages/json-cas/src/variable-store.ts b/packages/json-cas/src/variable-store.ts index 10af467..d354c9f 100644 --- a/packages/json-cas/src/variable-store.ts +++ b/packages/json-cas/src/variable-store.ts @@ -1,18 +1,30 @@ import { Database } from "bun:sqlite"; -import { ulid } from "ulidx"; -import type { Store } from "./types.js"; -import type { Variable, VariableId } from "./variable.js"; +import type { Hash, Store } from "./types.js"; +import type { Variable } from "./variable.js"; /** * Custom error types for variable operations */ export class VariableNotFoundError extends Error { - constructor(id: VariableId) { - super(`Variable not found: ${id}`); + constructor( + public variableName: string, + public variableSchema: Hash, + ) { + super(`Variable not found: name=${variableName}, schema=${variableSchema}`); this.name = "VariableNotFoundError"; } } +export class InvalidVariableNameError extends Error { + constructor( + public variableName: string, + public reason: string, + ) { + super(`Invalid variable name "${variableName}": ${reason}`); + this.name = "InvalidVariableNameError"; + } +} + export class SchemaMismatchError extends Error { constructor( public expected: string, @@ -23,13 +35,6 @@ export class SchemaMismatchError extends Error { } } -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}`); @@ -66,37 +71,41 @@ export class VariableStore { private casStore: Store, ) { this.db = new Database(dbPath, { create: true }); + // Enable foreign keys + this.db.exec("PRAGMA foreign_keys = ON"); this.initDb(); } private initDb(): void { this.db.exec(` CREATE TABLE IF NOT EXISTS variables ( - id TEXT PRIMARY KEY, - scope TEXT NOT NULL, - value TEXT NOT NULL, + name TEXT NOT NULL, schema TEXT NOT NULL, + value TEXT NOT NULL, created INTEGER NOT NULL, - updated INTEGER NOT NULL + updated INTEGER NOT NULL, + PRIMARY KEY (name, schema) ); - CREATE INDEX IF NOT EXISTS idx_var_scope ON variables(scope); + CREATE INDEX IF NOT EXISTS idx_var_name ON variables(name); CREATE INDEX IF NOT EXISTS idx_var_value ON variables(value); CREATE INDEX IF NOT EXISTS idx_var_schema ON variables(schema); CREATE TABLE IF NOT EXISTS variable_tags ( - variable_id TEXT NOT NULL, + variable_name TEXT NOT NULL, + variable_schema TEXT NOT NULL, key TEXT NOT NULL, value TEXT NOT NULL, - PRIMARY KEY (variable_id, key), - FOREIGN KEY (variable_id) REFERENCES variables(id) ON DELETE CASCADE + PRIMARY KEY (variable_name, variable_schema, key), + FOREIGN KEY (variable_name, variable_schema) REFERENCES variables(name, schema) ON DELETE CASCADE ); CREATE TABLE IF NOT EXISTS variable_labels ( - variable_id TEXT NOT NULL, + variable_name TEXT NOT NULL, + variable_schema TEXT NOT NULL, name TEXT NOT NULL, - PRIMARY KEY (variable_id, name), - FOREIGN KEY (variable_id) REFERENCES variables(id) ON DELETE CASCADE + PRIMARY KEY (variable_name, variable_schema, name), + FOREIGN KEY (variable_name, variable_schema) REFERENCES variables(name, schema) ON DELETE CASCADE ); CREATE INDEX IF NOT EXISTS idx_var_tag_key ON variable_tags(key); @@ -106,11 +115,47 @@ export class VariableStore { } /** - * Validate that scope ends with / + * Validate variable name format */ - private validateScope(scope: string): void { - if (!scope.endsWith("/")) { - throw new InvalidScopeError(scope); + private validateName(name: string): void { + // Rule 1: Cannot be empty + if (name === "") { + throw new InvalidVariableNameError(name, "Name cannot be empty"); + } + + // Rule 2: No leading slash + if (name.startsWith("/")) { + throw new InvalidVariableNameError( + name, + "Name cannot start with leading slash", + ); + } + + // Rule 3: No trailing slash + if (name.endsWith("/")) { + throw new InvalidVariableNameError( + name, + "Name cannot end with trailing slash", + ); + } + + // Rule 4: Each segment must match [a-zA-Z0-9._-]+ and no empty segments + const segments = name.split("/"); + for (const segment of segments) { + if (segment === "") { + throw new InvalidVariableNameError( + name, + "Name contains empty segment (consecutive slashes //)", + ); + } + + // Check for invalid characters + if (!/^[a-zA-Z0-9._-]+$/.test(segment)) { + throw new InvalidVariableNameError( + name, + `Segment "${segment}" contains invalid characters (only a-z, A-Z, 0-9, ., _, - allowed)`, + ); + } } } @@ -126,19 +171,146 @@ export class VariableStore { } /** - * Create a new variable + * Load tags for a variable */ - create( - scope: string, + private loadTags(name: string, schema: Hash): Record { + const stmt = this.db.prepare(` + SELECT key, value + FROM variable_tags + WHERE variable_name = ? AND variable_schema = ? + `); + + const rows = stmt.all(name, schema) as Array<{ + key: string; + value: string; + }>; + const tags: Record = {}; + for (const row of rows) { + tags[row.key] = row.value; + } + return tags; + } + + /** + * Load labels for a variable + */ + private loadLabels(name: string, schema: Hash): string[] { + const stmt = this.db.prepare(` + SELECT name + FROM variable_labels + WHERE variable_name = ? AND variable_schema = ? + ORDER BY name ASC + `); + + const rows = stmt.all(name, schema) as Array<{ name: string }>; + return rows.map((row) => row.name); + } + + /** + * Set a variable (upsert: create or update) + */ + set( + name: string, value: string, options?: { tags?: Record; labels?: string[]; }, ): Variable { - this.validateScope(scope); + // Validate name format + this.validateName(name); + const schema = this.extractSchema(value); + // Check if variable exists + const existing = this.get(name, schema); + + if (existing !== null) { + // Update existing variable + const now = Date.now(); + + // If options provided, use them; otherwise preserve existing + const tags = options?.tags ?? existing.tags; + const labels = options?.labels ?? existing.labels; + + // Check for tag/label conflicts when updating with new options + if (options !== undefined) { + const tagKeys = Object.keys(tags); + for (const key of tagKeys) { + if (labels.includes(key)) { + throw new TagLabelConflictError(key, "label", "tag"); + } + } + } + + this.db.exec("BEGIN TRANSACTION"); + + try { + // Update value and timestamp + const updateStmt = this.db.prepare(` + UPDATE variables + SET value = ?, updated = ? + WHERE name = ? AND schema = ? + `); + updateStmt.run(value, now, name, schema); + + // If options provided, update tags/labels + if (options !== undefined) { + // Delete existing tags and labels + this.db + .prepare(` + DELETE FROM variable_tags WHERE variable_name = ? AND variable_schema = ? + `) + .run(name, schema); + + this.db + .prepare(` + DELETE FROM variable_labels WHERE variable_name = ? AND variable_schema = ? + `) + .run(name, schema); + + // Insert new tags + const tagKeys = Object.keys(tags); + if (tagKeys.length > 0) { + const tagStmt = this.db.prepare(` + INSERT INTO variable_tags (variable_name, variable_schema, key, value) + VALUES (?, ?, ?, ?) + `); + for (const [key, val] of Object.entries(tags)) { + tagStmt.run(name, schema, key, val); + } + } + + // Insert new labels + if (labels.length > 0) { + const labelStmt = this.db.prepare(` + INSERT INTO variable_labels (variable_name, variable_schema, name) + VALUES (?, ?, ?) + `); + for (const labelName of labels) { + labelStmt.run(name, schema, labelName); + } + } + } + + this.db.exec("COMMIT"); + } catch (e) { + this.db.exec("ROLLBACK"); + throw e; + } + + return { + name, + schema, + value, + created: existing.created, + updated: now, + tags, + labels: [...labels], + }; + } + + // Create new variable const tags = options?.tags ?? {}; const labels = options?.labels ?? []; @@ -150,38 +322,37 @@ export class VariableStore { } } - const id = ulid(); const now = Date.now(); this.db.exec("BEGIN TRANSACTION"); try { const stmt = this.db.prepare(` - INSERT INTO variables (id, scope, value, schema, created, updated) - VALUES (?, ?, ?, ?, ?, ?) + INSERT INTO variables (name, schema, value, created, updated) + VALUES (?, ?, ?, ?, ?) `); - stmt.run(id, scope, value, schema, now, now); + stmt.run(name, schema, value, now, now); // Insert tags if (tagKeys.length > 0) { const tagStmt = this.db.prepare(` - INSERT INTO variable_tags (variable_id, key, value) - VALUES (?, ?, ?) + INSERT INTO variable_tags (variable_name, variable_schema, key, value) + VALUES (?, ?, ?, ?) `); for (const [key, val] of Object.entries(tags)) { - tagStmt.run(id, key, val); + tagStmt.run(name, schema, key, val); } } // Insert labels if (labels.length > 0) { const labelStmt = this.db.prepare(` - INSERT INTO variable_labels (variable_id, name) - VALUES (?, ?) + INSERT INTO variable_labels (variable_name, variable_schema, name) + VALUES (?, ?, ?) `); - for (const name of labels) { - labelStmt.run(id, name); + for (const labelName of labels) { + labelStmt.run(name, schema, labelName); } } @@ -192,10 +363,9 @@ export class VariableStore { } return { - id, - scope, - value, + name, schema, + value, created: now, updated: now, tags, @@ -204,54 +374,27 @@ export class VariableStore { } /** - * Load tags for a variable + * Get a variable by name, optionally with schema */ - private loadTags(id: VariableId): Record { - const stmt = this.db.prepare(` - SELECT key, value - FROM variable_tags - WHERE variable_id = ? - `); - - const rows = stmt.all(id) as Array<{ key: string; value: string }>; - const tags: Record = {}; - for (const row of rows) { - tags[row.key] = row.value; - } - return tags; - } - /** - * Load labels for a variable + * Get a variable by name and schema + * @param name - Variable name + * @param schema - Schema hash (required) + * @returns Variable if found, null otherwise */ - private loadLabels(id: VariableId): string[] { + get(name: string, schema: Hash): Variable | null { + // Precise match with schema const stmt = this.db.prepare(` - SELECT name - FROM variable_labels - WHERE variable_id = ? - ORDER BY name ASC - `); - - const rows = stmt.all(id) as Array<{ name: string }>; - return rows.map((row) => row.name); - } - - /** - * Get a variable by ID - */ - get(id: VariableId): Variable | null { - const stmt = this.db.prepare(` - SELECT id, scope, value, schema, created, updated + SELECT name, schema, value, created, updated FROM variables - WHERE id = ? + WHERE name = ? AND schema = ? `); - const row = stmt.get(id) as + const row = stmt.get(name, schema) as | { - id: string; - scope: string; - value: string; + name: string; schema: string; + value: string; created: number; updated: number; } @@ -262,14 +405,13 @@ export class VariableStore { return null; } - const tags = this.loadTags(row.id); - const labels = this.loadLabels(row.id); + const tags = this.loadTags(row.name, row.schema); + const labels = this.loadLabels(row.name, row.schema); return { - id: row.id, - scope: row.scope, - value: row.value, + name: row.name, schema: row.schema, + value: row.value, created: row.created, updated: row.updated, tags, @@ -280,10 +422,13 @@ export class VariableStore { /** * Update a variable's value (with schema validation) */ - update(id: VariableId, value: string): Variable { - const existing = this.get(id); + update(name: string, schema: Hash, value: string): Variable { + // Validate name format + this.validateName(name); + + const existing = this.get(name, schema); if (existing === null) { - throw new VariableNotFoundError(id); + throw new VariableNotFoundError(name, schema); } const newSchema = this.extractSchema(value); @@ -296,10 +441,10 @@ export class VariableStore { const stmt = this.db.prepare(` UPDATE variables SET value = ?, updated = ? - WHERE id = ? + WHERE name = ? AND schema = ? `); - stmt.run(value, now, id); + stmt.run(value, now, name, schema); return { ...existing, @@ -309,43 +454,69 @@ export class VariableStore { } /** - * Delete a variable + * Remove a variable (or all variants if schema omitted) */ - delete(id: VariableId): Variable { - const existing = this.get(id); - if (existing === null) { - throw new VariableNotFoundError(id); + remove(name: string): Variable[]; + remove(name: string, schema: Hash): Variable; + remove(name: string, schema?: Hash): Variable | Variable[] { + if (schema !== undefined) { + // Remove specific (name, schema) variant + const existing = this.get(name, schema); + if (existing === null) { + throw new VariableNotFoundError(name, schema); + } + + const stmt = this.db.prepare(` + DELETE FROM variables WHERE name = ? AND schema = ? + `); + + stmt.run(name, schema); + + return existing; + } + + // Remove all schema variants for this name + const variants = this.list({ exactName: name }); + + if (variants.length === 0) { + return []; } const stmt = this.db.prepare(` - DELETE FROM variables WHERE id = ? + DELETE FROM variables WHERE name = ? `); - stmt.run(id); + stmt.run(name); - return existing; + return variants; } /** - * List variables matching a scope prefix + * List variables with optional filters */ list(options?: { - scope?: string; + namePrefix?: string; + exactName?: string; + schema?: Hash; tags?: Record; labels?: string[]; }): Variable[] { - const scope = options?.scope ?? ""; + // Validate mutually exclusive options + if (options?.namePrefix !== undefined && options?.exactName !== undefined) { + throw new Error( + "namePrefix and exactName are mutually exclusive - cannot specify both", + ); + } + + const namePrefix = options?.namePrefix ?? ""; + const exactName = options?.exactName; + const schema = options?.schema; const filterTags = options?.tags ?? {}; const filterLabels = options?.labels ?? []; - // Validate scope format (must end with / if non-empty) - if (scope !== "" && !scope.endsWith("/")) { - throw new InvalidScopeError(scope); - } - - // Build query with tag/label filtering + // Build query with filters let query = ` - SELECT DISTINCT v.id, v.scope, v.value, v.schema, v.created, v.updated + SELECT DISTINCT v.name, v.schema, v.value, v.created, v.updated FROM variables v `; @@ -357,7 +528,8 @@ export class VariableStore { const key = tagKeys[i] as string; const value = filterTags[key] as string; query += ` - INNER JOIN variable_tags t${i} ON v.id = t${i}.variable_id + INNER JOIN variable_tags t${i} ON v.name = t${i}.variable_name + AND v.schema = t${i}.variable_schema AND t${i}.key = ? AND t${i}.value = ? `; params.push(key, value); @@ -367,36 +539,52 @@ export class VariableStore { for (let i = 0; i < filterLabels.length; i++) { const label = filterLabels[i] as string; query += ` - INNER JOIN variable_labels l${i} ON v.id = l${i}.variable_id + INNER JOIN variable_labels l${i} ON v.name = l${i}.variable_name + AND v.schema = l${i}.variable_schema AND l${i}.name = ? `; params.push(label); } - // Scope filter (always present) - query += " WHERE v.scope LIKE ? || '%'"; - params.push(scope); + // WHERE clause for name filters and schema + const whereClauses: string[] = []; + + if (exactName !== undefined) { + whereClauses.push("v.name = ?"); + params.push(exactName); + } else if (namePrefix !== "") { + whereClauses.push("v.name LIKE ? || '%'"); + params.push(namePrefix); + } + + if (schema !== undefined) { + whereClauses.push("v.schema = ?"); + params.push(schema); + } + + if (whereClauses.length > 0) { + query += ` WHERE ${whereClauses.join(" AND ")}`; + } + query += " ORDER BY v.created ASC"; const stmt = this.db.prepare(query); const rows = stmt.all(...params) as Array<{ - id: string; - scope: string; - value: string; + name: string; schema: string; + value: string; created: number; updated: number; }>; return rows.map((row) => ({ - id: row.id, - scope: row.scope, - value: row.value, + name: row.name, schema: row.schema, + value: row.value, created: row.created, updated: row.updated, - tags: this.loadTags(row.id), - labels: this.loadLabels(row.id), + tags: this.loadTags(row.name, row.schema), + labels: this.loadLabels(row.name, row.schema), })); } @@ -404,16 +592,20 @@ export class VariableStore { * Add/update/delete tags and labels */ tag( - id: VariableId, + name: string, + schema: Hash, operations: { add?: Record; // tags to add/update addLabels?: string[]; // labels to add delete?: string[]; // tag keys or label names to delete }, ): Variable { - const existing = this.get(id); + // Validate name format + this.validateName(name); + + const existing = this.get(name, schema); if (existing === null) { - throw new VariableNotFoundError(id); + throw new VariableNotFoundError(name, schema); } const addTags = operations.add ?? {}; @@ -433,14 +625,17 @@ export class VariableStore { } } - for (const name of addLabels) { + for (const labelName of addLabels) { // Check if this name is being added as a tag in the same operation - if (newTagKeys.includes(name)) { - throw new TagLabelConflictError(name, "tag", "label"); + if (newTagKeys.includes(labelName)) { + throw new TagLabelConflictError(labelName, "tag", "label"); } // Check if this name already exists as a tag key (and not being deleted) - if (existing.tags[name] !== undefined && !deleteNames.includes(name)) { - throw new TagLabelConflictError(name, "tag", "label"); + if ( + existing.tags[labelName] !== undefined && + !deleteNames.includes(labelName) + ) { + throw new TagLabelConflictError(labelName, "tag", "label"); } } @@ -451,43 +646,43 @@ export class VariableStore { try { // Update timestamp const updateStmt = this.db.prepare(` - UPDATE variables SET updated = ? WHERE id = ? + UPDATE variables SET updated = ? WHERE name = ? AND schema = ? `); - updateStmt.run(now, id); + updateStmt.run(now, name, schema); // Delete tags and labels if (deleteNames.length > 0) { const deleteTagStmt = this.db.prepare(` - DELETE FROM variable_tags WHERE variable_id = ? AND key = ? + DELETE FROM variable_tags WHERE variable_name = ? AND variable_schema = ? AND key = ? `); const deleteLabelStmt = this.db.prepare(` - DELETE FROM variable_labels WHERE variable_id = ? AND name = ? + DELETE FROM variable_labels WHERE variable_name = ? AND variable_schema = ? AND name = ? `); - for (const name of deleteNames) { - deleteTagStmt.run(id, name); - deleteLabelStmt.run(id, name); + for (const deleteName of deleteNames) { + deleteTagStmt.run(name, schema, deleteName); + deleteLabelStmt.run(name, schema, deleteName); } } // Add or update tags if (newTagKeys.length > 0) { const tagStmt = this.db.prepare(` - INSERT OR REPLACE INTO variable_tags (variable_id, key, value) - VALUES (?, ?, ?) + INSERT OR REPLACE INTO variable_tags (variable_name, variable_schema, key, value) + VALUES (?, ?, ?, ?) `); for (const [key, value] of Object.entries(addTags)) { - tagStmt.run(id, key, value); + tagStmt.run(name, schema, key, value); } } // Add labels (with conflict handling) if (addLabels.length > 0) { const labelStmt = this.db.prepare(` - INSERT OR IGNORE INTO variable_labels (variable_id, name) - VALUES (?, ?) + INSERT OR IGNORE INTO variable_labels (variable_name, variable_schema, name) + VALUES (?, ?, ?) `); - for (const name of addLabels) { - labelStmt.run(id, name); + for (const labelName of addLabels) { + labelStmt.run(name, schema, labelName); } } @@ -498,9 +693,9 @@ export class VariableStore { } // Return updated variable - const updated = this.get(id); + const updated = this.get(name, schema); if (updated === null) { - throw new VariableNotFoundError(id); + throw new VariableNotFoundError(name, schema); } return updated; } diff --git a/packages/json-cas/src/variable-tags-labels.test.ts b/packages/json-cas/src/variable-tags-labels.test.ts deleted file mode 100644 index 5b6d295..0000000 --- a/packages/json-cas/src/variable-tags-labels.test.ts +++ /dev/null @@ -1,740 +0,0 @@ -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 { - TagLabelConflictError, - VariableNotFoundError, - VariableStore, -} from "./variable-store.js"; - -describe("VariableStore - Tags and Labels (RFC-20 Phase 2)", () => { - let store: Store; - let varStore: VariableStore; - let dbPath: string; - let schemaHash: string; - let hashA: string; - let hashB: string; - let hashC: string; - - beforeEach(async () => { - dbPath = join(tmpdir(), `test-variables-phase2-${Date.now()}.db`); - store = createMemoryStore(); - - // Create test schema - schemaHash = await store.put("BOOTSTRAPHASH", { - type: "object", - properties: { name: { type: "string" } }, - }); - - // Create test CAS nodes - hashA = await store.put(schemaHash, { name: "a" }); - hashB = await store.put(schemaHash, { name: "b" }); - hashC = await store.put(schemaHash, { name: "c" }); - - varStore = new VariableStore(dbPath, store); - }); - - afterEach(() => { - varStore.close(); - try { - unlinkSync(dbPath); - } catch { - // Ignore cleanup errors - } - }); - - describe("Test Group 0: Setup and Backward Compatibility", () => { - test("0.1: Create variable without tags/labels", () => { - const variable = varStore.create("uwf/thread/", hashA); - - expect(variable.tags).toEqual({}); - expect(variable.labels).toEqual([]); - expect(variable.id).toMatch(/^[0-9A-HJKMNP-TV-Z]{26}$/); - expect(variable.scope).toBe("uwf/thread/"); - expect(variable.value).toBe(hashA); - }); - - test("0.2: Get variable returns empty tags and labels", () => { - const created = varStore.create("uwf/thread/", hashA); - const retrieved = varStore.get(created.id); - - expect(retrieved).not.toBeNull(); - expect(retrieved?.tags).toEqual({}); - expect(retrieved?.labels).toEqual([]); - }); - - test("0.3: Create variable with initial tags", () => { - const variable = varStore.create("uwf/thread/", hashA, { - tags: { status: "active", workflow: "solve-issue" }, - }); - - expect(variable.tags).toEqual({ - status: "active", - workflow: "solve-issue", - }); - expect(variable.labels).toEqual([]); - }); - - test("0.4: Create variable with initial labels", () => { - const variable = varStore.create("uwf/workflow/", hashC, { - labels: ["pinned"], - }); - - expect(variable.tags).toEqual({}); - expect(variable.labels).toEqual(["pinned"]); - }); - - test("0.5: Create variable with both tags and labels", () => { - const variable = varStore.create("uwf/thread/", hashA, { - tags: { status: "active" }, - labels: ["pinned"], - }); - - expect(variable.tags).toEqual({ status: "active" }); - expect(variable.labels).toEqual(["pinned"]); - }); - - test("0.6: Create variable with conflicting tag/label throws error", () => { - expect(() => - varStore.create("uwf/thread/", hashA, { - tags: { workflow: "solve-issue" }, - labels: ["workflow"], - }), - ).toThrow(TagLabelConflictError); - }); - }); - - describe("Test Group 1: Tag Operations", () => { - test("1.1: Add tag to existing variable", async () => { - const variable = varStore.create("uwf/thread/", hashA, { - tags: { status: "active" }, - }); - - await new Promise((resolve) => setTimeout(resolve, 10)); - - const updated = varStore.tag(variable.id, { - add: { priority: "high" }, - }); - - expect(updated.tags).toEqual({ - status: "active", - priority: "high", - }); - expect(updated.updated).toBeGreaterThan(variable.updated); - }); - - test("1.2: Tag same-key override", () => { - const variable = varStore.create("uwf/thread/", hashA, { - tags: { status: "active" }, - }); - - const updated = varStore.tag(variable.id, { - add: { status: "completed" }, - }); - - expect(updated.tags).toEqual({ status: "completed" }); - expect(Object.keys(updated.tags)).toHaveLength(1); - }); - - test("1.3: Delete tag using delete array", () => { - const variable = varStore.create("uwf/thread/", hashA, { - tags: { status: "active", workflow: "solve-issue" }, - }); - - const updated = varStore.tag(variable.id, { - delete: ["status"], - }); - - expect(updated.tags).toEqual({ workflow: "solve-issue" }); - expect(updated.tags.status).toBeUndefined(); - }); - - test("1.4: Delete non-existent tag is idempotent", () => { - const variable = varStore.create("uwf/thread/", hashA, { - tags: { status: "active" }, - }); - - const updated = varStore.tag(variable.id, { - delete: ["nonexistent"], - }); - - expect(updated.tags).toEqual({ status: "active" }); - }); - - test("1.5: Multiple tag operations in single call", () => { - const variable = varStore.create("uwf/thread/", hashA, { - tags: { status: "active", workflow: "solve-issue" }, - }); - - const updated = varStore.tag(variable.id, { - add: { env: "production", region: "us-west" }, - delete: ["workflow"], - }); - - expect(updated.tags).toEqual({ - status: "active", - env: "production", - region: "us-west", - }); - }); - - test("1.6: Delete then add same key in single operation", () => { - const variable = varStore.create("uwf/thread/", hashA, { - tags: { status: "active" }, - }); - - const updated = varStore.tag(variable.id, { - delete: ["status"], - add: { status: "new" }, - }); - - expect(updated.tags).toEqual({ status: "new" }); - }); - }); - - describe("Test Group 2: Label Operations", () => { - test("2.1: Add label to existing variable", () => { - const variable = varStore.create("uwf/thread/", hashA); - - const updated = varStore.tag(variable.id, { - addLabels: ["archived"], - }); - - expect(updated.labels).toContain("archived"); - expect(updated.labels).toHaveLength(1); - }); - - test("2.2: Delete label using delete array", () => { - const variable = varStore.create("uwf/thread/", hashA, { - labels: ["archived", "pinned"], - }); - - const updated = varStore.tag(variable.id, { - delete: ["archived"], - }); - - expect(updated.labels).toEqual(["pinned"]); - }); - - test("2.3: Add duplicate label is idempotent", () => { - const variable = varStore.create("uwf/workflow/", hashC, { - labels: ["pinned"], - }); - - const updated = varStore.tag(variable.id, { - addLabels: ["pinned"], - }); - - expect(updated.labels).toEqual(["pinned"]); - }); - - test("2.4: Multiple label operations in single call", () => { - const variable = varStore.create("uwf/thread/", hashA, { - labels: ["archived"], - }); - - const updated = varStore.tag(variable.id, { - addLabels: ["experimental", "deprecated"], - delete: ["archived"], - }); - - expect(updated.labels).toHaveLength(2); - expect(updated.labels).toContain("experimental"); - expect(updated.labels).toContain("deprecated"); - expect(updated.labels).not.toContain("archived"); - }); - }); - - describe("Test Group 3: Tag/Label Mutual Exclusion", () => { - test("3.1: Label conflicts with existing tag key", () => { - const variable = varStore.create("uwf/thread/", hashA, { - tags: { workflow: "solve-issue" }, - }); - - expect(() => - varStore.tag(variable.id, { - addLabels: ["workflow"], - }), - ).toThrow(TagLabelConflictError); - - // Verify variable state unchanged - const retrieved = varStore.get(variable.id); - expect(retrieved?.tags).toEqual({ workflow: "solve-issue" }); - expect(retrieved?.labels).toEqual([]); - }); - - test("3.2: Tag conflicts with existing label", () => { - const variable = varStore.create("uwf/workflow/", hashC, { - labels: ["pinned"], - }); - - expect(() => - varStore.tag(variable.id, { - add: { pinned: "true" }, - }), - ).toThrow(TagLabelConflictError); - - // Verify variable state unchanged - const retrieved = varStore.get(variable.id); - expect(retrieved?.tags).toEqual({}); - expect(retrieved?.labels).toEqual(["pinned"]); - }); - - test("3.3: Delete then add resolves conflict", () => { - const variable = varStore.create("uwf/workflow/", hashC, { - labels: ["pinned"], - }); - - const updated = varStore.tag(variable.id, { - delete: ["pinned"], - add: { pinned: "true" }, - }); - - expect(updated.tags).toEqual({ pinned: "true" }); - expect(updated.labels).toEqual([]); - }); - - test("3.4: Simultaneous conflicting operations in same call", () => { - const variable = varStore.create("uwf/thread/", hashA); - - expect(() => - varStore.tag(variable.id, { - add: { newkey: "value" }, - addLabels: ["newkey"], - }), - ).toThrow(TagLabelConflictError); - }); - }); - - describe("Test Group 4: Query - Scope Filtering", () => { - test("4.1: List with exact scope match", () => { - const var1 = varStore.create("uwf/thread/", hashA, { - tags: { status: "active" }, - }); - const var2 = varStore.create("uwf/thread/", hashB, { - tags: { status: "completed" }, - }); - varStore.create("uwf/workflow/", hashC); - - const results = varStore.list({ scope: "uwf/thread/" }); - - expect(results).toHaveLength(2); - expect(results.map((v) => v.id)).toContain(var1.id); - expect(results.map((v) => v.id)).toContain(var2.id); - }); - - test("4.2: List with scope prefix match", () => { - const var1 = varStore.create("uwf/thread/", hashA); - const var2 = varStore.create("uwf/thread/", hashB); - const var3 = varStore.create("uwf/workflow/", hashC); - - const results = varStore.list({ scope: "uwf/" }); - - 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("4.3: List all variables (no scope filter)", () => { - const var1 = varStore.create("uwf/thread/", hashA); - const var2 = varStore.create("app/config/", hashB); - - const results = varStore.list(); - - expect(results).toHaveLength(2); - expect(results.map((v) => v.id)).toContain(var1.id); - expect(results.map((v) => v.id)).toContain(var2.id); - }); - - test("4.4: List with non-matching scope returns empty", () => { - varStore.create("uwf/thread/", hashA); - - const results = varStore.list({ scope: "app/config/" }); - - expect(results).toEqual([]); - }); - }); - - describe("Test Group 5: Query - Tag Filtering", () => { - test("5.1: Filter by tag key-value pair", () => { - const var1 = varStore.create("uwf/thread/", hashA, { - tags: { status: "completed" }, - }); - const var2 = varStore.create("uwf/thread/", hashB, { - tags: { status: "completed" }, - }); - varStore.create("uwf/thread/", hashC, { - tags: { status: "active" }, - }); - - const results = varStore.list({ - tags: { status: "completed" }, - }); - - expect(results).toHaveLength(2); - expect(results.map((v) => v.id)).toContain(var1.id); - expect(results.map((v) => v.id)).toContain(var2.id); - }); - - test("5.2: Filter by non-existent tag returns empty", () => { - varStore.create("uwf/thread/", hashA, { - tags: { status: "active" }, - }); - - const results = varStore.list({ - tags: { nonexistent: "value" }, - }); - - expect(results).toEqual([]); - }); - - test("5.3: Multiple tag filters use AND logic", () => { - const var1 = varStore.create("uwf/thread/", hashA, { - tags: { status: "completed", priority: "high" }, - }); - varStore.create("uwf/thread/", hashB, { - tags: { status: "completed", priority: "low" }, - }); - varStore.create("uwf/thread/", hashC, { - tags: { status: "active", priority: "high" }, - }); - - const results = varStore.list({ - tags: { status: "completed", priority: "high" }, - }); - - expect(results).toHaveLength(1); - expect(results[0]?.id).toBe(var1.id); - }); - }); - - describe("Test Group 6: Query - Label Filtering", () => { - test("6.1: Filter by label", () => { - const var1 = varStore.create("uwf/workflow/", hashA, { - labels: ["pinned"], - }); - varStore.create("uwf/workflow/", hashB); - - const results = varStore.list({ - labels: ["pinned"], - }); - - expect(results).toHaveLength(1); - expect(results[0]?.id).toBe(var1.id); - }); - - test("6.2: Filter by non-existent label returns empty", () => { - varStore.create("uwf/workflow/", hashA, { - labels: ["pinned"], - }); - - const results = varStore.list({ - labels: ["nonexistent"], - }); - - expect(results).toEqual([]); - }); - - test("6.3: Multiple label filters use AND logic", () => { - const var1 = varStore.create("uwf/thread/", hashA, { - labels: ["experimental", "deprecated"], - }); - varStore.create("uwf/thread/", hashB, { - labels: ["experimental"], - }); - - const results = varStore.list({ - labels: ["experimental", "deprecated"], - }); - - expect(results).toHaveLength(1); - expect(results[0]?.id).toBe(var1.id); - }); - }); - - describe("Test Group 7: Query - Combined Filtering", () => { - test("7.1: Scope + tag filter", () => { - const var1 = varStore.create("uwf/thread/", hashA, { - tags: { status: "completed" }, - }); - const var2 = varStore.create("uwf/thread/", hashB, { - tags: { status: "completed" }, - }); - varStore.create("uwf/workflow/", hashC, { - tags: { status: "completed" }, - }); - - const results = varStore.list({ - scope: "uwf/thread/", - tags: { status: "completed" }, - }); - - expect(results).toHaveLength(2); - expect(results.map((v) => v.id)).toContain(var1.id); - expect(results.map((v) => v.id)).toContain(var2.id); - }); - - test("7.2: Scope + label filter", () => { - const var1 = varStore.create("uwf/workflow/", hashA, { - labels: ["pinned"], - }); - varStore.create("uwf/thread/", hashB, { - labels: ["pinned"], - }); - - const results = varStore.list({ - scope: "uwf/workflow/", - labels: ["pinned"], - }); - - expect(results).toHaveLength(1); - expect(results[0]?.id).toBe(var1.id); - }); - - test("7.3: Scope + multiple filters", () => { - const var1 = varStore.create("uwf/thread/", hashA, { - tags: { status: "completed", priority: "high" }, - }); - varStore.create("uwf/thread/", hashB, { - tags: { status: "completed" }, - }); - varStore.create("uwf/workflow/", hashC, { - tags: { status: "completed", priority: "high" }, - }); - - const results = varStore.list({ - scope: "uwf/", - tags: { status: "completed", priority: "high" }, - }); - - expect(results).toHaveLength(2); - expect(results.map((v) => v.id)).toContain(var1.id); - }); - - test("7.4: Combined filters with no matches", () => { - varStore.create("uwf/thread/", hashA, { - tags: { status: "active" }, - }); - - const results = varStore.list({ - scope: "app/", - tags: { status: "completed" }, - }); - - expect(results).toEqual([]); - }); - }); - - describe("Test Group 8: Edge Cases and Error Handling", () => { - test("8.1: Tag operation on non-existent variable", () => { - const fakeId = "01ARZ3NDEKTSV4RRFFQ69G5FAV"; - - expect(() => - varStore.tag(fakeId, { - add: { key: "value" }, - }), - ).toThrow(VariableNotFoundError); - }); - - test("8.2: Special characters in tag keys/values", () => { - const variable = varStore.create("uwf/thread/", hashA); - - const updated = varStore.tag(variable.id, { - add: { "env:region": "prod-us_west.2" }, - }); - - expect(updated.tags).toEqual({ "env:region": "prod-us_west.2" }); - }); - - test("8.3: Unicode in tag/label names", () => { - const variable = varStore.create("uwf/thread/", hashA); - - const updated = varStore.tag(variable.id, { - add: { 语言: "中文" }, - addLabels: ["测试"], - }); - - expect(updated.tags).toEqual({ 语言: "中文" }); - expect(updated.labels).toContain("测试"); - - // Verify persistence - const retrieved = varStore.get(variable.id); - expect(retrieved?.tags).toEqual({ 语言: "中文" }); - expect(retrieved?.labels).toContain("测试"); - }); - - test("8.4: Empty tag key or value", () => { - const variable = varStore.create("uwf/thread/", hashA); - - // Empty key - const updated1 = varStore.tag(variable.id, { - add: { "": "value" }, - }); - expect(updated1.tags).toEqual({ "": "value" }); - - // Empty value - const updated2 = varStore.tag(variable.id, { - add: { key: "" }, - }); - expect(updated2.tags.key).toBe(""); - }); - - test("8.5: Very long tag key/value", () => { - const variable = varStore.create("uwf/thread/", hashA); - const longKey = "k".repeat(1000); - const longValue = "v".repeat(1000); - - const updated = varStore.tag(variable.id, { - add: { [longKey]: longValue }, - }); - - expect(updated.tags[longKey]).toBe(longValue); - }); - }); - - describe("Test Group 9: Database Integrity", () => { - test("9.1: Cascade delete for tags", () => { - const variable = varStore.create("uwf/thread/", hashA, { - tags: { status: "active", workflow: "solve-issue" }, - }); - - varStore.delete(variable.id); - - // Verify variable is deleted - const retrieved = varStore.get(variable.id); - expect(retrieved).toBeNull(); - }); - - test("9.2: Cascade delete for labels", () => { - const variable = varStore.create("uwf/workflow/", hashA, { - labels: ["pinned", "archived"], - }); - - varStore.delete(variable.id); - - const retrieved = varStore.get(variable.id); - expect(retrieved).toBeNull(); - }); - - test("9.3: Tag update preserves other variable data", () => { - const variable = varStore.create("uwf/thread/", hashA, { - tags: { status: "active" }, - }); - - varStore.tag(variable.id, { - add: { priority: "high" }, - }); - - const retrieved = varStore.get(variable.id); - expect(retrieved?.id).toBe(variable.id); - expect(retrieved?.scope).toBe(variable.scope); - expect(retrieved?.value).toBe(variable.value); - expect(retrieved?.schema).toBe(variable.schema); - expect(retrieved?.created).toBe(variable.created); - }); - }); - - describe("Test Group 10: Batch Operations and Atomicity", () => { - test("10.1: Atomic tag operations", () => { - const variable = varStore.create("uwf/thread/", hashA, { - tags: { status: "active", workflow: "solve-issue" }, - }); - - const updated = varStore.tag(variable.id, { - add: { priority: "low" }, - addLabels: ["archived"], - delete: ["status"], - }); - - expect(updated.tags).toEqual({ - workflow: "solve-issue", - priority: "low", - }); - expect(updated.labels).toContain("archived"); - }); - - test("10.2: Rollback on conflict error", () => { - const variable = varStore.create("uwf/thread/", hashA, { - tags: { workflow: "solve-issue" }, - }); - - expect(() => - varStore.tag(variable.id, { - add: { priority: "high" }, - addLabels: ["workflow"], // Conflict! - }), - ).toThrow(TagLabelConflictError); - - // Verify NO changes applied - const retrieved = varStore.get(variable.id); - expect(retrieved?.tags).toEqual({ workflow: "solve-issue" }); - expect(retrieved?.labels).toEqual([]); - }); - }); - - describe("Test Group 11: Integration Tests", () => { - test("11.1: Full workflow with tags and labels", async () => { - // Create with initial tags - const var1 = varStore.create("uwf/thread/", hashA, { - tags: { status: "active" }, - }); - - await new Promise((resolve) => setTimeout(resolve, 10)); - - // Add more tags - varStore.tag(var1.id, { - add: { priority: "high", workflow: "solve-issue" }, - }); - - // Add labels - varStore.tag(var1.id, { - addLabels: ["pinned"], - }); - - // Update variable value - const updated = varStore.update(var1.id, hashB); - - // Verify tags/labels preserved - expect(updated.tags).toEqual({ - status: "active", - priority: "high", - workflow: "solve-issue", - }); - expect(updated.labels).toContain("pinned"); - - // Delete variable - varStore.delete(var1.id); - - // Verify deletion - const retrieved = varStore.get(var1.id); - expect(retrieved).toBeNull(); - }); - - test("11.2: Query with complex filtering", () => { - const var1 = varStore.create("uwf/thread/", hashA, { - tags: { status: "completed", priority: "high" }, - labels: ["archived"], - }); - varStore.create("uwf/thread/", hashB, { - tags: { status: "completed", priority: "low" }, - }); - varStore.create("uwf/workflow/", hashC, { - tags: { status: "completed", priority: "high" }, - labels: ["archived"], - }); - - const results = varStore.list({ - scope: "uwf/thread/", - tags: { status: "completed", priority: "high" }, - labels: ["archived"], - }); - - expect(results).toHaveLength(1); - expect(results[0]?.id).toBe(var1.id); - }); - }); -}); diff --git a/packages/json-cas/src/variable.test.ts b/packages/json-cas/src/variable.test.ts new file mode 100644 index 0000000..0c14eeb --- /dev/null +++ b/packages/json-cas/src/variable.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, test } from "bun:test"; +import type { Variable } from "./variable.js"; + +describe("Variable Type", () => { + test("Variable type uses (name, schema) composite key", () => { + const variable: Variable = { + name: "config", + schema: "ABC123DEF4567", + value: "XYZ789GHI0123", + created: 1234567890000, + updated: 1234567890000, + tags: { env: "prod" }, + labels: ["critical"], + }; + + expect(variable.name).toBe("config"); + expect(variable.schema).toBe("ABC123DEF4567"); + // id and scope should not exist + expect((variable as unknown as { id?: unknown }).id).toBeUndefined(); + expect((variable as unknown as { scope?: unknown }).scope).toBeUndefined(); + }); +}); diff --git a/packages/json-cas/src/variable.ts b/packages/json-cas/src/variable.ts index 3d74134..f475e4d 100644 --- a/packages/json-cas/src/variable.ts +++ b/packages/json-cas/src/variable.ts @@ -1,18 +1,13 @@ import type { Hash } from "./types.js"; -/** - * ULID identifier (26-character Crockford Base32) - */ -export type VariableId = string; - /** * Variable: mutable binding to an immutable CAS node + * Identified by composite key (name, schema) */ export type Variable = { - id: VariableId; - scope: string; // hierarchical path, must end with / + name: string; // variable name (unique per schema) + schema: Hash; // schema hash (part of composite key) value: Hash; // CAS node hash - schema: Hash; // extracted from value's CAS node.type created: number; // epoch ms updated: number; // epoch ms tags: Record; // key-value pairs