diff --git a/packages/cli-json-cas/src/index.ts b/packages/cli-json-cas/src/index.ts index aad4f8c..9c2fe12 100644 --- a/packages/cli-json-cas/src/index.ts +++ b/packages/cli-json-cas/src/index.ts @@ -62,7 +62,7 @@ const storePath = typeof flags.store === "string" ? flags.store : defaultStorePath; const compact = flags.json === true; -const defaultVarDbPath = join(defaultStorePath, "variables.db"); +const defaultVarDbPath = join(storePath, "variables.db"); const varDbPath = typeof flags["var-db"] === "string" ? flags["var-db"] : defaultVarDbPath; @@ -95,6 +95,46 @@ function openVarStore(): VariableStore { return createVariableStore(resolve(varDbPath), store); } +/** + * Get the Variable schema's CAS hash + * This is the type hash used in JSON envelopes + */ +async function getVariableSchemaHash(): Promise { + const store = openStore(); + + // Define the Variable JSON Schema (simple version for envelope) + const variableSchema: JSONSchema = { + title: "Variable", + type: "object", + properties: { + id: { type: "string" }, + scope: { type: "string" }, + value: { type: "string" }, + schema: { type: "string" }, + created: { type: "number" }, + updated: { type: "number" }, + }, + required: ["id", "scope", "value", "schema", "created", "updated"], + }; + + // Compute hash or retrieve from store + const hash = await putSchema(store, variableSchema); + return hash; +} + +/** + * Wrap Variable output in JSON envelope + */ +async function wrapVariableEnvelope( + variable: unknown, +): Promise<{ type: Hash; value: unknown }> { + const typeHash = await getVariableSchemaHash(); + return { + type: typeHash, + value: variable, + }; +} + // ---- Commands ---- async function cmdInit(): Promise { @@ -276,7 +316,8 @@ async function cmdVarCreate(_args: string[]): Promise { try { const variable = varStore.create(scope, value); - out(variable); + const envelope = await wrapVariableEnvelope(variable); + out(envelope); } catch (e) { if (e instanceof InvalidScopeError || e instanceof CasNodeNotFoundError) { die(`Error: ${e.message}`); @@ -298,7 +339,8 @@ async function cmdVarGet(args: string[]): Promise { if (variable === null) { die(`Error: Variable not found: ${id}`); } - out(variable); + const envelope = await wrapVariableEnvelope(variable); + out(envelope); } finally { varStore.close(); } @@ -316,7 +358,8 @@ async function cmdVarUpdate(args: string[]): Promise { try { const variable = varStore.update(id, value); - out(variable); + const envelope = await wrapVariableEnvelope(variable); + out(envelope); } catch (e) { if ( e instanceof VariableNotFoundError || @@ -339,7 +382,8 @@ async function cmdVarDelete(args: string[]): Promise { try { const variable = varStore.delete(id); - out(variable); + const envelope = await wrapVariableEnvelope(variable); + out(envelope); } catch (e) { if (e instanceof VariableNotFoundError) { die(`Error: ${e.message}`); @@ -350,6 +394,25 @@ async function cmdVarDelete(args: string[]): Promise { } } +async function cmdVarList(_args: string[]): Promise { + const scope = (flags.scope as string | undefined) ?? ""; + + const varStore = openVarStore(); + + try { + const variables = varStore.list({ scope }); + const envelope = await wrapVariableEnvelope(variables); + out(envelope); + } catch (e) { + if (e instanceof InvalidScopeError) { + die(`Error: ${e.message}`); + } + throw e; + } finally { + varStore.close(); + } +} + function printUsage(): void { console.log(`\ Usage: json-cas [--store ] [--json] [args] @@ -373,6 +436,7 @@ Commands: var get Get a variable by ID var update Update variable value var delete Delete a variable + var list [--scope ] List variables (optionally filter by scope prefix) Flags: --store Store directory (default: ~/.uncaged/json-cas) @@ -466,6 +530,9 @@ switch (cmd) { case "delete": await cmdVarDelete(subRest); break; + case "list": + await cmdVarList(subRest); + break; default: die(`Unknown var subcommand: ${sub ?? "(none)"}`); } diff --git a/packages/cli-json-cas/src/var.test.ts b/packages/cli-json-cas/src/var.test.ts index 8ba8785..f96a2ce 100644 --- a/packages/cli-json-cas/src/var.test.ts +++ b/packages/cli-json-cas/src/var.test.ts @@ -11,10 +11,12 @@ describe("CLI var commands", () => { let schemaHash: string; let hashA: string; let hashB: string; + let testCounter = 0; beforeEach(async () => { - // Create temporary paths - storePath = join(tmpdir(), `test-cli-store-${Date.now()}`); + // 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"); @@ -100,12 +102,18 @@ describe("CLI var commands", () => { expect(result.status).toBe(0); const output = JSON.parse(result.stdout); - expect(output.id).toMatch(/^[0-9A-HJKMNP-TV-Z]{26}$/); - expect(output.scope).toBe("uwf/thread/"); - expect(output.value).toBe(hashA); - expect(output.schema).toBe(schemaHash); - expect(output.created).toBeGreaterThan(Date.now() - 5000); - expect(output.updated).toBe(output.created); + // 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 /", () => { @@ -170,7 +178,7 @@ describe("CLI var commands", () => { ], { encoding: "utf-8" }, ); - const created = JSON.parse(createResult.stdout); + const created = JSON.parse(createResult.stdout).value; // Get the variable const result = spawnSync( @@ -182,10 +190,16 @@ describe("CLI var commands", () => { expect(result.status).toBe(0); const output = JSON.parse(result.stdout); - expect(output.id).toBe(created.id); - expect(output.scope).toBe("uwf/thread/"); - expect(output.value).toBe(hashA); - expect(output.schema).toBe(schemaHash); + // 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", () => { @@ -219,7 +233,7 @@ describe("CLI var commands", () => { ], { encoding: "utf-8" }, ); - const created = JSON.parse(createResult.stdout); + const created = JSON.parse(createResult.stdout).value; // Wait a bit to ensure different timestamp await new Promise((resolve) => setTimeout(resolve, 10)); @@ -234,10 +248,16 @@ describe("CLI var commands", () => { expect(result.status).toBe(0); const output = JSON.parse(result.stdout); - expect(output.id).toBe(created.id); - expect(output.value).toBe(hashB); - expect(output.schema).toBe(schemaHash); - expect(output.updated).toBeGreaterThan(created.created); + // 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); }); }); @@ -287,7 +307,7 @@ describe("CLI var commands", () => { ], { encoding: "utf-8" }, ); - const created = JSON.parse(createResult.stdout); + const created = JSON.parse(createResult.stdout).value; // Try to update with different schema const result = spawnSync( @@ -319,7 +339,7 @@ describe("CLI var commands", () => { ], { encoding: "utf-8" }, ); - const created = JSON.parse(createResult.stdout); + const created = JSON.parse(createResult.stdout).value; // Delete the variable const result = spawnSync( @@ -331,7 +351,13 @@ describe("CLI var commands", () => { expect(result.status).toBe(0); const output = JSON.parse(result.stdout); - expect(output.id).toBe(created.id); + // 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( @@ -355,6 +381,218 @@ describe("CLI var commands", () => { }); }); + 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 @@ -374,7 +612,7 @@ describe("CLI var commands", () => { { encoding: "utf-8" }, ); expect(createResult.status).toBe(0); - const var1 = JSON.parse(createResult.stdout); + const var1 = JSON.parse(createResult.stdout).value; expect(var1.value).toBe(hashA); // Get variable @@ -384,7 +622,7 @@ describe("CLI var commands", () => { { encoding: "utf-8" }, ); expect(getResult1.status).toBe(0); - const retrieved1 = JSON.parse(getResult1.stdout); + const retrieved1 = JSON.parse(getResult1.stdout).value; expect(retrieved1.value).toBe(hashA); // Wait to ensure different timestamp @@ -397,7 +635,7 @@ describe("CLI var commands", () => { { encoding: "utf-8" }, ); expect(updateResult.status).toBe(0); - const updated = JSON.parse(updateResult.stdout); + const updated = JSON.parse(updateResult.stdout).value; expect(updated.value).toBe(hashB); // Get updated variable @@ -407,7 +645,7 @@ describe("CLI var commands", () => { { encoding: "utf-8" }, ); expect(getResult2.status).toBe(0); - const retrieved2 = JSON.parse(getResult2.stdout); + const retrieved2 = JSON.parse(getResult2.stdout).value; expect(retrieved2.value).toBe(hashB); // Delete variable @@ -444,7 +682,7 @@ describe("CLI var commands", () => { ], { encoding: "utf-8" }, ); - const var1 = JSON.parse(createResult1.stdout); + const var1 = JSON.parse(createResult1.stdout).value; const createResult2 = spawnSync( "bun", @@ -461,7 +699,7 @@ describe("CLI var commands", () => { ], { encoding: "utf-8" }, ); - const var2 = JSON.parse(createResult2.stdout); + const var2 = JSON.parse(createResult2.stdout).value; // Verify independence expect(var1.id).not.toBe(var2.id); @@ -471,7 +709,7 @@ describe("CLI var commands", () => { [cliPath, "--store", storePath, "var", "get", var1.id], { encoding: "utf-8" }, ); - const retrieved1 = JSON.parse(getResult1.stdout); + const retrieved1 = JSON.parse(getResult1.stdout).value; expect(retrieved1.value).toBe(hashA); const getResult2 = spawnSync( @@ -479,7 +717,7 @@ describe("CLI var commands", () => { [cliPath, "--store", storePath, "var", "get", var2.id], { encoding: "utf-8" }, ); - const retrieved2 = JSON.parse(getResult2.stdout); + const retrieved2 = JSON.parse(getResult2.stdout).value; expect(retrieved2.value).toBe(hashB); // Delete var1, verify var2 still exists @@ -498,7 +736,7 @@ describe("CLI var commands", () => { { encoding: "utf-8" }, ); expect(getResult2Final.status).toBe(0); - const retrieved2Final = JSON.parse(getResult2Final.stdout); + const retrieved2Final = JSON.parse(getResult2Final.stdout).value; expect(retrieved2Final.value).toBe(hashB); }); @@ -518,7 +756,7 @@ describe("CLI var commands", () => { ], { encoding: "utf-8" }, ); - const var1 = JSON.parse(createResult1.stdout); + const var1 = JSON.parse(createResult1.stdout).value; const createResult2 = spawnSync( "bun", @@ -535,7 +773,7 @@ describe("CLI var commands", () => { ], { encoding: "utf-8" }, ); - const var2 = JSON.parse(createResult2.stdout); + const var2 = JSON.parse(createResult2.stdout).value; const createResult3 = spawnSync( "bun", @@ -552,7 +790,7 @@ describe("CLI var commands", () => { ], { encoding: "utf-8" }, ); - const var3 = JSON.parse(createResult3.stdout); + const var3 = JSON.parse(createResult3.stdout).value; expect(var1.scope).toBe("uwf/"); expect(var2.scope).toBe("uwf/thread/"); diff --git a/packages/json-cas/src/variable-store.test.ts b/packages/json-cas/src/variable-store.test.ts index c3ec63d..e606d1f 100644 --- a/packages/json-cas/src/variable-store.test.ts +++ b/packages/json-cas/src/variable-store.test.ts @@ -235,6 +235,99 @@ describe("VariableStore", () => { }); }); + describe("Test Group 6: Variable Listing", () => { + test("6.1: list() returns all variables with matching scope prefix", async () => { + const var1 = varStore.create("uwf/thread/", hashA); + const var2 = varStore.create("uwf/thread/", hashB); + const var3 = varStore.create("uwf/agent/", hashA); + varStore.create("app/config/", hashA); + + // Wait a bit to ensure different timestamps + await new Promise((resolve) => setTimeout(resolve, 10)); + + const results = varStore.list({ scope: "uwf/" }); + + expect(results).toHaveLength(3); + expect(results.every((v) => v.scope.startsWith("uwf/"))).toBe(true); + + // Verify ordering by created timestamp + expect(results[0]?.id).toBe(var1.id); + expect(results[1]?.id).toBe(var2.id); + expect(results[2]?.id).toBe(var3.id); + }); + + test("6.2: list() returns empty array when no matches", () => { + varStore.create("uwf/thread/", hashA); + + const results = varStore.list({ scope: "nonexistent/" }); + + expect(results).toHaveLength(0); + expect(Array.isArray(results)).toBe(true); + }); + + test("6.3: list() returns all variables when scope is empty string", () => { + const var1 = varStore.create("uwf/thread/", hashA); + const var2 = varStore.create("app/config/", hashB); + const var3 = varStore.create("test/", hashC); + + const results = varStore.list({ scope: "" }); + + expect(results).toHaveLength(3); + expect(results.map((v) => v.id)).toContain(var1.id); + expect(results.map((v) => v.id)).toContain(var2.id); + expect(results.map((v) => v.id)).toContain(var3.id); + }); + + test("6.4: list() validates scope format (must end with /)", () => { + varStore.create("uwf/thread/", hashA); + + expect(() => varStore.list({ scope: "uwf" })).toThrow(InvalidScopeError); + expect(() => varStore.list({ scope: "uwf" })).toThrow( + "scope must end with /", + ); + }); + + test("6.5: list() returns exact scope match and sub-scopes", () => { + varStore.create("uwf/thread/", hashA); + varStore.create("uwf/thread/active/", hashB); + + const results = varStore.list({ scope: "uwf/thread/" }); + + expect(results).toHaveLength(2); + expect(results[0]?.scope).toBe("uwf/thread/"); + expect(results[1]?.scope).toBe("uwf/thread/active/"); + }); + + test("6.6: list() result ordering is deterministic", async () => { + // Create 5 variables with the same scope prefix + const var1 = varStore.create("test/", hashA); + await new Promise((resolve) => setTimeout(resolve, 2)); + const var2 = varStore.create("test/", hashB); + await new Promise((resolve) => setTimeout(resolve, 2)); + const var3 = varStore.create("test/", hashA); + await new Promise((resolve) => setTimeout(resolve, 2)); + const var4 = varStore.create("test/", hashB); + await new Promise((resolve) => setTimeout(resolve, 2)); + const var5 = varStore.create("test/", hashA); + + // Call list multiple times + const results1 = varStore.list({ scope: "test/" }); + const results2 = varStore.list({ scope: "test/" }); + const results3 = varStore.list({ scope: "test/" }); + + // All results should be identical + expect(results1.map((v) => v.id)).toEqual(results2.map((v) => v.id)); + expect(results2.map((v) => v.id)).toEqual(results3.map((v) => v.id)); + + // Verify ordering by created timestamp (oldest first) + expect(results1[0]?.id).toBe(var1.id); + expect(results1[1]?.id).toBe(var2.id); + expect(results1[2]?.id).toBe(var3.id); + expect(results1[3]?.id).toBe(var4.id); + expect(results1[4]?.id).toBe(var5.id); + }); + }); + describe("Test Group 7: Integration Tests", () => { test("7.1: Full lifecycle workflow", async () => { // Create variable diff --git a/packages/json-cas/src/variable-store.ts b/packages/json-cas/src/variable-store.ts index e78e5bc..f0712be 100644 --- a/packages/json-cas/src/variable-store.ts +++ b/packages/json-cas/src/variable-store.ts @@ -200,6 +200,43 @@ export class VariableStore { return existing; } + /** + * List variables matching a scope prefix + */ + list(options?: { scope?: string }): Variable[] { + const scope = options?.scope ?? ""; + + // Validate scope format (must end with / if non-empty) + if (scope !== "" && !scope.endsWith("/")) { + throw new InvalidScopeError(scope); + } + + const stmt = this.db.prepare(` + SELECT id, scope, value, schema, created, updated + FROM variables + WHERE scope LIKE ? || '%' + ORDER BY created ASC + `); + + const rows = stmt.all(scope) as Array<{ + id: string; + scope: string; + value: string; + schema: string; + created: number; + updated: number; + }>; + + return rows.map((row) => ({ + id: row.id, + scope: row.scope, + value: row.value, + schema: row.schema, + created: row.created, + updated: row.updated, + })); + } + /** * Close the database connection */ diff --git a/packages/json-cas/src/variable.ts b/packages/json-cas/src/variable.ts index 8b568d5..fa50383 100644 --- a/packages/json-cas/src/variable.ts +++ b/packages/json-cas/src/variable.ts @@ -8,11 +8,11 @@ export type VariableId = string; /** * Variable: mutable binding to an immutable CAS node */ -export interface Variable { +export type Variable = { id: VariableId; scope: string; // hierarchical path, must end with / value: Hash; // CAS node hash schema: Hash; // extracted from value's CAS node.type created: number; // epoch ms updated: number; // epoch ms -} +};