feat: show tag info in get / var get output #56
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
},
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user