9965e75c22
Phase 4 of unified Store refactor (#38): - Add FsVarStore tests (12 cases) and FsTagStore tests (11 cases) covering CRUD, persistence-across-reopen, JSONL replay fidelity, ListOptions, and error paths. - Extract validateName into packages/core/src/validation.ts; remove duplicated copies in core/src/store.ts and fs/src/var-store.ts. - Fix FsTagStore.listByTag to honor ListOptions (limit/offset/desc) via applyListOptions, matching the in-memory implementation. - Replace stale openStoreAndVarStore example in usage.md with openStore returning OcasStore; add grep-based regression test. - Add OcasStore shape assertion in fs/src/store.test.ts. Closes #42; partially addresses #47 (items 1, 3).
258 lines
8.6 KiB
TypeScript
258 lines
8.6 KiB
TypeScript
import { describe, expect, test } from "bun:test";
|
|
import {
|
|
CasNodeNotFoundError,
|
|
InvalidVariableNameError,
|
|
MAX_HISTORY,
|
|
SchemaMismatchError,
|
|
TagLabelConflictError,
|
|
VariableNotFoundError,
|
|
} from "./errors.js";
|
|
import { createMemoryStore } from "./store.js";
|
|
import type { Hash } from "./types.js";
|
|
import { validateName } from "./validation.js";
|
|
|
|
function makeStoreWithSchema(): {
|
|
store: ReturnType<typeof createMemoryStore>;
|
|
schema: Hash;
|
|
meta: Hash;
|
|
put: (payload: unknown) => Hash;
|
|
} {
|
|
const store = createMemoryStore();
|
|
const meta = store.cas[Symbol.for("@ocas/core/bootstrap-store")]({
|
|
type: "object",
|
|
}) as Hash;
|
|
const schema = store.cas.put(meta, { type: "string" });
|
|
return {
|
|
store,
|
|
schema,
|
|
meta,
|
|
put: (payload) => store.cas.put(schema, payload),
|
|
};
|
|
}
|
|
|
|
describe("In-memory VarStore", () => {
|
|
test("C1. set + get round-trip", () => {
|
|
const { store, schema, put } = makeStoreWithSchema();
|
|
const h = put("hello");
|
|
const v = store.var.set("@app/x", h);
|
|
expect(v.name).toBe("@app/x");
|
|
expect(v.value).toBe(h);
|
|
expect(v.schema).toBe(schema);
|
|
expect(typeof v.created).toBe("number");
|
|
expect(typeof v.updated).toBe("number");
|
|
expect(v.tags).toEqual({});
|
|
expect(v.labels).toEqual([]);
|
|
|
|
const got = store.var.get("@app/x", schema);
|
|
expect(got).not.toBeNull();
|
|
expect(got?.value).toBe(h);
|
|
expect(got?.schema).toBe(schema);
|
|
});
|
|
|
|
test("C2. name validation", () => {
|
|
const { store, put } = makeStoreWithSchema();
|
|
const h = put("v");
|
|
expect(() => store.var.set("x", h)).toThrow(InvalidVariableNameError);
|
|
expect(() => store.var.set("@/x", h)).toThrow(InvalidVariableNameError);
|
|
expect(() => store.var.set("@app/", h)).toThrow(InvalidVariableNameError);
|
|
expect(() => store.var.set("@app//x", h)).toThrow(InvalidVariableNameError);
|
|
expect(() => store.var.set("@app/x.y_z-1", h)).not.toThrow();
|
|
});
|
|
|
|
test("C3. set throws CasNodeNotFoundError if hash not in cas", () => {
|
|
const { store } = makeStoreWithSchema();
|
|
expect(() => store.var.set("@app/x", "ZZZZZZZZZZZZZ")).toThrow(
|
|
CasNodeNotFoundError,
|
|
);
|
|
});
|
|
|
|
test("C4. idempotent same-value set", async () => {
|
|
const { store, schema, put } = makeStoreWithSchema();
|
|
const h = put("v");
|
|
const v1 = store.var.set("@app/x", h);
|
|
await new Promise((r) => setTimeout(r, 5));
|
|
const v2 = store.var.set("@app/x", h);
|
|
expect(v2.updated).toBe(v1.updated);
|
|
expect(store.var.history("@app/x", schema)).toHaveLength(1);
|
|
});
|
|
|
|
test("C5. update via re-set with new value bumps updated and history", async () => {
|
|
const { store, schema, put } = makeStoreWithSchema();
|
|
const h1 = put("v1");
|
|
const h2 = put("v2");
|
|
const v1 = store.var.set("@app/x", h1);
|
|
await new Promise((r) => setTimeout(r, 5));
|
|
const v2 = store.var.set("@app/x", h2);
|
|
expect(v2.updated).toBeGreaterThan(v1.updated);
|
|
const hist = store.var.history("@app/x", schema);
|
|
expect(hist.map((e) => e.value)).toEqual([h2, h1]);
|
|
expect(hist[0]?.position).toBe(0);
|
|
expect(hist[1]?.position).toBe(1);
|
|
});
|
|
|
|
test("C6. history bound to MAX_HISTORY (10)", () => {
|
|
const { store, schema, put } = makeStoreWithSchema();
|
|
const hashes: Hash[] = [];
|
|
for (let i = 0; i < 12; i++) {
|
|
const h = put(`v${i}`);
|
|
hashes.push(h);
|
|
store.var.set("@app/x", h);
|
|
}
|
|
const hist = store.var.history("@app/x", schema);
|
|
expect(hist).toHaveLength(MAX_HISTORY);
|
|
expect(hist[0]?.value).toBe(hashes[11] as string);
|
|
});
|
|
|
|
test("C7. history rotation when re-setting an older value", () => {
|
|
const { store, schema, put } = makeStoreWithSchema();
|
|
const h1 = put("v1");
|
|
const h2 = put("v2");
|
|
const h3 = put("v3");
|
|
store.var.set("@app/x", h1);
|
|
store.var.set("@app/x", h2);
|
|
store.var.set("@app/x", h3);
|
|
expect(store.var.history("@app/x", schema).map((e) => e.value)).toEqual([
|
|
h3,
|
|
h2,
|
|
h1,
|
|
]);
|
|
store.var.set("@app/x", h1);
|
|
expect(store.var.history("@app/x", schema).map((e) => e.value)).toEqual([
|
|
h1,
|
|
h3,
|
|
h2,
|
|
]);
|
|
});
|
|
|
|
test("C8. tags + labels round-trip", () => {
|
|
const { store, schema, put } = makeStoreWithSchema();
|
|
const h = put("v");
|
|
store.var.set("@app/x", h, {
|
|
tags: { env: "prod" },
|
|
labels: ["pinned"],
|
|
});
|
|
const got = store.var.get("@app/x", schema);
|
|
expect(got?.tags).toEqual({ env: "prod" });
|
|
expect(got?.labels).toEqual(["pinned"]);
|
|
});
|
|
|
|
test("C9. tag/label conflict throws TagLabelConflictError", () => {
|
|
const { store, put } = makeStoreWithSchema();
|
|
const h = put("v");
|
|
expect(() =>
|
|
store.var.set("@app/x", h, {
|
|
tags: { x: "y" },
|
|
labels: ["x"],
|
|
}),
|
|
).toThrow(TagLabelConflictError);
|
|
});
|
|
|
|
test("C10. update requires existing variable and matching schema", () => {
|
|
const { store, put } = makeStoreWithSchema();
|
|
const h = put("v");
|
|
expect(() => store.var.update("@app/x", h)).toThrow(VariableNotFoundError);
|
|
store.var.set("@app/x", h);
|
|
|
|
// Different schema for new value
|
|
const meta = store.cas[Symbol.for("@ocas/core/bootstrap-store")]({
|
|
type: "object",
|
|
}) as Hash;
|
|
const otherSchema = store.cas.put(meta, { type: "number" });
|
|
const h2 = store.cas.put(otherSchema, 42);
|
|
expect(() => store.var.update("@app/x", h2)).toThrow(SchemaMismatchError);
|
|
});
|
|
|
|
test("C11. remove with and without schema", () => {
|
|
const { store, schema, put } = makeStoreWithSchema();
|
|
const h = put("v");
|
|
store.var.set("@app/x", h);
|
|
const removed = store.var.remove("@app/x", schema);
|
|
expect(removed).toHaveLength(1);
|
|
expect(removed[0]?.value).toBe(h);
|
|
expect(store.var.get("@app/x", schema)).toBeNull();
|
|
|
|
// remove(name) without schema returns array; empty if none
|
|
expect(store.var.remove("@app/none")).toEqual([]);
|
|
|
|
store.var.set("@app/y", put("y1"));
|
|
const removedAll = store.var.remove("@app/y");
|
|
expect(Array.isArray(removedAll)).toBe(true);
|
|
expect(removedAll).toHaveLength(1);
|
|
});
|
|
|
|
test("C12. list filters and ListOptions", () => {
|
|
const { store, schema, put } = makeStoreWithSchema();
|
|
const h1 = put("v1");
|
|
const h2 = put("v2");
|
|
store.var.set("@app/a", h1, { tags: { env: "prod" } });
|
|
store.var.set("@app/b", h2, { labels: ["pinned"] });
|
|
|
|
expect(() =>
|
|
store.var.list({ namePrefix: "@app", exactName: "@app/a" }),
|
|
).toThrow();
|
|
|
|
const byPrefix = store.var.list({ namePrefix: "@app/" });
|
|
expect(byPrefix.map((v) => v.name).sort()).toEqual(["@app/a", "@app/b"]);
|
|
|
|
const exact = store.var.list({ exactName: "@app/a" });
|
|
expect(exact).toHaveLength(1);
|
|
|
|
const byTag = store.var.list({ tags: { env: "prod" } });
|
|
expect(byTag.map((v) => v.name)).toEqual(["@app/a"]);
|
|
|
|
const byLabel = store.var.list({ labels: ["pinned"] });
|
|
expect(byLabel.map((v) => v.name)).toEqual(["@app/b"]);
|
|
|
|
const bySchema = store.var.list({ schema });
|
|
expect(bySchema).toHaveLength(2);
|
|
|
|
const limited = store.var.list({ limit: 1 });
|
|
expect(limited).toHaveLength(1);
|
|
});
|
|
|
|
test("C13. close() is a no-op", () => {
|
|
const { store } = makeStoreWithSchema();
|
|
expect(() => store.var.close()).not.toThrow();
|
|
});
|
|
|
|
test("C14. cas.delete does NOT cascade-delete the variable", () => {
|
|
const { store, schema, put } = makeStoreWithSchema();
|
|
const h = put("v");
|
|
store.var.set("@app/x", h);
|
|
store.cas.delete(h);
|
|
const got = store.var.get("@app/x", schema);
|
|
expect(got).not.toBeNull();
|
|
expect(got?.value).toBe(h);
|
|
});
|
|
});
|
|
|
|
describe("validateName (shared)", () => {
|
|
test("C-VN1. accepts well-formed names", () => {
|
|
expect(() => validateName("@app/x")).not.toThrow();
|
|
expect(() => validateName("@app/a.b_c-1")).not.toThrow();
|
|
expect(() => validateName("@app/nested/path")).not.toThrow();
|
|
expect(() => validateName("@ocas/schema")).not.toThrow();
|
|
});
|
|
|
|
test("C-VN2. rejects empty / missing-@ / @ -only / trailing slash / double slash", () => {
|
|
expect(() => validateName("")).toThrow(InvalidVariableNameError);
|
|
expect(() => validateName("x")).toThrow(InvalidVariableNameError);
|
|
expect(() => validateName("@/x")).toThrow(InvalidVariableNameError);
|
|
expect(() => validateName("@app/")).toThrow(InvalidVariableNameError);
|
|
expect(() => validateName("@app//x")).toThrow(InvalidVariableNameError);
|
|
});
|
|
|
|
test("C-VN3. rejects invalid segment characters", () => {
|
|
expect(() => validateName("@app/foo bar")).toThrow(
|
|
InvalidVariableNameError,
|
|
);
|
|
expect(() => validateName("@app/foo!bar")).toThrow(
|
|
InvalidVariableNameError,
|
|
);
|
|
});
|
|
|
|
test("C-VN4. scope must start with a letter", () => {
|
|
expect(() => validateName("@1bad/x")).toThrow(InvalidVariableNameError);
|
|
});
|
|
});
|