feat: show tag info in get / var get output #56

Merged
xiaoju merged 1 commits from fix/53-tag-info-output into main 2026-06-02 11:26:51 +00:00
5 changed files with 310 additions and 7 deletions
+10 -2
View File
@@ -334,7 +334,9 @@ async function cmdGet(args: string[]): Promise<void> {
const hash = resolveHash(input, store);
const node = store.cas.get(hash);
if (node === null) die(`Node not found: ${hash}`);
await out(await wrapEnvelope(store, "@ocas/output/get", node), store);
const tags = store.tag.tags(hash);
const value = tags.length === 0 ? node : { ...node, tags };
await out(await wrapEnvelope(store, "@ocas/output/get", value), store);
}
async function cmdHas(args: string[]): Promise<void> {
@@ -633,7 +635,13 @@ async function cmdVarGet(args: string[]): Promise<void> {
if (variable === null) {
die(`Error: Variable not found: name=${name}, schema=${schema}`);
}
await out(await wrapEnvelope(store, "@ocas/output/var-get", variable), store);
const valueTags = store.tag.tags(variable.value);
const out_value =
valueTags.length === 0 ? variable : { ...variable, valueTags };
await out(
await wrapEnvelope(store, "@ocas/output/var-get", out_value),
store,
);
}
async function cmdVarDelete(args: string[]): Promise<void> {
@@ -69,7 +69,7 @@ exports[`Phase 3: Variable System 3.1 var set creates variable 1`] = `
exports[`Phase 3: Variable System 3.2 var get returns variable 1`] = `
{
"type": "7C75FQT98KKQD",
"type": "F5RRJTXP8Z99D",
"value": {
"labels": [],
"name": "@myapp/config",
@@ -152,7 +152,7 @@ exports[`Phase 3: Variable System 3.3 var list shows all variables 1`] = `
"name": "@ocas/output/get",
"schema": "CTS5P6RD8HMCS",
"tags": {},
"value": "FB4K0SXG68ZFS",
"value": "7V5G8E2VW8B2G",
},
{
"labels": [],
@@ -257,7 +257,7 @@ exports[`Phase 3: Variable System 3.3 var list shows all variables 1`] = `
"name": "@ocas/output/var-get",
"schema": "CTS5P6RD8HMCS",
"tags": {},
"value": "7C75FQT98KKQD",
"value": "F5RRJTXP8Z99D",
},
{
"labels": [],
@@ -2,7 +2,7 @@
exports[`Phase 1: CAS Core 1.6 get returns node JSON (snapshot) 1`] = `
{
"type": "FB4K0SXG68ZFS",
"type": "7V5G8E2VW8B2G",
"value": {
"payload": {
"age": 30,
+269
View File
@@ -0,0 +1,269 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { mkdirSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { Hash } from "@ocas/core";
import { bootstrap, validate } from "@ocas/core";
import { openStore as openFsStore } from "@ocas/fs";
let testDir: string;
let storePath: string;
let cliPath: string;
beforeEach(() => {
testDir = join(
tmpdir(),
`ocas-get-tag-test-${Date.now()}-${Math.random().toString(36).slice(2)}`,
);
storePath = join(testDir, "store");
cliPath = join(import.meta.dir, "../src/index.ts");
mkdirSync(testDir, { recursive: true });
mkdirSync(storePath, { recursive: true });
});
afterEach(() => {
try {
rmSync(testDir, { recursive: true, force: true });
} catch {
// ignore
}
});
async function runCli(...args: string[]): Promise<{
stdout: string;
stderr: string;
exitCode: number;
}> {
const proc = Bun.spawn(
["bun", "run", cliPath, "--home", storePath, ...args],
{ stdout: "pipe", stderr: "pipe" },
);
const [stdout, stderr] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
]);
await proc.exited;
return {
stdout: stdout.trim(),
stderr: stderr.trim(),
exitCode: proc.exitCode ?? 0,
};
}
async function createTestNode(value = "hello-world"): Promise<Hash> {
const store = await openFsStore(storePath);
const aliases = bootstrap(store);
const typeHash = aliases["@ocas/string"];
if (!typeHash) throw new Error("@ocas/string not found");
return store.cas.put(typeHash, value);
}
describe("get/var-get with tags", () => {
test("G1: ocas get on untagged node — output unchanged (no tags field)", async () => {
const hash = await createTestNode("hello");
const { stdout, exitCode } = await runCli("get", hash);
expect(exitCode).toBe(0);
const envelope = JSON.parse(stdout);
expect(typeof envelope.value.type).toBe("string");
expect(envelope.value.payload).toBe("hello");
expect(typeof envelope.value.timestamp).toBe("number");
expect("tags" in envelope.value).toBe(false);
});
test("G2: ocas get on tagged node — includes tags array", async () => {
const hash = await createTestNode("tagged");
await runCli("tag", hash, "env:prod", "stable", "owner:alice");
const { stdout, exitCode } = await runCli("get", hash);
expect(exitCode).toBe(0);
const envelope = JSON.parse(stdout);
expect(envelope.value.payload).toBe("tagged");
expect(typeof envelope.value.timestamp).toBe("number");
expect(Array.isArray(envelope.value.tags)).toBe(true);
expect(envelope.value.tags).toHaveLength(3);
const pairs = new Set(
envelope.value.tags.map(
(t: { key: string; value: string | null }) => `${t.key}=${t.value}`,
),
);
expect(pairs.has("env=prod")).toBe(true);
expect(pairs.has("stable=null")).toBe(true);
expect(pairs.has("owner=alice")).toBe(true);
for (const t of envelope.value.tags) {
expect(t.target).toBe(hash);
expect(typeof t.created).toBe("number");
}
});
test("G3: ocas get after untag removes all tags — no tags key (empty array not serialized)", async () => {
const hash = await createTestNode("u3");
await runCli("tag", hash, "k:v");
await runCli("untag", hash, "k");
const { stdout, exitCode } = await runCli("get", hash);
expect(exitCode).toBe(0);
const envelope = JSON.parse(stdout);
expect("tags" in envelope.value).toBe(false);
});
test("V1: ocas var get when value hash untagged — output unchanged (no valueTags)", async () => {
const hash = await createTestNode("hello");
await runCli("var", "set", "@user/foo", hash);
const { stdout, exitCode } = await runCli(
"var",
"get",
"@user/foo",
"--schema",
"@ocas/string",
);
expect(exitCode).toBe(0);
const envelope = JSON.parse(stdout);
expect(envelope.value.name).toBe("@user/foo");
expect(envelope.value.value).toBe(hash);
expect(envelope.value.tags).toEqual({});
expect(envelope.value.labels).toEqual([]);
expect("valueTags" in envelope.value).toBe(false);
});
test("V2: ocas var get when value hash tagged — includes valueTags array", async () => {
const hash = await createTestNode("hello");
await runCli("var", "set", "@user/foo", hash);
await runCli("tag", hash, "env:prod", "stable");
const { stdout, exitCode } = await runCli(
"var",
"get",
"@user/foo",
"--schema",
"@ocas/string",
);
expect(exitCode).toBe(0);
const envelope = JSON.parse(stdout);
expect(envelope.value.tags).toEqual({});
expect(envelope.value.labels).toEqual([]);
expect(Array.isArray(envelope.value.valueTags)).toBe(true);
expect(envelope.value.valueTags).toHaveLength(2);
const pairs = new Set(
envelope.value.valueTags.map(
(t: { key: string; value: string | null }) => `${t.key}=${t.value}`,
),
);
expect(pairs.has("env=prod")).toBe(true);
expect(pairs.has("stable=null")).toBe(true);
for (const t of envelope.value.valueTags) {
expect(t.target).toBe(hash);
expect(typeof t.created).toBe("number");
}
});
test("V3: variable tags vs valueTags don't collide", async () => {
const hash = await createTestNode("hello");
await runCli("var", "set", "@user/foo", hash, "--tag", "a:1", "--tag", "x");
await runCli("tag", hash, "owner:bob");
const { stdout, exitCode } = await runCli(
"var",
"get",
"@user/foo",
"--schema",
"@ocas/string",
);
expect(exitCode).toBe(0);
const envelope = JSON.parse(stdout);
expect(envelope.value.tags).toEqual({ a: "1" });
expect(envelope.value.labels).toEqual(["x"]);
expect(Array.isArray(envelope.value.valueTags)).toBe(true);
expect(envelope.value.valueTags).toHaveLength(1);
expect(envelope.value.valueTags[0]).toMatchObject({
key: "owner",
value: "bob",
target: hash,
});
});
test("S1: @ocas/output/get schema validates with and without tags", async () => {
const store = await openFsStore(storePath);
const aliases = bootstrap(store);
const getSchemaHash = aliases["@ocas/output/get"];
if (!getSchemaHash) throw new Error("schema not found");
const stringHash = aliases["@ocas/string"];
if (!stringHash) throw new Error("string schema not found");
const untagged = {
type: getSchemaHash,
payload: { type: stringHash, payload: "hi", timestamp: 1 },
timestamp: 2,
};
expect(validate(store, untagged)).toBe(true);
const tagged = {
type: getSchemaHash,
payload: {
type: stringHash,
payload: "hi",
timestamp: 1,
tags: [
{
key: "env",
value: "prod",
target: stringHash,
created: 123,
},
],
},
timestamp: 2,
};
expect(validate(store, tagged)).toBe(true);
});
test("S2: @ocas/output/var-get schema validates with and without valueTags", async () => {
const store = await openFsStore(storePath);
const aliases = bootstrap(store);
const varGetSchemaHash = aliases["@ocas/output/var-get"];
if (!varGetSchemaHash) throw new Error("schema not found");
const stringHash = aliases["@ocas/string"];
if (!stringHash) throw new Error("string schema not found");
const untagged = {
type: varGetSchemaHash,
payload: {
name: "@user/foo",
schema: stringHash,
value: stringHash,
created: 1,
updated: 2,
tags: {},
labels: [],
},
timestamp: 3,
};
expect(validate(store, untagged)).toBe(true);
const tagged = {
type: varGetSchemaHash,
payload: {
name: "@user/foo",
schema: stringHash,
value: stringHash,
created: 1,
updated: 2,
tags: {},
labels: [],
valueTags: [
{
key: "env",
value: "prod",
target: stringHash,
created: 123,
},
],
},
timestamp: 3,
};
expect(validate(store, tagged)).toBe(true);
});
});
+27 -1
View File
@@ -131,6 +131,18 @@ const OUTPUT_SCHEMAS: ReadonlyArray<
type: { type: "string", format: "ocas_ref" },
payload: {},
timestamp: { type: "number" },
tags: {
type: "array",
items: {
type: "object",
properties: {
key: { type: "string" },
value: { type: ["string", "null"] },
target: { type: "string", format: "ocas_ref" },
created: { type: "number" },
},
},
},
},
title: "ocas get result",
},
@@ -224,7 +236,21 @@ const OUTPUT_SCHEMAS: ReadonlyArray<
"@ocas/output/var-get",
{
type: "object",
properties: { ...VARIABLE_PROPERTIES },
properties: {
...VARIABLE_PROPERTIES,
valueTags: {
type: "array",
items: {
type: "object",
properties: {
key: { type: "string" },
value: { type: ["string", "null"] },
target: { type: "string", format: "ocas_ref" },
created: { type: "number" },
},
},
},
},
title: "ocas var get result",
},
],