import { describe, expect, test, vi } from "vitest"; import { bootstrap } from "./bootstrap.js"; import { getSchema, type OnDangling, putSchema, refs, validate, walk, } from "./schema.js"; import { createMemoryStore } from "./store.js"; import type { CasNode, Hash } from "./types.js"; // ────────────────────────────────────────────────────────────────────────────── // Step 1: putSchema() — store a JSON Schema as a CAS node // ────────────────────────────────────────────────────────────────────────────── describe("putSchema", () => { test("returns a valid 13-char hash", async () => { const store = createMemoryStore(); const hash = putSchema(store, { type: "object", properties: {} }); expect(hash).toHaveLength(13); expect(hash).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/); }); test("schema node is stored in the store", async () => { const store = createMemoryStore(); const schema = { type: "object", properties: { name: { type: "string" } } }; const hash = putSchema(store, schema); expect(store.cas.has(hash)).toBe(true); const node = store.cas.get(hash); expect(node).not.toBeNull(); expect(node?.payload).toEqual(schema); }); test("schema node type equals the meta-schema hash", async () => { const store = createMemoryStore(); const builtinSchemas = bootstrap(store); const metaHash = builtinSchemas["@ocas/schema"] ?? ""; const schemaHash = putSchema(store, { type: "string" }); const node = store.cas.get(schemaHash) as CasNode; expect(node.type).toBe(metaHash); }); test("putSchema is idempotent: same schema → same hash", async () => { const store = createMemoryStore(); const schema = { type: "number" }; const h1 = putSchema(store, schema); const h2 = putSchema(store, schema); expect(h1).toBe(h2); }); test("different schemas produce different hashes", async () => { const store = createMemoryStore(); const h1 = putSchema(store, { type: "string" }); const h2 = putSchema(store, { type: "number" }); expect(h1).not.toBe(h2); }); }); // ────────────────────────────────────────────────────────────────────────────── // Step 2: getSchema() — retrieve a JSON Schema by type hash // ────────────────────────────────────────────────────────────────────────────── describe("getSchema", () => { test("returns the original schema object", async () => { const store = createMemoryStore(); const schema = { type: "object", properties: { age: { type: "number" } } }; const hash = putSchema(store, schema); expect(getSchema(store, hash)).toEqual(schema); }); test("returns null for an unknown hash", () => { const store = createMemoryStore(); expect(getSchema(store, "0000000000000")).toBeNull(); }); test("roundtrip: put then get returns the same schema", async () => { const store = createMemoryStore(); const schema = { type: "object", required: ["id"], properties: { id: { type: "string", format: "ocas_ref" }, label: { type: "string" }, }, }; const hash = putSchema(store, schema); expect(getSchema(store, hash)).toEqual(schema); }); }); // ────────────────────────────────────────────────────────────────────────────── // Step 3: validate() — validate a node's payload against its schema // ────────────────────────────────────────────────────────────────────────────── describe("validate", () => { test("returns true when payload matches the schema", async () => { const store = createMemoryStore(); const schemaHash = putSchema(store, { type: "object", properties: { name: { type: "string" }, age: { type: "number" } }, required: ["name"], }); const nodeHash = store.cas.put(schemaHash, { name: "Alice", age: 30 }); const node = store.cas.get(nodeHash) as CasNode; expect(validate(store, node)).toBe(true); }); test("returns false when payload violates the schema", async () => { const store = createMemoryStore(); const schemaHash = putSchema(store, { type: "object", properties: { count: { type: "number" } }, required: ["count"], }); const nodeHash = store.cas.put(schemaHash, { count: "not-a-number" }); const node = store.cas.get(nodeHash) as CasNode; expect(validate(store, node)).toBe(false); }); test("returns false when required field is missing", async () => { const store = createMemoryStore(); const schemaHash = putSchema(store, { type: "object", required: ["title"], properties: { title: { type: "string" } }, }); const nodeHash = store.cas.put(schemaHash, {}); const node = store.cas.get(nodeHash) as CasNode; expect(validate(store, node)).toBe(false); }); test("returns false when schema cannot be found", async () => { const store = createMemoryStore(); const fakeNode: CasNode = { type: "0000000000000", payload: { x: 1 }, timestamp: Date.now(), }; expect(validate(store, fakeNode)).toBe(false); }); }); // ────────────────────────────────────────────────────────────────────────────── // Step 4: refs() — extract ocas_ref hashes from a node's payload // ────────────────────────────────────────────────────────────────────────────── describe("refs", () => { test("returns empty array when schema has no ocas_ref fields", async () => { const store = createMemoryStore(); const schemaHash = putSchema(store, { type: "object", properties: { title: { type: "string" } }, }); const nodeHash = store.cas.put(schemaHash, { title: "hello" }); const node = store.cas.get(nodeHash) as CasNode; expect(refs(store, node)).toEqual([]); }); test("returns the ocas_ref hash values from payload", async () => { const store = createMemoryStore(); const schemaHash = putSchema(store, { type: "object", properties: { parentHash: { type: "string", format: "ocas_ref" }, label: { type: "string" }, }, }); const targetHash = "AAAAAAAAAAAAA"; const nodeHash = store.cas.put(schemaHash, { parentHash: targetHash, label: "child", }); const node = store.cas.get(nodeHash) as CasNode; expect(refs(store, node)).toEqual([targetHash]); }); test("collects multiple ocas_ref fields", async () => { const store = createMemoryStore(); const schemaHash = putSchema(store, { type: "object", properties: { leftHash: { type: "string", format: "ocas_ref" }, rightHash: { type: "string", format: "ocas_ref" }, }, }); const h1 = "AAAAAAAAAAAAA"; const h2 = "BBBBBBBBBBBBB"; const nodeHash = store.cas.put(schemaHash, { leftHash: h1, rightHash: h2, }); const node = store.cas.get(nodeHash) as CasNode; const result = refs(store, node); expect(result).toHaveLength(2); expect(result).toContain(h1); expect(result).toContain(h2); }); test("skips null/undefined ocas_ref values", async () => { const store = createMemoryStore(); const schemaHash = putSchema(store, { type: "object", properties: { optionalRef: { type: "string", format: "ocas_ref" }, label: { type: "string" }, }, }); const nodeHash = store.cas.put(schemaHash, { label: "no ref here" }); const node = store.cas.get(nodeHash) as CasNode; expect(refs(store, node)).toEqual([]); }); test("returns empty array when schema is not found", () => { const store = createMemoryStore(); const orphanNode: CasNode = { type: "0000000000000", payload: { x: 1 }, timestamp: Date.now(), }; expect(refs(store, orphanNode)).toEqual([]); }); }); // ────────────────────────────────────────────────────────────────────────────── // Step 5: walk() — BFS traversal via ocas_ref links // ────────────────────────────────────────────────────────────────────────────── describe("walk", () => { test("visits a single node with no refs", async () => { const store = createMemoryStore(); const schemaHash = putSchema(store, { type: "object", properties: { val: { type: "number" } }, }); const nodeHash = store.cas.put(schemaHash, { val: 42 }); const visited: string[] = []; walk(store, nodeHash, (hash) => visited.push(hash)); expect(visited).toContain(nodeHash); }); test("visits all reachable nodes in a chain A → B → C", async () => { const store = createMemoryStore(); const schemaHash = putSchema(store, { type: "object", properties: { nextHash: { type: "string", format: "ocas_ref" }, val: { type: "number" }, }, }); const hashC = store.cas.put(schemaHash, { val: 3 }); const hashB = store.cas.put(schemaHash, { nextHash: hashC, val: 2 }); const hashA = store.cas.put(schemaHash, { nextHash: hashB, val: 1 }); const visited: string[] = []; walk(store, hashA, (hash) => visited.push(hash)); expect(visited).toContain(hashA); expect(visited).toContain(hashB); expect(visited).toContain(hashC); expect(visited[0]).toBe(hashA); }); test("handles cycles without infinite loop", async () => { const store = createMemoryStore(); const schemaHash = putSchema(store, { type: "object", properties: { peerHash: { type: "string", format: "ocas_ref" }, val: { type: "number" }, }, }); // A → B, B → A (manual cycle by inserting pre-known hash) const hashA = store.cas.put(schemaHash, { val: 1 }); const _hashB = store.cas.put(schemaHash, { peerHash: hashA, val: 2 }); // update A to point at B — since store is content-addressed we can't mutate, // so we build a diamond: root → A and root → B, A → C, B → C const hashC = store.cas.put(schemaHash, { val: 3 }); const hashD = store.cas.put(schemaHash, { peerHash: hashC, val: 4 }); const hashE = store.cas.put(schemaHash, { peerHash: hashC, val: 5 }); const schemaHash2 = putSchema(store, { type: "object", properties: { leftHash: { type: "string", format: "ocas_ref" }, rightHash: { type: "string", format: "ocas_ref" }, }, }); const rootHash = store.cas.put(schemaHash2, { leftHash: hashD, rightHash: hashE, }); const visited = new Set(); walk(store, rootHash, (hash) => visited.add(hash)); // Should visit root + hashD + hashE + hashC (shared node visited once) expect(visited.has(rootHash)).toBe(true); expect(visited.has(hashD)).toBe(true); expect(visited.has(hashE)).toBe(true); expect(visited.has(hashC)).toBe(true); }); test("skips missing hashes gracefully", async () => { const store = createMemoryStore(); const schemaHash = putSchema(store, { type: "object", properties: { ref: { type: "string", format: "ocas_ref" } }, }); const nodeHash = store.cas.put(schemaHash, { ref: "0000000000000" }); const visited: string[] = []; walk(store, nodeHash, (hash) => visited.push(hash)); // visits the root; the dangling ref hash has no stored node so it's skipped expect(visited).toContain(nodeHash); }); test("visitor receives both hash and node", async () => { const store = createMemoryStore(); const schemaHash = putSchema(store, { type: "object", properties: { x: { type: "number" } }, }); const nodeHash = store.cas.put(schemaHash, { x: 7 }); const received: { hash: string; node: CasNode }[] = []; walk(store, nodeHash, (hash, node) => { received.push({ hash, node }); }); const rootCall = received.find((r) => r.hash === nodeHash); expect(rootCall).toBeDefined(); expect(rootCall?.node.payload).toEqual({ x: 7 }); }); }); // ────────────────────────────────────────────────────────────────────────────── // Suite W: walk() follows node.type (#130) // ────────────────────────────────────────────────────────────────────────────── describe("walk() follows node.type (#130)", () => { test("W.1 walk() visits node.type alongside the node itself", () => { const store = createMemoryStore(); const aliases = bootstrap(store); const metaHash = aliases["@ocas/schema"] as Hash; const schemaHash = putSchema(store, { type: "object", properties: { val: { type: "number" } }, }); const dataHash = store.cas.put(schemaHash, { val: 42 }); const visited = new Set(); walk(store, dataHash, (h) => { visited.add(h); }); expect(visited.has(dataHash)).toBe(true); expect(visited.has(schemaHash)).toBe(true); expect(visited.has(metaHash)).toBe(true); // transitively via schemaHash.type }); test("W.2 walk() terminates on self-referencing meta-schema", () => { const store = createMemoryStore(); const aliases = bootstrap(store); const metaHash = aliases["@ocas/schema"] as Hash; const visited = new Set(); walk(store, metaHash, (h) => { visited.add(h); }); expect(visited.has(metaHash)).toBe(true); expect(visited.size).toBeGreaterThanOrEqual(1); }); test("W.3 walk() reaches refs embedded inside a schema node's payload", () => { const store = createMemoryStore(); const aliases = bootstrap(store); const metaHash = aliases["@ocas/schema"] as Hash; const stringHash = aliases["@ocas/string"] as Hash; // Custom meta: a schema that adds an extraRef field (format: ocas_ref). const customMeta = store.cas.put(metaHash, { type: "object", properties: { extraRef: { type: "string", format: "ocas_ref" }, type: { type: "string" }, }, }); const targetHash = store.cas.put(stringHash, "secret"); // S is typed by customMeta AND has extraRef pointing to targetHash. const S = store.cas.put(customMeta, { extraRef: targetHash, type: "string", }); // D is typed by S (since S.payload says type:"string", D.payload must be a string). const D = store.cas.put(S, "hello"); const visited = new Set(); walk(store, D, (h) => { visited.add(h); }); expect(visited.has(D)).toBe(true); expect(visited.has(S)).toBe(true); expect(visited.has(customMeta)).toBe(true); expect(visited.has(targetHash)).toBe(true); // ← the new behavior }); test("W.4 walk() reports dangling type via onDangling", () => { const store = createMemoryStore(); const schemaHash = putSchema(store, { type: "object", properties: { val: { type: "number" } }, }); const dataHash = store.cas.put(schemaHash, { val: 1 }); expect(store.cas.delete(schemaHash)).toBe(true); const visited: Hash[] = []; const spy = vi.fn(); walk(store, dataHash, (h) => visited.push(h), { onDangling: spy }); expect(visited).toContain(dataHash); expect(spy).toHaveBeenCalledWith(schemaHash); }); test("W.5 walk() visits shared type exactly once across siblings", () => { const store = createMemoryStore(); const leaf = putSchema(store, { type: "object", properties: { v: { type: "number" } }, }); const pair = putSchema(store, { type: "object", properties: { a: { type: "string", format: "ocas_ref" }, b: { type: "string", format: "ocas_ref" }, }, }); const d1 = store.cas.put(leaf, { v: 1 }); const d2 = store.cas.put(leaf, { v: 2 }); const root = store.cas.put(pair, { a: d1, b: d2 }); const calls: Hash[] = []; walk(store, root, (h) => calls.push(h)); const leafCount = calls.filter((h) => h === leaf).length; expect(leafCount).toBe(1); }); }); // ────────────────────────────────────────────────────────────────────────────── // Step 6: Bootstrap self-reference in schema context // ────────────────────────────────────────────────────────────────────────────── describe("bootstrap meta-schema self-reference", () => { test("metaNode.type === metaHash (self-referencing)", async () => { const store = createMemoryStore(); const builtinSchemas = bootstrap(store); const metaHash = builtinSchemas["@ocas/schema"] ?? ""; const metaNode = store.cas.get(metaHash) as CasNode; expect(metaNode.type).toBe(metaHash); }); test("schema nodes have type === metaHash", async () => { const store = createMemoryStore(); const builtinSchemas = bootstrap(store); const metaHash = builtinSchemas["@ocas/schema"] ?? ""; const schemaHash = putSchema(store, { type: "string" }); const schemaNode = store.cas.get(schemaHash) as CasNode; expect(schemaNode.type).toBe(metaHash); }); test("data nodes have type === schemaHash (not metaHash)", async () => { const store = createMemoryStore(); const builtinSchemas = bootstrap(store); const metaHash = builtinSchemas["@ocas/schema"] ?? ""; const schemaHash = putSchema(store, { type: "object", properties: { val: { type: "number" } }, }); const dataHash = store.cas.put(schemaHash, { val: 99 }); const dataNode = store.cas.get(dataHash) as CasNode; expect(dataNode.type).toBe(schemaHash); expect(dataNode.type).not.toBe(metaHash); }); // ── P1 leaf constraints ────────────────────────────────────────────────── test("accepts schema with numeric constraints (minimum/maximum)", async () => { const store = createMemoryStore(); const hash = putSchema(store, { type: "number", minimum: 0, maximum: 100, exclusiveMinimum: -1, exclusiveMaximum: 101, }); expect(hash).toHaveLength(13); // validate a conforming payload const nodeHash = store.cas.put(hash, 42); const node = store.cas.get(nodeHash) as CasNode; expect(validate(store, node)).toBe(true); // validate a non-conforming payload const badHash = store.cas.put(hash, 200); const badNode = store.cas.get(badHash) as CasNode; expect(validate(store, badNode)).toBe(false); }); test("accepts schema with string constraints (minLength/maxLength/pattern)", async () => { const store = createMemoryStore(); const hash = putSchema(store, { type: "string", minLength: 1, maxLength: 10, pattern: "^[a-z]+$", }); expect(hash).toHaveLength(13); const goodHash = store.cas.put(hash, "hello"); expect(validate(store, store.cas.get(goodHash) as CasNode)).toBe(true); const badHash = store.cas.put(hash, "HELLO"); expect(validate(store, store.cas.get(badHash) as CasNode)).toBe(false); }); test("accepts schema with array constraints (minItems/maxItems/uniqueItems)", async () => { const store = createMemoryStore(); const hash = putSchema(store, { type: "array", items: { type: "number" }, minItems: 1, maxItems: 3, uniqueItems: true, }); expect(hash).toHaveLength(13); const goodHash = store.cas.put(hash, [1, 2, 3]); expect(validate(store, store.cas.get(goodHash) as CasNode)).toBe(true); const tooMany = store.cas.put(hash, [1, 2, 3, 4]); expect(validate(store, store.cas.get(tooMany) as CasNode)).toBe(false); const dupes = store.cas.put(hash, [1, 1]); expect(validate(store, store.cas.get(dupes) as CasNode)).toBe(false); }); test("rejects schema with wrong constraint types", async () => { const store = createMemoryStore(); expect(() => putSchema(store, { type: "number", minimum: "zero" } as never), ).toThrow(); expect(() => putSchema(store, { type: "string", maxLength: true } as never), ).toThrow(); expect(() => putSchema(store, { type: "array", uniqueItems: 1 } as never), ).toThrow(); }); test("accepts schema with nested property constraints", async () => { const store = createMemoryStore(); const hash = putSchema(store, { type: "object", properties: { name: { type: "string", minLength: 1, maxLength: 50 }, age: { type: "number", minimum: 0, maximum: 150 }, scores: { type: "array", items: { type: "number" }, minItems: 1, uniqueItems: true, }, }, required: ["name"], }); expect(hash).toHaveLength(13); const good = store.cas.put(hash, { name: "Alice", age: 30, scores: [95, 87], }); expect(validate(store, store.cas.get(good) as CasNode)).toBe(true); }); test("bootstrap is idempotent across putSchema calls", async () => { const store = createMemoryStore(); const builtinSchemas = bootstrap(store); const metaHash = builtinSchemas["@ocas/schema"] ?? ""; putSchema(store, { type: "string" }); putSchema(store, { type: "number" }); // bootstrap node should still be there and unchanged const metaNode = store.cas.get(metaHash) as CasNode; expect(metaNode.type).toBe(metaHash); }); // ── P2 combinators, conditionals, and leaf constraints ────────────────── test("accepts schema with allOf", async () => { const store = createMemoryStore(); const hash = putSchema(store, { allOf: [ { type: "object", properties: { name: { type: "string" } } }, { type: "object", required: ["name"] }, ], }); expect(hash).toHaveLength(13); const good = store.cas.put(hash, { name: "Alice" }); expect(validate(store, store.cas.get(good) as CasNode)).toBe(true); const bad = store.cas.put(hash, {}); expect(validate(store, store.cas.get(bad) as CasNode)).toBe(false); }); test("accepts schema with if/then/else", async () => { const store = createMemoryStore(); const hash = putSchema(store, { type: "object", properties: { kind: { type: "string" }, value: {}, }, if: { properties: { kind: { const: "number" } } }, // biome-ignore lint/suspicious/noThenProperty: JSON Schema keyword, not a thenable then: { properties: { value: { type: "number" } } }, else: { properties: { value: { type: "string" } } }, }); expect(hash).toHaveLength(13); }); test("accepts schema with patternProperties", async () => { const store = createMemoryStore(); const hash = putSchema(store, { type: "object", patternProperties: { "^x-": { type: "string" }, }, }); expect(hash).toHaveLength(13); const good = store.cas.put(hash, { "x-custom": "hello" }); expect(validate(store, store.cas.get(good) as CasNode)).toBe(true); }); test("accepts schema with prefixItems (tuple)", async () => { const store = createMemoryStore(); const hash = putSchema(store, { type: "array", prefixItems: [{ type: "string" }, { type: "number" }], }); expect(hash).toHaveLength(13); }); test("accepts schema with multipleOf", async () => { const store = createMemoryStore(); const hash = putSchema(store, { type: "number", multipleOf: 5, }); expect(hash).toHaveLength(13); const good = store.cas.put(hash, 15); expect(validate(store, store.cas.get(good) as CasNode)).toBe(true); const bad = store.cas.put(hash, 7); expect(validate(store, store.cas.get(bad) as CasNode)).toBe(false); }); test("accepts schema with minProperties/maxProperties", async () => { const store = createMemoryStore(); const hash = putSchema(store, { type: "object", minProperties: 1, maxProperties: 3, }); expect(hash).toHaveLength(13); const good = store.cas.put(hash, { a: 1, b: 2 }); expect(validate(store, store.cas.get(good) as CasNode)).toBe(true); const empty = store.cas.put(hash, {}); expect(validate(store, store.cas.get(empty) as CasNode)).toBe(false); }); test("accepts schema with default value", async () => { const store = createMemoryStore(); const hash = putSchema(store, { type: "string", default: "hello", }); expect(hash).toHaveLength(13); }); test("rejects invalid P2 keyword types", async () => { const store = createMemoryStore(); expect(() => putSchema(store, { allOf: "not-array" } as never)).toThrow(); expect(() => putSchema(store, { multipleOf: "five" } as never)).toThrow(); expect(() => putSchema(store, { patternProperties: [1, 2] } as never), ).toThrow(); }); test("collectRefs traverses allOf sub-schemas", async () => { const store = createMemoryStore(); const innerSchema = putSchema(store, { type: "string" }); const schema = putSchema(store, { allOf: [ { type: "object", properties: { ref: { type: "string", format: "ocas_ref" } }, }, ], }); const targetHash = store.cas.put(innerSchema, "target"); const nodeHash = store.cas.put(schema, { ref: targetHash }); const node = store.cas.get(nodeHash) as CasNode; const refList = refs(store, node); expect(refList).toContain(targetHash); }); test("collectRefs traverses patternProperties", async () => { const store = createMemoryStore(); const innerSchema = putSchema(store, { type: "string" }); const schema = putSchema(store, { type: "object", patternProperties: { "^ref_": { type: "string", format: "ocas_ref" }, }, }); const targetHash = store.cas.put(innerSchema, "hello"); const nodeHash = store.cas.put(schema, { ref_a: targetHash }); const node = store.cas.get(nodeHash) as CasNode; const refList = refs(store, node); expect(refList).toContain(targetHash); }); test("collectRefs traverses prefixItems", async () => { const store = createMemoryStore(); const innerSchema = putSchema(store, { type: "string" }); const schema = putSchema(store, { type: "array", prefixItems: [{ type: "string", format: "ocas_ref" }, { type: "number" }], }); const targetHash = store.cas.put(innerSchema, "hello"); const nodeHash = store.cas.put(schema, [targetHash, 42]); const node = store.cas.get(nodeHash) as CasNode; const refList = refs(store, node); expect(refList).toContain(targetHash); }); // ── P3 combinators, propertyNames, and metadata ────────────────────────── test("accepts schema with not", async () => { const store = createMemoryStore(); const hash = putSchema(store, { not: { type: "string" }, }); expect(hash).toHaveLength(13); const good = store.cas.put(hash, 42); expect(validate(store, store.cas.get(good) as CasNode)).toBe(true); const bad = store.cas.put(hash, "hello"); expect(validate(store, store.cas.get(bad) as CasNode)).toBe(false); }); test("accepts schema with contains", async () => { const store = createMemoryStore(); const hash = putSchema(store, { type: "array", contains: { type: "number", minimum: 10 }, }); expect(hash).toHaveLength(13); const good = store.cas.put(hash, [1, 2, 15]); expect(validate(store, store.cas.get(good) as CasNode)).toBe(true); const bad = store.cas.put(hash, [1, 2, 3]); expect(validate(store, store.cas.get(bad) as CasNode)).toBe(false); }); test("accepts schema with propertyNames", async () => { const store = createMemoryStore(); const hash = putSchema(store, { type: "object", propertyNames: { pattern: "^[a-z]+$" }, }); expect(hash).toHaveLength(13); const good = store.cas.put(hash, { foo: 1, bar: 2 }); expect(validate(store, store.cas.get(good) as CasNode)).toBe(true); const bad = store.cas.put(hash, { Foo: 1 }); expect(validate(store, store.cas.get(bad) as CasNode)).toBe(false); }); test("accepts schema with metadata keywords", async () => { const store = createMemoryStore(); const hash = putSchema(store, { type: "string", examples: ["hello", "world"], readOnly: true, deprecated: false, $comment: "This is a test schema", }); expect(hash).toHaveLength(13); }); test("rejects invalid P3 keyword types", async () => { const store = createMemoryStore(); expect(() => putSchema(store, { not: "not-object" } as never)).toThrow(); expect(() => putSchema(store, { examples: "not-array" } as never), ).toThrow(); expect(() => putSchema(store, { readOnly: "yes" } as never)).toThrow(); expect(() => putSchema(store, { $comment: 42 } as never)).toThrow(); }); test("collectRefs traverses contains", async () => { const store = createMemoryStore(); const innerSchema = putSchema(store, { type: "string" }); const schema = putSchema(store, { type: "array", contains: { type: "string", format: "ocas_ref" }, }); const targetHash = store.cas.put(innerSchema, "hello"); const nodeHash = store.cas.put(schema, [targetHash, "not-a-ref"]); const node = store.cas.get(nodeHash) as CasNode; const refList = refs(store, node); expect(refList).toContain(targetHash); }); }); // ────────────────────────────────────────────────────────────────────────────── // Suite A: collectRefs() oneOf traversal (issue #93) // ────────────────────────────────────────────────────────────────────────────── describe("collectRefs oneOf traversal", () => { test("A.1 returns the ref hash from the chosen oneOf branch (string variant)", async () => { const store = createMemoryStore(); const innerSchema = putSchema(store, { type: "string" }); const schema = putSchema(store, { type: "object", properties: { prev: { oneOf: [{ type: "null" }, { type: "string", format: "ocas_ref" }], }, }, }); const targetHash = store.cas.put(innerSchema, "target"); const nodeHash = store.cas.put(schema, { prev: targetHash }); const node = store.cas.get(nodeHash) as CasNode; expect(refs(store, node)).toContain(targetHash); }); test("A.2 returns no ref when oneOf matches the null variant", async () => { const store = createMemoryStore(); const schema = putSchema(store, { type: "object", properties: { prev: { oneOf: [{ type: "null" }, { type: "string", format: "ocas_ref" }], }, }, }); const nodeHash = store.cas.put(schema, { prev: null }); const node = store.cas.get(nodeHash) as CasNode; expect(refs(store, node)).toEqual([]); }); test("A.3 traverses nested combinators inside oneOf", async () => { const store = createMemoryStore(); const innerSchema = putSchema(store, { type: "string" }); const schema = putSchema(store, { oneOf: [ { type: "null" }, { type: "object", properties: { ref: { type: "string", format: "ocas_ref" } }, }, ], }); const targetHash = store.cas.put(innerSchema, "target"); const nodeHash = store.cas.put(schema, { ref: targetHash }); const node = store.cas.get(nodeHash) as CasNode; expect(refs(store, node)).toContain(targetHash); }); test("A.4 multiple ref branches in oneOf all surface", async () => { const store = createMemoryStore(); const innerSchema = putSchema(store, { type: "string" }); const schema = putSchema(store, { oneOf: [ { type: "string", format: "ocas_ref" }, { type: "string", format: "ocas_ref" }, ], }); const targetHash = store.cas.put(innerSchema, "target"); const nodeHash = store.cas.put(schema, targetHash); const node = store.cas.get(nodeHash) as CasNode; const result = refs(store, node); expect(result).toContain(targetHash); }); }); // ────────────────────────────────────────────────────────────────────────────── // Suite B: dangling refs (issue #112) — graceful handling via onDangling // ────────────────────────────────────────────────────────────────────────────── describe("dangling refs (issue #112)", () => { // ── refs() — dangling type schema and dangling ref targets ─────────────── test("refs() does not throw when node.type is missing and no callback is given", () => { const store = createMemoryStore(); const node: CasNode = { type: "0000000000000", payload: { x: 1 }, timestamp: Date.now(), }; expect(() => refs(store, node)).not.toThrow(); expect(refs(store, node)).toEqual([]); }); test("refs() with onDangling is NOT called for missing schema (only for missing payload refs)", () => { const store = createMemoryStore(); const node: CasNode = { type: "0000000000000", payload: { x: 1 }, timestamp: Date.now(), }; const spy = vi.fn(); const result = refs(store, node, { onDangling: spy }); expect(result).toEqual([]); expect(spy).not.toHaveBeenCalled(); }); test("refs() returns ref hashes regardless of whether targets exist", () => { const store = createMemoryStore(); const schemaHash = putSchema(store, { type: "object", properties: { ref: { type: "string", format: "ocas_ref" } }, }); const missingHash: Hash = "AAAAAAAAAAAAA"; const nodeHash = store.cas.put(schemaHash, { ref: missingHash }); const node = store.cas.get(nodeHash) as CasNode; // Without options: returns the ref, no throw. expect(() => refs(store, node)).not.toThrow(); expect(refs(store, node)).toEqual([missingHash]); // With onDangling: ref is still returned, callback called once. const spy = vi.fn(); const result = refs(store, node, { onDangling: spy }); expect(result).toEqual([missingHash]); expect(spy).toHaveBeenCalledTimes(1); expect(spy).toHaveBeenCalledWith(missingHash); }); test("refs() de-duplicates onDangling per unique hash", () => { const store = createMemoryStore(); const schemaHash = putSchema(store, { type: "object", properties: { leftHash: { type: "string", format: "ocas_ref" }, rightHash: { type: "string", format: "ocas_ref" }, }, }); const missingHash: Hash = "AAAAAAAAAAAAA"; const nodeHash = store.cas.put(schemaHash, { leftHash: missingHash, rightHash: missingHash, }); const node = store.cas.get(nodeHash) as CasNode; const spy = vi.fn(); const result = refs(store, node, { onDangling: spy }); // Returned array preserves duplicates (current refs() semantics) expect(result).toHaveLength(2); expect(result.every((h) => h === missingHash)).toBe(true); // Callback fired only once for the unique hash expect(spy).toHaveBeenCalledTimes(1); expect(spy).toHaveBeenCalledWith(missingHash); }); // ── walk() — dangling root and dangling descendants ───────────────────── test("walk() default behavior is unchanged when there are no dangling refs (regression: optional options)", () => { const store = createMemoryStore(); const schemaHash = putSchema(store, { type: "object", properties: { nextHash: { type: "string", format: "ocas_ref" }, val: { type: "number" }, }, }); const hashC = store.cas.put(schemaHash, { val: 3 }); const hashB = store.cas.put(schemaHash, { nextHash: hashC, val: 2 }); const hashA = store.cas.put(schemaHash, { nextHash: hashB, val: 1 }); const visited: string[] = []; walk(store, hashA, (hash) => visited.push(hash)); expect(visited).toContain(hashA); expect(visited).toContain(hashB); expect(visited).toContain(hashC); }); test("walk() silently skips a dangling root (no callback, no throw)", () => { const store = createMemoryStore(); const visitor = vi.fn(); expect(() => walk(store, "0000000000000" as Hash, visitor)).not.toThrow(); expect(visitor).not.toHaveBeenCalled(); }); test("walk() with onDangling reports a dangling root", () => { const store = createMemoryStore(); const visitor = vi.fn(); const spy = vi.fn(); walk(store, "0000000000000" as Hash, visitor, { onDangling: spy }); expect(visitor).not.toHaveBeenCalled(); expect(spy).toHaveBeenCalledTimes(1); expect(spy).toHaveBeenCalledWith("0000000000000"); }); test("walk() continues past a dangling middle node and visits remaining reachable subtree", () => { const store = createMemoryStore(); const leafSchema = putSchema(store, { type: "object", properties: { val: { type: "number" } }, }); const linkSchema = putSchema(store, { type: "object", properties: { next: { type: "string", format: "ocas_ref" } }, }); const diamondSchema = putSchema(store, { type: "object", properties: { leftHash: { type: "string", format: "ocas_ref" }, rightHash: { type: "string", format: "ocas_ref" }, }, }); // R has its own ref child Z const hashZ = store.cas.put(leafSchema, { val: 99 }); const hashR = store.cas.put(linkSchema, { next: hashZ }); // Left child will be inserted then deleted const leftHash = store.cas.put(leafSchema, { val: 7 }); const rootHash = store.cas.put(diamondSchema, { leftHash, rightHash: hashR, }); // Delete the left child to simulate dangling ref expect(store.cas.delete(leftHash)).toBe(true); expect(store.cas.has(leftHash)).toBe(false); // Without onDangling — silent skip const visited: string[] = []; expect(() => walk(store, rootHash, (h) => visited.push(h))).not.toThrow(); expect(visited).toContain(rootHash); expect(visited).toContain(hashR); expect(visited).toContain(hashZ); expect(visited).not.toContain(leftHash); // With onDangling — callback fires for the deleted left, full subtree still visited const visited2: string[] = []; const spy = vi.fn(); walk(store, rootHash, (h) => visited2.push(h), { onDangling: spy }); expect(visited2).toContain(rootHash); expect(visited2).toContain(hashR); expect(visited2).toContain(hashZ); expect(visited2).not.toContain(leftHash); expect(spy).toHaveBeenCalledTimes(1); expect(spy).toHaveBeenCalledWith(leftHash); }); test("walk() reports each unique dangling hash exactly once even when reachable from multiple paths", () => { const store = createMemoryStore(); const leafSchema = putSchema(store, { type: "object", properties: { val: { type: "number" } }, }); const linkSchema = putSchema(store, { type: "object", properties: { next: { type: "string", format: "ocas_ref" } }, }); const diamondSchema = putSchema(store, { type: "object", properties: { leftHash: { type: "string", format: "ocas_ref" }, rightHash: { type: "string", format: "ocas_ref" }, }, }); // Shared child that we will delete const sharedChild = store.cas.put(leafSchema, { val: 1 }); // Two parents that both reference the shared child const parentA = store.cas.put(linkSchema, { next: sharedChild }); const parentB = store.cas.put(linkSchema, { next: sharedChild }); const rootHash = store.cas.put(diamondSchema, { leftHash: parentA, rightHash: parentB, }); // Delete the shared child: now both parents reference a missing hash expect(store.cas.delete(sharedChild)).toBe(true); const spy = vi.fn(); walk(store, rootHash, () => {}, { onDangling: spy }); expect(spy).toHaveBeenCalledTimes(1); expect(spy).toHaveBeenCalledWith(sharedChild); }); test("walk() handles a cycle that contains a dangling ref without infinite loop", () => { const store = createMemoryStore(); const linkSchema = putSchema(store, { type: "object", properties: { next: { type: "string", format: "ocas_ref" }, val: { type: "number" }, }, }); // Build root → A, A → B, A → root (cycle), then delete B // Since CAS is content-addressed, build B first, then A pointing to B, // but for the cycle we use a dual-ref schema for A. const dualSchema = putSchema(store, { type: "object", properties: { a: { type: "string", format: "ocas_ref" }, b: { type: "string", format: "ocas_ref" }, val: { type: "number" }, }, }); const hashB = store.cas.put(linkSchema, { val: 2 }); // We don't actually have rootHash before A - so just build A → B and root → A. // For cycle: have A reference B AND a placeholder root. We can't make a true // cycle in CAS since hashes are content-derived. Instead, use a schema where // visited dedup naturally guards. const hashA = store.cas.put(dualSchema, { a: hashB, b: hashB, val: 1, }); // root references A and B both (so A is reachable from two places) const rootHash = store.cas.put(dualSchema, { a: hashA, b: hashB, val: 0, }); // Delete B to make it dangling expect(store.cas.delete(hashB)).toBe(true); const visited = new Set(); const spy = vi.fn(); expect(() => walk(store, rootHash, (h) => visited.add(h), { onDangling: spy }), ).not.toThrow(); // Termination: visited contains root + A expect(visited.has(rootHash)).toBe(true); expect(visited.has(hashA)).toBe(true); expect(visited.has(hashB)).toBe(false); // B reported exactly once even though reachable from two paths expect(spy).toHaveBeenCalledTimes(1); expect(spy).toHaveBeenCalledWith(hashB); }); test("walk() onDangling fires during traversal of the missing node", () => { const store = createMemoryStore(); const leafSchema = putSchema(store, { type: "object", properties: { val: { type: "number" } }, }); const linkSchema = putSchema(store, { type: "object", properties: { next: { type: "string", format: "ocas_ref" } }, }); const childHash = store.cas.put(leafSchema, { val: 1 }); const rootHash = store.cas.put(linkSchema, { next: childHash }); expect(store.cas.delete(childHash)).toBe(true); const log: string[] = []; walk(store, rootHash, (h) => log.push(`v:${h}`), { onDangling: (h) => log.push(`d:${h}`), }); // visitor called for root, then dangling fires for child expect(log).toContain(`v:${rootHash}`); expect(log).toContain(`d:${childHash}`); // visitor was NOT called for the deleted child expect(log).not.toContain(`v:${childHash}`); }); // ── Backward-compat / signature ────────────────────────────────────────── test("walk() three-argument call still type-checks and behaves identically", () => { const store = createMemoryStore(); const schemaHash = putSchema(store, { type: "object", properties: { val: { type: "number" } }, }); const nodeHash = store.cas.put(schemaHash, { val: 42 }); const visited: string[] = []; // No options object — original signature. walk(store, nodeHash, (hash) => visited.push(hash)); expect(visited).toContain(nodeHash); }); test("refs() two-argument call still type-checks and returns the same array as before", () => { const store = createMemoryStore(); const schemaHash = putSchema(store, { type: "object", properties: { ref: { type: "string", format: "ocas_ref" } }, }); const targetHash = "AAAAAAAAAAAAA" as Hash; const nodeHash = store.cas.put(schemaHash, { ref: targetHash }); const node = store.cas.get(nodeHash) as CasNode; expect(refs(store, node)).toEqual([targetHash]); }); // ── Exports ────────────────────────────────────────────────────────────── test("OnDangling type is exported and assignable", () => { // Compile-time check: no runtime behavior asserted here. const _cb: OnDangling = (_h) => {}; expect(typeof _cb).toBe("function"); }); }); // ────────────────────────────────────────────────────────────────────────────── // Suite: walk() followType option (#135) // ────────────────────────────────────────────────────────────────────────────── describe("walk() followType option (#135)", () => { test("followType: false — only payload refs are traversed, schemas excluded", () => { const store = createMemoryStore(); bootstrap(store); const simpleSchema = putSchema(store, { type: "object", properties: { val: { type: "number" } }, }); const refSchema = putSchema(store, { type: "object", properties: { ref: { type: "string", format: "ocas_ref" }, val: { type: "number" }, }, }); const leaf = store.cas.put(simpleSchema, { val: 7 }); const root = store.cas.put(refSchema, { ref: leaf, val: 1 }); const visited = new Set(); walk(store, root, (h) => visited.add(h), { followType: false }); expect(visited.has(root)).toBe(true); expect(visited.has(leaf)).toBe(true); expect(visited.has(refSchema)).toBe(false); expect(visited.has(simpleSchema)).toBe(false); }); test("followType: true — schema chain is traversed (explicit)", () => { const store = createMemoryStore(); const aliases = bootstrap(store); const metaHash = aliases["@ocas/schema"] as Hash; const simpleSchema = putSchema(store, { type: "object", properties: { val: { type: "number" } }, }); const refSchema = putSchema(store, { type: "object", properties: { ref: { type: "string", format: "ocas_ref" }, val: { type: "number" }, }, }); const leaf = store.cas.put(simpleSchema, { val: 7 }); const root = store.cas.put(refSchema, { ref: leaf, val: 1 }); const visited = new Set(); walk(store, root, (h) => visited.add(h), { followType: true }); expect(visited.has(root)).toBe(true); expect(visited.has(leaf)).toBe(true); expect(visited.has(refSchema)).toBe(true); expect(visited.has(simpleSchema)).toBe(true); expect(visited.has(metaHash)).toBe(true); }); test("followType omitted (default = true) — backward compatible", () => { const store = createMemoryStore(); const aliases = bootstrap(store); const metaHash = aliases["@ocas/schema"] as Hash; const schema = putSchema(store, { type: "object", properties: { val: { type: "number" } }, }); const node = store.cas.put(schema, { val: 42 }); const visited = new Set(); walk(store, node, (h) => visited.add(h)); expect(visited.has(node)).toBe(true); expect(visited.has(schema)).toBe(true); expect(visited.has(metaHash)).toBe(true); }); test("followType omitted with empty options — same as default true", () => { const store = createMemoryStore(); const aliases = bootstrap(store); const metaHash = aliases["@ocas/schema"] as Hash; const schema = putSchema(store, { type: "object", properties: { val: { type: "number" } }, }); const node = store.cas.put(schema, { val: 42 }); const visited = new Set(); walk(store, node, (h) => visited.add(h), {}); expect(visited.has(node)).toBe(true); expect(visited.has(schema)).toBe(true); expect(visited.has(metaHash)).toBe(true); }); test("followType: false still fully traverses payload ocas_ref edges", () => { const store = createMemoryStore(); bootstrap(store); const chainSchema = putSchema(store, { type: "object", properties: { next: { type: "string", format: "ocas_ref" }, val: { type: "number" }, }, }); const c = store.cas.put(chainSchema, { val: 3 }); const b = store.cas.put(chainSchema, { next: c, val: 2 }); const a = store.cas.put(chainSchema, { next: b, val: 1 }); const visited: Hash[] = []; walk(store, a, (h) => visited.push(h), { followType: false }); expect(visited).toContain(a); expect(visited).toContain(b); expect(visited).toContain(c); expect(visited).not.toContain(chainSchema); }); });