diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index cb54ced..c423ce6 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -334,7 +334,9 @@ async function cmdGet(args: string[]): Promise { 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 { @@ -633,7 +635,13 @@ async function cmdVarGet(args: string[]): Promise { 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 { diff --git a/packages/cli/tests/__snapshots__/edge-cases.test.ts.snap b/packages/cli/tests/__snapshots__/edge-cases.test.ts.snap index 7646251..361a10f 100644 --- a/packages/cli/tests/__snapshots__/edge-cases.test.ts.snap +++ b/packages/cli/tests/__snapshots__/edge-cases.test.ts.snap @@ -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": [], diff --git a/packages/cli/tests/__snapshots__/put-get-has.test.ts.snap b/packages/cli/tests/__snapshots__/put-get-has.test.ts.snap index 6ac88e3..addbd57 100644 --- a/packages/cli/tests/__snapshots__/put-get-has.test.ts.snap +++ b/packages/cli/tests/__snapshots__/put-get-has.test.ts.snap @@ -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, diff --git a/packages/cli/tests/get-tag-info.test.ts b/packages/cli/tests/get-tag-info.test.ts new file mode 100644 index 0000000..7770311 --- /dev/null +++ b/packages/cli/tests/get-tag-info.test.ts @@ -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 { + 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); + }); +}); diff --git a/packages/core/src/bootstrap.ts b/packages/core/src/bootstrap.ts index 5322604..c48b235 100644 --- a/packages/core/src/bootstrap.ts +++ b/packages/core/src/bootstrap.ts @@ -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", }, ],