Compare commits

...

7 Commits

Author SHA1 Message Date
xiaoju 7242588dd9 feat: implement RFC-20 Phase 3 GC integration
Implements garbage collection (GC) with mark-and-sweep algorithm:
- Mark phase: recursively walks references from all variable values (global, not scoped)
- Sweep phase: deletes unmarked CAS nodes
- Schema preservation: schemas referenced by reachable nodes are preserved
- Bootstrap preservation: self-referencing meta-schema always preserved

New features:
- Core gc() function in packages/json-cas/src/gc.ts with GcStats interface
- Extended Store interface with listAll() and delete() methods
- CLI command: json-cas gc (outputs JSON stats)
- Comprehensive test suite with 16 test scenarios

Implements: #23

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-30 08:16:20 +00:00
xiaonuo c34a8b3c58 Merge pull request 'feat: implement RFC-20 Phase 2 tag/label + query system' (#29) from fix/22-tag-label-query into main 2026-05-30 07:57:23 +00:00
xingyue 08b143ea0b feat: add retrospect-workflow for self-improvement 2026-05-30 15:50:22 +08:00
xiaoju 1269de5b96 feat: implement RFC-20 Phase 2 tag/label + query system
Implements comprehensive tag/label functionality for variables:

## Core Features
- Tags: key-value pairs with same-key override semantics
- Labels: bare identifiers
- Deletion syntax: `:name` removes tag or label
- Mutual exclusion: tag keys and label names cannot coexist
- Unified `var tag` command for all tag/label operations

## Data Model
- Extended Variable type with tags/labels fields
- New variable_tags and variable_labels SQLite tables
- Foreign key constraints with CASCADE delete
- Proper indexes for efficient querying

## Query Capabilities
- Filter by scope (hierarchical prefix matching)
- Filter by tags (key:value pairs, AND logic)
- Filter by labels (bare names, AND logic)
- Combined filtering (scope + tags/labels)

## CLI Commands
- `json-cas var create --tag <tag>...` - initial tags/labels
- `json-cas var tag <id> <tag>...` - add/update/delete
- `json-cas var list --tag <tag>...` - query with filters

## Implementation Details
- TagLabelConflictError and InvalidTagFormatError types
- Atomic batch operations with rollback
- 46 comprehensive tests for tags/labels
- Backward compatible with Phase 1
- All 214 tests pass

Closes #22

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-30 07:38:06 +00:00
xiaonuo 263fe40146 Merge pull request 'chore: Phase 1 code style fixes and missing features' (#28) from fix/27-phase1-code-style-fixes into main 2026-05-30 06:51:34 +00:00
xiaoju aefd93c33e chore: Phase 1 code style fixes and missing features
Fixes #27

Changes:
1. Variable uses type instead of interface
2. Add JSON envelope output {type, value} to all CLI var commands
3. Add list method with scope prefix matching to VariableStore and CLI
4. Fix var-db path to default to <storePath>/variables.db instead of <defaultStorePath>/variables.db

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-30 06:42:00 +00:00
xiaonuo 76dab6737c Merge pull request 'feat: RFC-20 Phase 1 - Variable CRUD Operations' (#25) from fix/21-variable-crud into main 2026-05-30 06:22:44 +00:00
14 changed files with 2500 additions and 53 deletions
+220
View File
@@ -0,0 +1,220 @@
name: "retrospect-workflow"
description: "Post-execution retrospective: analyze a completed thread, find inefficiencies, and improve the workflow definition."
roles:
analyst:
description: "Scans thread execution for anomalies and produces a findings report"
goal: "You are a workflow execution analyst. You review completed thread data to find inefficiencies, wasted effort, and procedure gaps."
capabilities:
- data-analysis
procedure: |
You receive a completed thread ID in your task prompt.
Phase 0 — Validation (must pass before any analysis):
1. Run `uwf step list <thread-id>` to get thread metadata including the workflow hash
2. Run `uwf workflow show <workflow-hash>` to get the workflow name
3. Verify the workflow exists locally: check `.workflows/<name>.yaml` in the current repo
- If NOT found: output $status=wrong_project with the workflow name. Do NOT proceed.
4. Compare the thread's workflow hash against the current registered version:
- Run `uwf workflow show <name>` to get the current hash
- If hashes differ: the thread ran on an older version. Note this — you will need to diff versions after analysis.
Phase 1 — Overview scan:
5. From the step list, compute a health signal for each step:
- Duration: flag if >2x the median of other steps
- Output tokens: flag if >2x the median
- Status flow: flag non-happy-path transitions (rejected, fix_code, fix_spec, hook_failed)
- Step count: flag if the same role appears more than expected (indicates loops)
6. If no anomalies found AND versions match: output $status=clean
7. If no anomalies found BUT versions differ:
- Diff the two workflow versions to check if any procedure changes are relevant
- If the current version already addresses potential concerns: output $status=clean with a note
- Otherwise: proceed to Phase 2
Phase 2 — Targeted deep-dive (only for flagged steps):
8. For each flagged step, run `uwf step show <hash>` to get the detail with turns
9. Analyze the turn sequence for:
- Repeated tool calls with the same or similar input (blind retries)
- Tool errors followed by no strategy change (same approach retried)
- Unnecessary exploration (reading files or running commands unrelated to the task)
- Hallucinated commands or flags (commands that don't exist or wrong syntax)
- Excessive turns before reaching the goal
10. For each finding, record:
- Which role and step hash
- What happened (specific turn indices and commands)
- Root cause hypothesis (procedure gap, missing pitfall, unclear instruction)
- Suggested fix (what to add/change in the procedure)
11. If versions differ: compare findings against the version diff.
Mark any finding that is already fixed in the current version as "resolved_in_current".
Only report findings that are NOT yet addressed.
Output a structured findings report. Set $status=clean if nothing actionable, $status=findings if unresolved issues exist, or $status=wrong_project if the workflow doesn't belong here.
output: "A findings report with per-issue root cause and suggested procedure fixes. Set $status to clean or findings (with report hash)."
frontmatter:
oneOf:
- properties:
$status: { const: "clean" }
summary: { type: string }
required: [$status, summary]
- properties:
$status: { const: "findings" }
report: { type: string }
targetWorkflow: { type: string }
required: [$status, report, targetWorkflow]
- properties:
$status: { const: "wrong_project" }
workflowName: { type: string }
required: [$status, workflowName]
proposer:
description: "Translates findings into concrete workflow edits"
goal: "You are a workflow improvement proposer. You read the analyst's findings and produce specific, minimal edits to the workflow YAML."
capabilities:
- planning
procedure: |
1. Read the analyst's findings report from your task prompt
2. Locate the target workflow YAML:
- Workflow definitions live in the WORKFLOW ENGINE repo (where `uwf` is developed), NOT in the repo that was analyzed.
- Find it via: `uwf workflow show <targetWorkflow> --format yaml` to read the current definition
- The physical file is `.workflows/<targetWorkflow>.yaml` in the workflow engine repo
- Use `git rev-parse --show-toplevel` in the current directory to find the workflow engine repo root
3. Read the current workflow YAML to understand existing procedures
4. For each finding, draft a minimal edit:
- Prefer adding a pitfall note or clarifying instruction over restructuring
- If a procedure step is ambiguous, make it explicit
- If a tool usage pattern is wrong, add a "Do NOT" or "IMPORTANT" note
- Keep edits surgical — don't rewrite procedures that work fine
5. Check if existing tests need updating (search for test files referencing the workflow)
6. Produce a change plan as CAS text node via `uwf cas put-text "<plan>"`
The plan should list each edit with:
- File path
- What to change (old text → new text, or addition)
- Why (linked to which finding)
- Any test updates needed
output: "A change plan stored in CAS. Set $status to ready (with plan hash and repoPath) or no_action (if findings don't warrant changes)."
frontmatter:
oneOf:
- properties:
$status: { const: "ready" }
plan: { type: string }
repoPath: { type: string }
required: [$status, plan, repoPath]
- properties:
$status: { const: "no_action" }
reason: { type: string }
required: [$status, reason]
developer:
description: "Applies the proposed workflow edits"
goal: "You are a developer agent. You apply workflow YAML edits and update related tests."
capabilities:
- coding
procedure: |
IMPORTANT: Always work in a git worktree, NEVER modify the main working directory directly.
The workflow definitions live in THIS repo (the workflow engine), not the repo that was analyzed.
Before starting any work, set up an isolated worktree:
1. Use `git rev-parse --show-toplevel` to find the repo root (do NOT use repoPath from proposer — that's the analyzed repo)
2. `git fetch origin` to get latest refs
3. `git worktree add .worktrees/retrospect/<short-slug> -b retrospect/<short-slug> origin/main`
4. `cd .worktrees/retrospect/<short-slug> && bun install`
5. ALL subsequent work must happen inside the worktree directory.
Then apply changes:
6. Read the change plan from CAS: `uwf cas get <plan hash>`
7. Apply each edit from the plan to the workflow YAML
8. Update or add tests as specified in the plan
9. Run `bun run build` and `bun test` to verify
10. Run `bun run check` for lint
11. Commit with message: `improve: <workflow-name> — <brief summary>`
output: "List all files changed and provide a summary. Set $status to done (with branch/worktree), or failed (with reason)."
frontmatter:
oneOf:
- properties:
$status: { const: "done" }
branch: { type: string }
worktree: { type: string }
required: [$status, branch, worktree]
- properties:
$status: { const: "failed" }
reason: { type: string }
required: [$status, reason]
reviewer:
description: "Reviews the workflow edits for correctness"
goal: "You are a reviewer. You verify that workflow edits are minimal, correct, and actually address the findings."
capabilities:
- code-review
procedure: |
The worktree path is provided in your task prompt. cd into it first.
Review criteria:
1. Each edit must trace back to a specific finding — no drive-by changes
2. Edits should be minimal — don't rewrite working procedures
3. New pitfall notes or instructions must be clear and actionable
4. Tests must be updated if assertions changed
5. `bun run build` and `bun test` must pass
6. `bunx biome check` must pass
IMPORTANT: `tea pr create` must run from the MAIN repo directory (not a worktree), because tea cannot detect the repo from worktree `.git` files.
output: "Explain your decision. Set $status to approved (with branch/worktree) or rejected (with comments)."
frontmatter:
oneOf:
- properties:
$status: { const: "approved" }
branch: { type: string }
worktree: { type: string }
required: [$status, branch, worktree]
- properties:
$status: { const: "rejected" }
comments: { type: string }
worktree: { type: string }
required: [$status, comments, worktree]
committer:
description: "Commits and creates PR"
goal: "You are a committer agent. You create a clean commit and push a PR."
capabilities: []
procedure: |
The worktree path, branch name, and repo info are provided in your task prompt.
cd into the worktree first.
Note: You inherit the developer's worktree and branch. Do NOT create a new branch.
1. Stage all changes: `git add -A`
2. Commit with a descriptive message: `git commit -m "improve: <workflow> — <summary>"`
3. Push the branch: `git push -u origin <branch-name>`
- If push hook fails: capture the error log in your output, mark hook_failed
4. On push success: create a PR via `tea pr create --title "..." --description "..."`
- IMPORTANT: `tea pr create` must run from the MAIN repo directory (not a worktree), because tea cannot detect the repo from worktree `.git` files. cd to the repo root first.
- Do NOT pass `--repo` — let tea auto-detect from the main repo's git remote.
- PR description must include: What / Why / Findings / Changes sections
- On tea failure: capture stderr/stdout, include PR details for manual creation, mark hook_failed
5. After PR creation, clean up the worktree:
- cd to the repo root (parent of .worktrees)
- `git worktree remove <worktree-path>`
output: "Include PR URL on success or error log on failure. Set $status to committed (with prUrl) or hook_failed (with error)."
frontmatter:
oneOf:
- properties:
$status: { const: "committed" }
prUrl: { type: string }
required: [$status, prUrl]
- properties:
$status: { const: "hook_failed" }
error: { type: string }
required: [$status, error]
graph:
$START:
_: { role: "analyst", prompt: "Analyze completed thread {{{threadId}}} for execution anomalies." }
analyst:
clean: { role: "$END", prompt: "No issues found. Thread executed cleanly." }
findings: { role: "proposer", prompt: "Findings report: {{{report}}}. Target workflow: {{{targetWorkflow}}}. Propose minimal edits." }
wrong_project: { role: "$END", prompt: "Thread uses workflow '{{{workflowName}}}' which does not exist in this project. Run retrospect from the correct repo." }
proposer:
no_action: { role: "$END", prompt: "No actionable changes needed: {{{reason}}}." }
ready: { role: "developer", prompt: "Apply the change plan (CAS hash: {{{plan}}}) to the workflow definitions in this repo." }
developer:
done: { role: "reviewer", prompt: "Review workflow edits on branch {{{branch}}} at {{{worktree}}}." }
failed: { role: "$END", prompt: "Developer failed: {{{reason}}}. Ending workflow." }
reviewer:
rejected: { role: "developer", prompt: "Reviewer rejected: {{{comments}}}. Fix the issues in {{{worktree}}}." }
approved: { role: "committer", prompt: "Approved. Commit and push branch {{{branch}}} from {{{worktree}}}." }
committer:
hook_failed: { role: "developer", prompt: "Push hook failed: {{{error}}}. Fix and re-submit." }
committed: { role: "$END", prompt: "PR created: {{{prUrl}}}. Workflow improved." }
+241 -12
View File
@@ -9,11 +9,14 @@ import {
CasNodeNotFoundError,
computeHash,
createVariableStore,
gc,
getSchema,
InvalidScopeError,
InvalidTagFormatError,
putSchema,
refs,
SchemaMismatchError,
TagLabelConflictError,
VariableNotFoundError,
validate,
verify,
@@ -23,10 +26,17 @@ import { createFsStore } from "@uncaged/json-cas-fs";
// ---- Argument parsing ----
type Flags = Record<string, string | boolean>;
type Flags = Record<string, string | boolean | string[]>;
/** Flags that consume the next token as their value. All others are boolean. */
const VALUE_FLAGS = new Set(["store", "format", "scope", "value", "var-db"]);
const VALUE_FLAGS = new Set([
"store",
"format",
"scope",
"value",
"var-db",
"tag",
]);
function parseArgs(argv: string[]): { flags: Flags; positional: string[] } {
const flags: Flags = {};
@@ -39,7 +49,19 @@ function parseArgs(argv: string[]): { flags: Flags; positional: string[] } {
if (VALUE_FLAGS.has(key)) {
const next = argv[i + 1];
if (next !== undefined && !next.startsWith("--")) {
flags[key] = next;
// Handle repeatable flags (like --tag)
if (key === "tag") {
const existing = flags[key];
if (Array.isArray(existing)) {
existing.push(next);
} else if (typeof existing === "string") {
flags[key] = [existing, next];
} else {
flags[key] = [next];
}
} else {
flags[key] = next;
}
i++;
} else {
flags[key] = true;
@@ -62,7 +84,7 @@ const storePath =
typeof flags.store === "string" ? flags.store : defaultStorePath;
const compact = flags.json === true;
const defaultVarDbPath = join(defaultStorePath, "variables.db");
const defaultVarDbPath = join(storePath, "variables.db");
const varDbPath =
typeof flags["var-db"] === "string" ? flags["var-db"] : defaultVarDbPath;
@@ -95,6 +117,89 @@ function openVarStore(): VariableStore {
return createVariableStore(resolve(varDbPath), store);
}
/**
* Get the Variable schema's CAS hash
* This is the type hash used in JSON envelopes
*/
async function getVariableSchemaHash(): Promise<Hash> {
const store = openStore();
// Define the Variable JSON Schema (simple version for envelope)
const variableSchema: JSONSchema = {
title: "Variable",
type: "object",
properties: {
id: { type: "string" },
scope: { type: "string" },
value: { type: "string" },
schema: { type: "string" },
created: { type: "number" },
updated: { type: "number" },
tags: { type: "object" },
labels: { type: "array", items: { type: "string" } },
},
required: [
"id",
"scope",
"value",
"schema",
"created",
"updated",
"tags",
"labels",
],
};
// Compute hash or retrieve from store
const hash = await putSchema(store, variableSchema);
return hash;
}
/**
* Wrap Variable output in JSON envelope
*/
async function wrapVariableEnvelope(
variable: unknown,
): Promise<{ type: Hash; value: unknown }> {
const typeHash = await getVariableSchemaHash();
return {
type: typeHash,
value: variable,
};
}
/**
* Parse tag/label arguments
* Returns: { tags: Record<string, string>, labels: string[], deleteNames: string[] }
*/
function parseTagsLabels(args: string[]): {
tags: Record<string, string>;
labels: string[];
deleteNames: string[];
} {
const tags: Record<string, string> = {};
const labels: string[] = [];
const deleteNames: string[] = [];
for (const arg of args) {
if (arg.startsWith(":")) {
// Deletion syntax: :name
deleteNames.push(arg.slice(1));
} else if (arg.includes(":")) {
// Tag: key:value (split on first colon)
const colonIdx = arg.indexOf(":");
const key = arg.slice(0, colonIdx);
const value = arg.slice(colonIdx + 1);
tags[key] = value;
} else {
// Label: bare identifier
labels.push(arg);
}
}
return { tags, labels, deleteNames };
}
// ---- Commands ----
async function cmdInit(): Promise<void> {
@@ -268,6 +373,7 @@ async function cmdCat(args: string[]): Promise<void> {
async function cmdVarCreate(_args: string[]): Promise<void> {
const scope = flags.scope as string | undefined;
const value = flags.value as string | undefined;
const tagFlags = flags.tag;
if (!scope) die("Usage: json-cas var create --scope <scope> --value <hash>");
if (!value) die("Usage: json-cas var create --scope <scope> --value <hash>");
@@ -275,10 +381,31 @@ async function cmdVarCreate(_args: string[]): Promise<void> {
const varStore = openVarStore();
try {
const variable = varStore.create(scope, value);
out(variable);
// Parse tags/labels from --tag flags
const tagArgs = Array.isArray(tagFlags)
? tagFlags
: typeof tagFlags === "string"
? [tagFlags]
: [];
const { tags, labels, deleteNames } = parseTagsLabels(tagArgs);
// Check for conflicts in initial tags/labels
if (deleteNames.length > 0) {
die("Error: Cannot use deletion syntax (:name) in var create");
}
const variable = varStore.create(scope, value, {
tags: Object.keys(tags).length > 0 ? tags : undefined,
labels: labels.length > 0 ? labels : undefined,
});
const envelope = await wrapVariableEnvelope(variable);
out(envelope);
} catch (e) {
if (e instanceof InvalidScopeError || e instanceof CasNodeNotFoundError) {
if (
e instanceof InvalidScopeError ||
e instanceof CasNodeNotFoundError ||
e instanceof TagLabelConflictError
) {
die(`Error: ${e.message}`);
}
throw e;
@@ -298,7 +425,8 @@ async function cmdVarGet(args: string[]): Promise<void> {
if (variable === null) {
die(`Error: Variable not found: ${id}`);
}
out(variable);
const envelope = await wrapVariableEnvelope(variable);
out(envelope);
} finally {
varStore.close();
}
@@ -316,7 +444,8 @@ async function cmdVarUpdate(args: string[]): Promise<void> {
try {
const variable = varStore.update(id, value);
out(variable);
const envelope = await wrapVariableEnvelope(variable);
out(envelope);
} catch (e) {
if (
e instanceof VariableNotFoundError ||
@@ -339,7 +468,8 @@ async function cmdVarDelete(args: string[]): Promise<void> {
try {
const variable = varStore.delete(id);
out(variable);
const envelope = await wrapVariableEnvelope(variable);
out(envelope);
} catch (e) {
if (e instanceof VariableNotFoundError) {
die(`Error: ${e.message}`);
@@ -350,6 +480,91 @@ async function cmdVarDelete(args: string[]): Promise<void> {
}
}
async function cmdVarTag(args: string[]): Promise<void> {
const id = args[0];
if (!id) die("Usage: json-cas var tag <id> <tag>...");
const tagArgs = args.slice(1);
if (tagArgs.length === 0) {
die("Usage: json-cas var tag <id> <tag>...");
}
const varStore = openVarStore();
try {
const { tags, labels, deleteNames } = parseTagsLabels(tagArgs);
const variable = varStore.tag(id, {
add: Object.keys(tags).length > 0 ? tags : undefined,
addLabels: labels.length > 0 ? labels : undefined,
delete: deleteNames.length > 0 ? deleteNames : undefined,
});
const envelope = await wrapVariableEnvelope(variable);
out(envelope);
} catch (e) {
if (
e instanceof VariableNotFoundError ||
e instanceof TagLabelConflictError ||
e instanceof InvalidTagFormatError
) {
die(`Error: ${e.message}`);
}
throw e;
} finally {
varStore.close();
}
}
async function cmdVarList(_args: string[]): Promise<void> {
const scope = (flags.scope as string | undefined) ?? "";
const tagFlags = flags.tag;
const varStore = openVarStore();
try {
// Parse tags/labels from --tag flags
const tagArgs = Array.isArray(tagFlags)
? tagFlags
: typeof tagFlags === "string"
? [tagFlags]
: [];
const { tags, labels, deleteNames } = parseTagsLabels(tagArgs);
// Check for invalid deletion syntax in filters
if (deleteNames.length > 0) {
die("Error: Cannot use deletion syntax (:name) in var list filters");
}
const variables = varStore.list({
scope,
tags: Object.keys(tags).length > 0 ? tags : undefined,
labels: labels.length > 0 ? labels : undefined,
});
const envelope = await wrapVariableEnvelope(variables);
out(envelope);
} catch (e) {
if (e instanceof InvalidScopeError) {
die(`Error: ${e.message}`);
}
throw e;
} finally {
varStore.close();
}
}
async function cmdGc(_args: string[]): Promise<void> {
const store = createFsStore(storePath);
const varStore = createVariableStore(varDbPath, store);
try {
const stats = gc(store, varStore);
out(stats);
} finally {
varStore.close();
}
}
function printUsage(): void {
console.log(`\
Usage: json-cas [--store <path>] [--json] <command> [args]
@@ -369,15 +584,19 @@ Commands:
walk <hash> [--format tree] Recursive traversal
hash <type-hash> <file.json> Compute hash without storing (dry run)
cat <hash> [--payload] Output node (--payload for payload only)
var create --scope <s> --value <h> Create a variable
var create --scope <s> --value <h> [--tag <tag>...] Create a variable
var get <id> Get a variable by ID
var update <id> <hash> Update variable value
var delete <id> Delete a variable
var tag <id> <tag>... Add/update/delete tags and labels
var list [--scope <prefix>] [--tag <tag>...] List variables (filter by scope/tags/labels)
gc Run garbage collection
Flags:
--store <path> Store directory (default: ~/.uncaged/json-cas)
--var-db <path> Variable database path (default: <store>/variables.db)
--json Compact JSON output`);
--json Compact JSON output
--tag <tag> Tag/label (can be repeated): key:value (tag), name (label), :name (delete)`);
}
// ---- Dispatch ----
@@ -466,12 +685,22 @@ switch (cmd) {
case "delete":
await cmdVarDelete(subRest);
break;
case "tag":
await cmdVarTag(subRest);
break;
case "list":
await cmdVarList(subRest);
break;
default:
die(`Unknown var subcommand: ${sub ?? "(none)"}`);
}
break;
}
case "gc":
await cmdGc(rest);
break;
default:
die(`Unknown command: ${cmd}`);
}
+271 -33
View File
@@ -11,10 +11,12 @@ describe("CLI var commands", () => {
let schemaHash: string;
let hashA: string;
let hashB: string;
let testCounter = 0;
beforeEach(async () => {
// Create temporary paths
storePath = join(tmpdir(), `test-cli-store-${Date.now()}`);
// Create temporary paths with counter to ensure uniqueness
testCounter++;
storePath = join(tmpdir(), `test-cli-store-${Date.now()}-${testCounter}`);
varDbPath = join(storePath, "variables.db");
cliPath = join(import.meta.dir, "index.ts");
@@ -100,12 +102,18 @@ describe("CLI var commands", () => {
expect(result.status).toBe(0);
const output = JSON.parse(result.stdout);
expect(output.id).toMatch(/^[0-9A-HJKMNP-TV-Z]{26}$/);
expect(output.scope).toBe("uwf/thread/");
expect(output.value).toBe(hashA);
expect(output.schema).toBe(schemaHash);
expect(output.created).toBeGreaterThan(Date.now() - 5000);
expect(output.updated).toBe(output.created);
// Expect envelope format
expect(output.type).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
expect(output.value).toBeDefined();
// Check the actual variable in the value field
const variable = output.value;
expect(variable.id).toMatch(/^[0-9A-HJKMNP-TV-Z]{26}$/);
expect(variable.scope).toBe("uwf/thread/");
expect(variable.value).toBe(hashA);
expect(variable.schema).toBe(schemaHash);
expect(variable.created).toBeGreaterThan(Date.now() - 5000);
expect(variable.updated).toBe(variable.created);
});
test("1.2: Create variable fails with scope not ending in /", () => {
@@ -170,7 +178,7 @@ describe("CLI var commands", () => {
],
{ encoding: "utf-8" },
);
const created = JSON.parse(createResult.stdout);
const created = JSON.parse(createResult.stdout).value;
// Get the variable
const result = spawnSync(
@@ -182,10 +190,16 @@ describe("CLI var commands", () => {
expect(result.status).toBe(0);
const output = JSON.parse(result.stdout);
expect(output.id).toBe(created.id);
expect(output.scope).toBe("uwf/thread/");
expect(output.value).toBe(hashA);
expect(output.schema).toBe(schemaHash);
// Expect envelope format
expect(output.type).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
expect(output.value).toBeDefined();
// Check the actual variable in the value field
const variable = output.value;
expect(variable.id).toBe(created.id);
expect(variable.scope).toBe("uwf/thread/");
expect(variable.value).toBe(hashA);
expect(variable.schema).toBe(schemaHash);
});
test("2.2: Get non-existent variable", () => {
@@ -219,7 +233,7 @@ describe("CLI var commands", () => {
],
{ encoding: "utf-8" },
);
const created = JSON.parse(createResult.stdout);
const created = JSON.parse(createResult.stdout).value;
// Wait a bit to ensure different timestamp
await new Promise((resolve) => setTimeout(resolve, 10));
@@ -234,10 +248,16 @@ describe("CLI var commands", () => {
expect(result.status).toBe(0);
const output = JSON.parse(result.stdout);
expect(output.id).toBe(created.id);
expect(output.value).toBe(hashB);
expect(output.schema).toBe(schemaHash);
expect(output.updated).toBeGreaterThan(created.created);
// Expect envelope format
expect(output.type).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
expect(output.value).toBeDefined();
// Check the actual variable in the value field
const variable = output.value;
expect(variable.id).toBe(created.id);
expect(variable.value).toBe(hashB);
expect(variable.schema).toBe(schemaHash);
expect(variable.updated).toBeGreaterThan(created.created);
});
});
@@ -287,7 +307,7 @@ describe("CLI var commands", () => {
],
{ encoding: "utf-8" },
);
const created = JSON.parse(createResult.stdout);
const created = JSON.parse(createResult.stdout).value;
// Try to update with different schema
const result = spawnSync(
@@ -319,7 +339,7 @@ describe("CLI var commands", () => {
],
{ encoding: "utf-8" },
);
const created = JSON.parse(createResult.stdout);
const created = JSON.parse(createResult.stdout).value;
// Delete the variable
const result = spawnSync(
@@ -331,7 +351,13 @@ describe("CLI var commands", () => {
expect(result.status).toBe(0);
const output = JSON.parse(result.stdout);
expect(output.id).toBe(created.id);
// Expect envelope format
expect(output.type).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
expect(output.value).toBeDefined();
// Check the actual variable in the value field
const variable = output.value;
expect(variable.id).toBe(created.id);
// Verify it's deleted
const getResult = spawnSync(
@@ -355,6 +381,218 @@ describe("CLI var commands", () => {
});
});
describe("Test Group 6: Variable Listing", () => {
test("6.1: List variables with scope prefix", () => {
// Create variables with different scopes
const createResult1 = spawnSync(
"bun",
[
cliPath,
"--store",
storePath,
"var",
"create",
"--scope",
"uwf/thread/",
"--value",
hashA,
],
{ encoding: "utf-8" },
);
expect(createResult1.status).toBe(0);
const var1 = JSON.parse(createResult1.stdout).value;
const createResult2 = spawnSync(
"bun",
[
cliPath,
"--store",
storePath,
"var",
"create",
"--scope",
"uwf/thread/",
"--value",
hashB,
],
{ encoding: "utf-8" },
);
expect(createResult2.status).toBe(0);
const var2 = JSON.parse(createResult2.stdout).value;
const createResult3 = spawnSync(
"bun",
[
cliPath,
"--store",
storePath,
"var",
"create",
"--scope",
"uwf/agent/",
"--value",
hashA,
],
{ encoding: "utf-8" },
);
expect(createResult3.status).toBe(0);
const var3 = JSON.parse(createResult3.stdout).value;
const createResult4 = spawnSync(
"bun",
[
cliPath,
"--store",
storePath,
"var",
"create",
"--scope",
"app/config/",
"--value",
hashA,
],
{ encoding: "utf-8" },
);
expect(createResult4.status).toBe(0);
// List all variables with uwf/ prefix
const listResult = spawnSync(
"bun",
[cliPath, "--store", storePath, "var", "list", "--scope", "uwf/"],
{ encoding: "utf-8" },
);
expect(listResult.status).toBe(0);
const output = JSON.parse(listResult.stdout);
// Expect envelope format
expect(output.type).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
expect(output.value).toBeDefined();
expect(Array.isArray(output.value)).toBe(true);
// Check the actual variables in the value field
const variables = output.value;
expect(variables).toHaveLength(3);
expect(
variables.every((v: { scope: string }) => v.scope.startsWith("uwf/")),
).toBe(true);
// Verify ordering by created timestamp
expect(variables[0].id).toBe(var1.id);
expect(variables[1].id).toBe(var2.id);
expect(variables[2].id).toBe(var3.id);
});
test("6.2: List all variables when no scope specified", () => {
// Create variables with different scopes
const createResult1 = spawnSync(
"bun",
[
cliPath,
"--store",
storePath,
"var",
"create",
"--scope",
"uwf/thread/",
"--value",
hashA,
],
{ encoding: "utf-8" },
);
expect(createResult1.status).toBe(0);
const createResult2 = spawnSync(
"bun",
[
cliPath,
"--store",
storePath,
"var",
"create",
"--scope",
"app/config/",
"--value",
hashB,
],
{ encoding: "utf-8" },
);
expect(createResult2.status).toBe(0);
// List all variables without scope filter
const listResult = spawnSync(
"bun",
[cliPath, "--store", storePath, "var", "list"],
{ encoding: "utf-8" },
);
expect(listResult.status).toBe(0);
const output = JSON.parse(listResult.stdout);
// Expect envelope format
expect(output.type).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
expect(output.value).toBeDefined();
expect(Array.isArray(output.value)).toBe(true);
const variables = output.value;
expect(variables).toHaveLength(2);
});
test("6.3: List returns empty array when no matches", () => {
// Create a variable
const createResult = spawnSync(
"bun",
[
cliPath,
"--store",
storePath,
"var",
"create",
"--scope",
"uwf/thread/",
"--value",
hashA,
],
{ encoding: "utf-8" },
);
expect(createResult.status).toBe(0);
// List with non-matching scope
const listResult = spawnSync(
"bun",
[
cliPath,
"--store",
storePath,
"var",
"list",
"--scope",
"nonexistent/",
],
{ encoding: "utf-8" },
);
expect(listResult.status).toBe(0);
const output = JSON.parse(listResult.stdout);
expect(output.type).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
expect(output.value).toBeDefined();
expect(Array.isArray(output.value)).toBe(true);
expect(output.value).toHaveLength(0);
});
test("6.4: List fails with invalid scope format", () => {
const listResult = spawnSync(
"bun",
[cliPath, "--store", storePath, "var", "list", "--scope", "uwf"],
{ encoding: "utf-8" },
);
expect(listResult.status).not.toBe(0);
expect(listResult.stderr).toContain("scope must end with /");
});
});
describe("Test Group 7: Integration Tests", () => {
test("7.1: Full lifecycle workflow", async () => {
// Create variable
@@ -374,7 +612,7 @@ describe("CLI var commands", () => {
{ encoding: "utf-8" },
);
expect(createResult.status).toBe(0);
const var1 = JSON.parse(createResult.stdout);
const var1 = JSON.parse(createResult.stdout).value;
expect(var1.value).toBe(hashA);
// Get variable
@@ -384,7 +622,7 @@ describe("CLI var commands", () => {
{ encoding: "utf-8" },
);
expect(getResult1.status).toBe(0);
const retrieved1 = JSON.parse(getResult1.stdout);
const retrieved1 = JSON.parse(getResult1.stdout).value;
expect(retrieved1.value).toBe(hashA);
// Wait to ensure different timestamp
@@ -397,7 +635,7 @@ describe("CLI var commands", () => {
{ encoding: "utf-8" },
);
expect(updateResult.status).toBe(0);
const updated = JSON.parse(updateResult.stdout);
const updated = JSON.parse(updateResult.stdout).value;
expect(updated.value).toBe(hashB);
// Get updated variable
@@ -407,7 +645,7 @@ describe("CLI var commands", () => {
{ encoding: "utf-8" },
);
expect(getResult2.status).toBe(0);
const retrieved2 = JSON.parse(getResult2.stdout);
const retrieved2 = JSON.parse(getResult2.stdout).value;
expect(retrieved2.value).toBe(hashB);
// Delete variable
@@ -444,7 +682,7 @@ describe("CLI var commands", () => {
],
{ encoding: "utf-8" },
);
const var1 = JSON.parse(createResult1.stdout);
const var1 = JSON.parse(createResult1.stdout).value;
const createResult2 = spawnSync(
"bun",
@@ -461,7 +699,7 @@ describe("CLI var commands", () => {
],
{ encoding: "utf-8" },
);
const var2 = JSON.parse(createResult2.stdout);
const var2 = JSON.parse(createResult2.stdout).value;
// Verify independence
expect(var1.id).not.toBe(var2.id);
@@ -471,7 +709,7 @@ describe("CLI var commands", () => {
[cliPath, "--store", storePath, "var", "get", var1.id],
{ encoding: "utf-8" },
);
const retrieved1 = JSON.parse(getResult1.stdout);
const retrieved1 = JSON.parse(getResult1.stdout).value;
expect(retrieved1.value).toBe(hashA);
const getResult2 = spawnSync(
@@ -479,7 +717,7 @@ describe("CLI var commands", () => {
[cliPath, "--store", storePath, "var", "get", var2.id],
{ encoding: "utf-8" },
);
const retrieved2 = JSON.parse(getResult2.stdout);
const retrieved2 = JSON.parse(getResult2.stdout).value;
expect(retrieved2.value).toBe(hashB);
// Delete var1, verify var2 still exists
@@ -498,7 +736,7 @@ describe("CLI var commands", () => {
{ encoding: "utf-8" },
);
expect(getResult2Final.status).toBe(0);
const retrieved2Final = JSON.parse(getResult2Final.stdout);
const retrieved2Final = JSON.parse(getResult2Final.stdout).value;
expect(retrieved2Final.value).toBe(hashB);
});
@@ -518,7 +756,7 @@ describe("CLI var commands", () => {
],
{ encoding: "utf-8" },
);
const var1 = JSON.parse(createResult1.stdout);
const var1 = JSON.parse(createResult1.stdout).value;
const createResult2 = spawnSync(
"bun",
@@ -535,7 +773,7 @@ describe("CLI var commands", () => {
],
{ encoding: "utf-8" },
);
const var2 = JSON.parse(createResult2.stdout);
const var2 = JSON.parse(createResult2.stdout).value;
const createResult3 = spawnSync(
"bun",
@@ -552,7 +790,7 @@ describe("CLI var commands", () => {
],
{ encoding: "utf-8" },
);
const var3 = JSON.parse(createResult3.stdout);
const var3 = JSON.parse(createResult3.stdout).value;
expect(var1.scope).toBe("uwf/");
expect(var2.scope).toBe("uwf/thread/");
+39
View File
@@ -5,6 +5,7 @@ import {
readdirSync,
readFileSync,
renameSync,
unlinkSync,
writeFileSync,
} from "node:fs";
import { join } from "node:path";
@@ -175,6 +176,44 @@ export function createFsStore(dir: string): BootstrapCapableStore {
return typeIndex.get(typeHash) ?? [];
},
listAll(): Hash[] {
return Array.from(data.keys());
},
delete(hash: Hash): void {
const node = data.get(hash);
if (node) {
data.delete(hash);
// Delete file
try {
unlinkSync(join(dir, `${hash}.bin`));
} catch {
// ignore if file doesn't exist
}
// Remove from type index
const list = typeIndex.get(node.type);
if (list) {
const idx = list.indexOf(hash);
if (idx !== -1) {
list.splice(idx, 1);
}
if (list.length === 0) {
typeIndex.delete(node.type);
// Delete empty index file
try {
unlinkSync(join(indexDir, node.type));
} catch {
// ignore
}
} else {
// Rewrite index file
const body = `${list.join("\n")}\n`;
writeFileSync(join(indexDir, node.type), body, "utf8");
}
}
}
},
[BOOTSTRAP_STORE]: putSelfReferencing,
};
+451
View File
@@ -0,0 +1,451 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { unlinkSync } from "node:fs";
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";
function tmpDbPath(): string {
return `/tmp/test-gc-${Date.now()}-${Math.random().toString(36).slice(2)}.db`;
}
describe("gc()", () => {
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
}
});
test("preserves variable-referenced 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 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);
// 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);
});
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);
// 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);
// Run GC
gc(store, varStore);
// 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(stats.scanned).toBe(2);
});
test("deleting one variable doesn't remove 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 one variable
varStore.delete(var1.id);
// 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(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);
});
test("handles empty CAS store", () => {
// Fresh store, no bootstrap, no nodes
// 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(hashOrphan)).toBe(false);
expect(stats.scanned).toBe(3);
});
test("returns accurate stats", async () => {
// Bootstrap
const _metaHash = await bootstrap(store);
// Create schemas and nodes
const schema1 = {
type: "object",
properties: { name: { type: "string" } },
};
const schema2 = {
type: "object",
properties: { age: { type: "number" } },
};
const schema1Hash = await putSchema(store, schema1);
const schema2Hash = await putSchema(store, schema2);
// Create 2 nodes
const hash1 = await store.put(schema1Hash, { name: "node1" });
const hash2 = await store.put(schema2Hash, { age: 42 });
// 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 2 variables
varStore.create("test/", hash1);
varStore.create("test/", hash2);
// Count total before GC
const totalBefore = 8; // metaHash, schema1Hash, schema2Hash, hash1, hash2, orphan1, orphan2, orphan3
// Run GC
const stats = gc(store, varStore);
// Verify stats
expect(stats.total).toBe(totalBefore);
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);
});
});
+94
View File
@@ -0,0 +1,94 @@
import { walk } from "./schema.js";
import type { Hash, Store } from "./types.js";
import type { VariableStore } from "./variable-store.js";
export interface GcStats {
total: number; // Total CAS nodes before GC
reachable: number; // Nodes marked as reachable
collected: number; // Nodes deleted (swept)
scanned: number; // Variables scanned as roots
}
/**
* Garbage collection: mark-and-sweep algorithm
* - Roots: all variable values (global, not scoped)
* - Mark: recursively walk refs from roots
* - Sweep: delete unmarked nodes
* - Schema preservation: schemas of reachable nodes are also marked
*/
export function gc(store: Store, varStore: VariableStore): GcStats {
// Get all variables (no filters → global)
const variables = varStore.list();
const scanned = variables.length;
// Collect unique root hashes from all variables
const roots = new Set<Hash>();
for (const variable of variables) {
roots.add(variable.value);
}
// Mark phase: walk from all roots
const reachable = new Set<Hash>();
for (const rootHash of roots) {
walk(store, rootHash, (hash, node) => {
// Mark the node itself
reachable.add(hash);
// Mark the schema (type) of the node
reachable.add(node.type);
});
}
// Walk the schema chain to ensure bootstrap meta-schema is preserved
// For each reachable schema, walk its schema chain (not its references)
const schemasToWalk = new Set<Hash>();
for (const hash of reachable) {
const node = store.get(hash);
if (node) {
schemasToWalk.add(node.type);
}
}
for (const schemaHash of schemasToWalk) {
// Walk the schema's type chain (meta-schema, etc.)
let current: Hash | null = schemaHash;
while (current !== null && !reachable.has(current)) {
reachable.add(current);
const node = store.get(current);
if (!node || node.type === current) {
// Self-referencing or missing node, stop
break;
}
current = node.type;
}
}
// Preserve all self-referencing nodes (bootstrap meta-schema)
// These are nodes where type === hash
const allHashes = store.listAll();
for (const hash of allHashes) {
const node = store.get(hash);
if (node && node.type === hash) {
reachable.add(hash);
}
}
// Count total nodes
const total = allHashes.length;
// Sweep phase: delete unmarked nodes
let collected = 0;
for (const hash of allHashes) {
if (!reachable.has(hash)) {
store.delete(hash);
collected++;
}
}
return {
total,
reachable: reachable.size,
collected,
scanned,
};
}
+3
View File
@@ -2,6 +2,7 @@ export { bootstrap } from "./bootstrap.js";
export type { BootstrapCapableStore } from "./bootstrap-capable.js";
export { BOOTSTRAP_STORE } from "./bootstrap-capable.js";
export { cborEncode } from "./cbor.js";
export { type GcStats, gc } from "./gc.js";
export { computeHash, computeSelfHash } from "./hash.js";
export type { JSONSchema } from "./schema.js";
export {
@@ -19,7 +20,9 @@ export {
CasNodeNotFoundError,
createVariableStore,
InvalidScopeError,
InvalidTagFormatError,
SchemaMismatchError,
TagLabelConflictError,
VariableNotFoundError,
VariableStore,
} from "./variable-store.js";
+8
View File
@@ -27,6 +27,14 @@ export class MemStore implements BootstrapCapableStore {
return this.#inner.listByType(typeHash);
}
listAll(): Hash[] {
return this.#inner.listAll();
}
delete(hash: Hash): void {
this.#inner.delete(hash);
}
[BOOTSTRAP_STORE](payload: unknown): Promise<Hash> {
return this.#inner[BOOTSTRAP_STORE](payload);
}
+19
View File
@@ -52,6 +52,25 @@ export function createMemoryStore(): BootstrapCapableStore {
return set ? [...set] : [];
},
listAll(): Hash[] {
return Array.from(data.keys());
},
delete(hash: Hash): void {
const node = data.get(hash);
if (node) {
data.delete(hash);
// Remove from type index
const set = byType.get(node.type);
if (set) {
set.delete(hash);
if (set.size === 0) {
byType.delete(node.type);
}
}
}
},
[BOOTSTRAP_STORE]: putSelfReferencing,
};
+2
View File
@@ -24,4 +24,6 @@ export type Store = {
get(hash: Hash): CasNode | null;
has(hash: Hash): boolean;
listByType(typeHash: Hash): Hash[];
listAll(): Hash[];
delete(hash: Hash): void;
};
@@ -68,6 +68,8 @@ describe("VariableStore", () => {
expect(variable.created).toBeGreaterThan(Date.now() - 5000);
expect(variable.created).toBeLessThanOrEqual(Date.now());
expect(variable.updated).toBe(variable.created);
expect(variable.tags).toEqual({});
expect(variable.labels).toEqual([]);
// Verify persistence
const retrieved = varStore.get(variable.id);
@@ -75,6 +77,8 @@ describe("VariableStore", () => {
expect(retrieved?.id).toBe(variable.id);
expect(retrieved?.scope).toBe(variable.scope);
expect(retrieved?.value).toBe(variable.value);
expect(retrieved?.tags).toEqual({});
expect(retrieved?.labels).toEqual([]);
});
test("1.2: Create variable fails with scope not ending in /", () => {
@@ -235,6 +239,99 @@ describe("VariableStore", () => {
});
});
describe("Test Group 6: Variable Listing", () => {
test("6.1: list() returns all variables with matching scope prefix", async () => {
const var1 = varStore.create("uwf/thread/", hashA);
const var2 = varStore.create("uwf/thread/", hashB);
const var3 = varStore.create("uwf/agent/", hashA);
varStore.create("app/config/", hashA);
// Wait a bit to ensure different timestamps
await new Promise((resolve) => setTimeout(resolve, 10));
const results = varStore.list({ scope: "uwf/" });
expect(results).toHaveLength(3);
expect(results.every((v) => v.scope.startsWith("uwf/"))).toBe(true);
// Verify ordering by created timestamp
expect(results[0]?.id).toBe(var1.id);
expect(results[1]?.id).toBe(var2.id);
expect(results[2]?.id).toBe(var3.id);
});
test("6.2: list() returns empty array when no matches", () => {
varStore.create("uwf/thread/", hashA);
const results = varStore.list({ scope: "nonexistent/" });
expect(results).toHaveLength(0);
expect(Array.isArray(results)).toBe(true);
});
test("6.3: list() returns all variables when scope is empty string", () => {
const var1 = varStore.create("uwf/thread/", hashA);
const var2 = varStore.create("app/config/", hashB);
const var3 = varStore.create("test/", hashC);
const results = varStore.list({ scope: "" });
expect(results).toHaveLength(3);
expect(results.map((v) => v.id)).toContain(var1.id);
expect(results.map((v) => v.id)).toContain(var2.id);
expect(results.map((v) => v.id)).toContain(var3.id);
});
test("6.4: list() validates scope format (must end with /)", () => {
varStore.create("uwf/thread/", hashA);
expect(() => varStore.list({ scope: "uwf" })).toThrow(InvalidScopeError);
expect(() => varStore.list({ scope: "uwf" })).toThrow(
"scope must end with /",
);
});
test("6.5: list() returns exact scope match and sub-scopes", () => {
varStore.create("uwf/thread/", hashA);
varStore.create("uwf/thread/active/", hashB);
const results = varStore.list({ scope: "uwf/thread/" });
expect(results).toHaveLength(2);
expect(results[0]?.scope).toBe("uwf/thread/");
expect(results[1]?.scope).toBe("uwf/thread/active/");
});
test("6.6: list() result ordering is deterministic", async () => {
// Create 5 variables with the same scope prefix
const var1 = varStore.create("test/", hashA);
await new Promise((resolve) => setTimeout(resolve, 2));
const var2 = varStore.create("test/", hashB);
await new Promise((resolve) => setTimeout(resolve, 2));
const var3 = varStore.create("test/", hashA);
await new Promise((resolve) => setTimeout(resolve, 2));
const var4 = varStore.create("test/", hashB);
await new Promise((resolve) => setTimeout(resolve, 2));
const var5 = varStore.create("test/", hashA);
// Call list multiple times
const results1 = varStore.list({ scope: "test/" });
const results2 = varStore.list({ scope: "test/" });
const results3 = varStore.list({ scope: "test/" });
// All results should be identical
expect(results1.map((v) => v.id)).toEqual(results2.map((v) => v.id));
expect(results2.map((v) => v.id)).toEqual(results3.map((v) => v.id));
// Verify ordering by created timestamp (oldest first)
expect(results1[0]?.id).toBe(var1.id);
expect(results1[1]?.id).toBe(var2.id);
expect(results1[2]?.id).toBe(var3.id);
expect(results1[3]?.id).toBe(var4.id);
expect(results1[4]?.id).toBe(var5.id);
});
});
describe("Test Group 7: Integration Tests", () => {
test("7.1: Full lifecycle workflow", async () => {
// Create variable
+311 -6
View File
@@ -37,6 +37,24 @@ export class CasNodeNotFoundError extends Error {
}
}
export class TagLabelConflictError extends Error {
constructor(
public conflictName: string,
public existingType: "tag" | "label",
public attemptedType: "tag" | "label",
) {
super(`Conflict: '${conflictName}' already exists as a ${existingType}`);
this.name = "TagLabelConflictError";
}
}
export class InvalidTagFormatError extends Error {
constructor(tag: string) {
super(`Invalid tag format: ${tag}`);
this.name = "InvalidTagFormatError";
}
}
/**
* Variable store with SQLite backend
*/
@@ -65,6 +83,25 @@ export class VariableStore {
CREATE INDEX IF NOT EXISTS idx_var_scope ON variables(scope);
CREATE INDEX IF NOT EXISTS idx_var_value ON variables(value);
CREATE INDEX IF NOT EXISTS idx_var_schema ON variables(schema);
CREATE TABLE IF NOT EXISTS variable_tags (
variable_id 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
);
CREATE TABLE IF NOT EXISTS variable_labels (
variable_id TEXT NOT NULL,
name TEXT NOT NULL,
PRIMARY KEY (variable_id, name),
FOREIGN KEY (variable_id) REFERENCES variables(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_var_tag_key ON variable_tags(key);
CREATE INDEX IF NOT EXISTS idx_var_tag_key_value ON variable_tags(key, value);
CREATE INDEX IF NOT EXISTS idx_var_label_name ON variable_labels(name);
`);
}
@@ -91,19 +128,68 @@ export class VariableStore {
/**
* Create a new variable
*/
create(scope: string, value: string): Variable {
create(
scope: string,
value: string,
options?: {
tags?: Record<string, string>;
labels?: string[];
},
): Variable {
this.validateScope(scope);
const schema = this.extractSchema(value);
const tags = options?.tags ?? {};
const labels = options?.labels ?? [];
// Check for tag/label conflicts
const tagKeys = Object.keys(tags);
for (const key of tagKeys) {
if (labels.includes(key)) {
throw new TagLabelConflictError(key, "label", "tag");
}
}
const id = ulid();
const now = Date.now();
const stmt = this.db.prepare(`
INSERT INTO variables (id, scope, value, schema, created, updated)
VALUES (?, ?, ?, ?, ?, ?)
`);
this.db.exec("BEGIN TRANSACTION");
stmt.run(id, scope, value, schema, now, now);
try {
const stmt = this.db.prepare(`
INSERT INTO variables (id, scope, value, schema, created, updated)
VALUES (?, ?, ?, ?, ?, ?)
`);
stmt.run(id, scope, value, schema, now, now);
// Insert tags
if (tagKeys.length > 0) {
const tagStmt = this.db.prepare(`
INSERT INTO variable_tags (variable_id, key, value)
VALUES (?, ?, ?)
`);
for (const [key, val] of Object.entries(tags)) {
tagStmt.run(id, key, val);
}
}
// Insert labels
if (labels.length > 0) {
const labelStmt = this.db.prepare(`
INSERT INTO variable_labels (variable_id, name)
VALUES (?, ?)
`);
for (const name of labels) {
labelStmt.run(id, name);
}
}
this.db.exec("COMMIT");
} catch (e) {
this.db.exec("ROLLBACK");
throw e;
}
return {
id,
@@ -112,9 +198,44 @@ export class VariableStore {
schema,
created: now,
updated: now,
tags,
labels: [...labels],
};
}
/**
* Load tags for a variable
*/
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
*/
private loadLabels(id: VariableId): string[] {
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
*/
@@ -141,6 +262,9 @@ export class VariableStore {
return null;
}
const tags = this.loadTags(row.id);
const labels = this.loadLabels(row.id);
return {
id: row.id,
scope: row.scope,
@@ -148,6 +272,8 @@ export class VariableStore {
schema: row.schema,
created: row.created,
updated: row.updated,
tags,
labels,
};
}
@@ -200,6 +326,185 @@ export class VariableStore {
return existing;
}
/**
* List variables matching a scope prefix
*/
list(options?: {
scope?: string;
tags?: Record<string, string>;
labels?: string[];
}): Variable[] {
const scope = options?.scope ?? "";
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
let query = `
SELECT DISTINCT v.id, v.scope, v.value, v.schema, v.created, v.updated
FROM variables v
`;
const params: (string | number)[] = [];
// Tag filters (AND logic)
const tagKeys = Object.keys(filterTags);
for (let i = 0; i < tagKeys.length; i++) {
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
AND t${i}.key = ? AND t${i}.value = ?
`;
params.push(key, value);
}
// Label filters (AND logic)
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
AND l${i}.name = ?
`;
params.push(label);
}
// Scope filter (always present)
query += " WHERE v.scope LIKE ? || '%'";
params.push(scope);
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;
schema: string;
created: number;
updated: number;
}>;
return rows.map((row) => ({
id: row.id,
scope: row.scope,
value: row.value,
schema: row.schema,
created: row.created,
updated: row.updated,
tags: this.loadTags(row.id),
labels: this.loadLabels(row.id),
}));
}
/**
* Add/update/delete tags and labels
*/
tag(
id: VariableId,
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);
if (existing === null) {
throw new VariableNotFoundError(id);
}
const addTags = operations.add ?? {};
const addLabels = operations.addLabels ?? [];
const deleteNames = operations.delete ?? [];
// Check for conflicts between tags and labels
const newTagKeys = Object.keys(addTags);
for (const key of newTagKeys) {
// Check if this key is being added as a label in the same operation
if (addLabels.includes(key)) {
throw new TagLabelConflictError(key, "label", "tag");
}
// Check if this key already exists as a label (and not being deleted)
if (existing.labels.includes(key) && !deleteNames.includes(key)) {
throw new TagLabelConflictError(key, "label", "tag");
}
}
for (const name 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");
}
// 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");
}
}
const now = Date.now();
this.db.exec("BEGIN TRANSACTION");
try {
// Update timestamp
const updateStmt = this.db.prepare(`
UPDATE variables SET updated = ? WHERE id = ?
`);
updateStmt.run(now, id);
// Delete tags and labels
if (deleteNames.length > 0) {
const deleteTagStmt = this.db.prepare(`
DELETE FROM variable_tags WHERE variable_id = ? AND key = ?
`);
const deleteLabelStmt = this.db.prepare(`
DELETE FROM variable_labels WHERE variable_id = ? AND name = ?
`);
for (const name of deleteNames) {
deleteTagStmt.run(id, name);
deleteLabelStmt.run(id, name);
}
}
// Add or update tags
if (newTagKeys.length > 0) {
const tagStmt = this.db.prepare(`
INSERT OR REPLACE INTO variable_tags (variable_id, key, value)
VALUES (?, ?, ?)
`);
for (const [key, value] of Object.entries(addTags)) {
tagStmt.run(id, 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 (?, ?)
`);
for (const name of addLabels) {
labelStmt.run(id, name);
}
}
this.db.exec("COMMIT");
} catch (e) {
this.db.exec("ROLLBACK");
throw e;
}
// Return updated variable
const updated = this.get(id);
if (updated === null) {
throw new VariableNotFoundError(id);
}
return updated;
}
/**
* Close the database connection
*/
@@ -0,0 +1,740 @@
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);
});
});
});
+4 -2
View File
@@ -8,11 +8,13 @@ export type VariableId = string;
/**
* Variable: mutable binding to an immutable CAS node
*/
export interface Variable {
export type Variable = {
id: VariableId;
scope: string; // hierarchical path, must end with /
value: Hash; // CAS node hash
schema: Hash; // extracted from value's CAS node.type
created: number; // epoch ms
updated: number; // epoch ms
}
tags: Record<string, string>; // key-value pairs
labels: string[]; // bare identifiers
};