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>
This commit is contained in:
@@ -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<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 +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<Hash> {
|
||||
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<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> {
|
||||
@@ -308,6 +372,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>");
|
||||
@@ -315,11 +380,31 @@ async function cmdVarCreate(_args: string[]): Promise<void> {
|
||||
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<void> {
|
||||
}
|
||||
}
|
||||
|
||||
async function cmdVarList(_args: string[]): Promise<void> {
|
||||
const scope = (flags.scope as string | undefined) ?? "";
|
||||
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 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<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) {
|
||||
@@ -432,16 +571,18 @@ 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 list [--scope <prefix>] List variables (optionally filter by scope prefix)
|
||||
var tag <id> <tag>... Add/update/delete tags and labels
|
||||
var list [--scope <prefix>] [--tag <tag>...] List variables (filter by scope/tags/labels)
|
||||
|
||||
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 ----
|
||||
@@ -530,6 +671,9 @@ switch (cmd) {
|
||||
case "delete":
|
||||
await cmdVarDelete(subRest);
|
||||
break;
|
||||
case "tag":
|
||||
await cmdVarTag(subRest);
|
||||
break;
|
||||
case "list":
|
||||
await cmdVarList(subRest);
|
||||
break;
|
||||
|
||||
@@ -19,7 +19,9 @@ export {
|
||||
CasNodeNotFoundError,
|
||||
createVariableStore,
|
||||
InvalidScopeError,
|
||||
InvalidTagFormatError,
|
||||
SchemaMismatchError,
|
||||
TagLabelConflictError,
|
||||
VariableNotFoundError,
|
||||
VariableStore,
|
||||
} from "./variable-store.js";
|
||||
|
||||
@@ -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 /", () => {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -203,22 +329,57 @@ export class VariableStore {
|
||||
/**
|
||||
* List variables matching a scope prefix
|
||||
*/
|
||||
list(options?: { scope?: string }): Variable[] {
|
||||
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);
|
||||
}
|
||||
|
||||
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<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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<string, string>; // key-value pairs
|
||||
labels: string[]; // bare identifiers
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user