diff --git a/packages/cli-json-cas/src/index.ts b/packages/cli-json-cas/src/index.ts index 9c2fe12..192214e 100644 --- a/packages/cli-json-cas/src/index.ts +++ b/packages/cli-json-cas/src/index.ts @@ -11,9 +11,11 @@ import { createVariableStore, getSchema, InvalidScopeError, + InvalidTagFormatError, putSchema, refs, SchemaMismatchError, + TagLabelConflictError, VariableNotFoundError, validate, verify, @@ -23,10 +25,17 @@ import { createFsStore } from "@uncaged/json-cas-fs"; // ---- Argument parsing ---- -type Flags = Record; +type Flags = Record; /** 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 +48,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; @@ -113,8 +134,19 @@ async function getVariableSchemaHash(): Promise { 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"], + required: [ + "id", + "scope", + "value", + "schema", + "created", + "updated", + "tags", + "labels", + ], }; // Compute hash or retrieve from store @@ -135,6 +167,38 @@ async function wrapVariableEnvelope( }; } +/** + * Parse tag/label arguments + * Returns: { tags: Record, labels: string[], deleteNames: string[] } + */ +function parseTagsLabels(args: string[]): { + tags: Record; + labels: string[]; + deleteNames: string[]; +} { + const tags: Record = {}; + 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 { @@ -308,6 +372,7 @@ async function cmdCat(args: string[]): Promise { async function cmdVarCreate(_args: string[]): Promise { const scope = flags.scope as string | undefined; const value = flags.value as string | undefined; + const tagFlags = flags.tag; if (!scope) die("Usage: json-cas var create --scope --value "); if (!value) die("Usage: json-cas var create --scope --value "); @@ -315,11 +380,31 @@ async function cmdVarCreate(_args: string[]): Promise { const varStore = openVarStore(); try { - const variable = varStore.create(scope, value); + // 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; @@ -394,13 +479,67 @@ async function cmdVarDelete(args: string[]): Promise { } } -async function cmdVarList(_args: string[]): Promise { - const scope = (flags.scope as string | undefined) ?? ""; +async function cmdVarTag(args: string[]): Promise { + const id = args[0]; + if (!id) die("Usage: json-cas var tag ..."); + + const tagArgs = args.slice(1); + if (tagArgs.length === 0) { + die("Usage: json-cas var tag ..."); + } const varStore = openVarStore(); try { - const variables = varStore.list({ scope }); + 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 { + 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) { @@ -432,16 +571,18 @@ Commands: walk [--format tree] Recursive traversal hash Compute hash without storing (dry run) cat [--payload] Output node (--payload for payload only) - var create --scope --value Create a variable + var create --scope --value [--tag ...] Create a variable var get Get a variable by ID var update Update variable value var delete Delete a variable - var list [--scope ] List variables (optionally filter by scope prefix) + var tag ... Add/update/delete tags and labels + var list [--scope ] [--tag ...] List variables (filter by scope/tags/labels) Flags: --store Store directory (default: ~/.uncaged/json-cas) --var-db Variable database path (default: /variables.db) - --json Compact JSON output`); + --json Compact JSON output + --tag Tag/label (can be repeated): key:value (tag), name (label), :name (delete)`); } // ---- Dispatch ---- @@ -530,6 +671,9 @@ switch (cmd) { case "delete": await cmdVarDelete(subRest); break; + case "tag": + await cmdVarTag(subRest); + break; case "list": await cmdVarList(subRest); break; diff --git a/packages/json-cas/src/index.ts b/packages/json-cas/src/index.ts index ba0e19e..f93d692 100644 --- a/packages/json-cas/src/index.ts +++ b/packages/json-cas/src/index.ts @@ -19,7 +19,9 @@ export { CasNodeNotFoundError, createVariableStore, InvalidScopeError, + InvalidTagFormatError, SchemaMismatchError, + TagLabelConflictError, VariableNotFoundError, VariableStore, } from "./variable-store.js"; diff --git a/packages/json-cas/src/variable-store.test.ts b/packages/json-cas/src/variable-store.test.ts index e606d1f..f474dc6 100644 --- a/packages/json-cas/src/variable-store.test.ts +++ b/packages/json-cas/src/variable-store.test.ts @@ -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 /", () => { diff --git a/packages/json-cas/src/variable-store.ts b/packages/json-cas/src/variable-store.ts index f0712be..10af467 100644 --- a/packages/json-cas/src/variable-store.ts +++ b/packages/json-cas/src/variable-store.ts @@ -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; + 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 { + const stmt = this.db.prepare(` + SELECT key, value + FROM variable_tags + WHERE variable_id = ? + `); + + const rows = stmt.all(id) as Array<{ key: string; value: string }>; + const tags: Record = {}; + for (const row of rows) { + tags[row.key] = row.value; + } + return tags; + } + + /** + * Load labels for a variable + */ + 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, }; } @@ -203,22 +329,57 @@ export class VariableStore { /** * List variables matching a scope prefix */ - list(options?: { scope?: string }): Variable[] { + list(options?: { + scope?: string; + tags?: Record; + 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); } - const stmt = this.db.prepare(` - SELECT id, scope, value, schema, created, updated - FROM variables - WHERE scope LIKE ? || '%' - ORDER BY created ASC - `); + // 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 rows = stmt.all(scope) as Array<{ + 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; @@ -234,9 +395,116 @@ export class VariableStore { 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; // 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 */ diff --git a/packages/json-cas/src/variable-tags-labels.test.ts b/packages/json-cas/src/variable-tags-labels.test.ts new file mode 100644 index 0000000..5b6d295 --- /dev/null +++ b/packages/json-cas/src/variable-tags-labels.test.ts @@ -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); + }); + }); +}); diff --git a/packages/json-cas/src/variable.ts b/packages/json-cas/src/variable.ts index fa50383..3d74134 100644 --- a/packages/json-cas/src/variable.ts +++ b/packages/json-cas/src/variable.ts @@ -15,4 +15,6 @@ export type Variable = { schema: Hash; // extracted from value's CAS node.type created: number; // epoch ms updated: number; // epoch ms + tags: Record; // key-value pairs + labels: string[]; // bare identifiers };