feat: RFC-31 Phase 1+2 — Variable model refactor with (name, schema) composite key
- Replace ULID id + scope with qualified name + schema composite PK - Add set() upsert, remove() with optional schema, validateName() - get(name, schema) with fixed return type (no polymorphic) - Tags/labels adapted to composite foreign keys - GC compatible Fixes #32 Ref #31
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
+108
-380
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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<string, string> {
|
||||
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<string, string> = {};
|
||||
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<string, string>;
|
||||
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<string, string> {
|
||||
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<string, string> = {};
|
||||
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<string, string>;
|
||||
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<string, string>; // 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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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<string, string>; // key-value pairs
|
||||
|
||||
Reference in New Issue
Block a user