Merge pull request 'fix(fs): add FsStore var/tag test coverage and share validateName' (#48) from fix/42-fsstore-implements-store into main

This commit was merged in pull request #48.
This commit is contained in:
2026-06-02 09:35:08 +00:00
10 changed files with 473 additions and 72 deletions
@@ -0,0 +1,19 @@
import { describe, expect, test } from "bun:test";
import { readFileSync } from "node:fs";
import { join } from "node:path";
const usagePath = join(import.meta.dir, "usage.md");
describe("usage.md doc cleanup (D)", () => {
test("D3. usage.md does not reference legacy openStoreAndVarStore / createVariableStore", () => {
const content = readFileSync(usagePath, "utf8");
expect(content).not.toContain("openStoreAndVarStore");
expect(content).not.toContain("createVariableStore");
});
test("D1. usage.md references openStore returning OcasStore", () => {
const content = readFileSync(usagePath, "utf8");
expect(content).toContain("openStore");
expect(content).toMatch(/store\.cas|store\.var|store\.tag/);
});
});
+3 -2
View File
@@ -184,8 +184,9 @@ const hash = await store.put(typeHash, { message: "hello" });
For filesystem persistence:
```typescript
import { openStoreAndVarStore } from "@ocas/fs";
const { store, varStore } = await openStoreAndVarStore("/path/to/store");
import { openStore } from "@ocas/fs";
const store = await openStore("/path/to/store");
// store.cas / store.var / store.tag
```
## Common Pitfalls
+1
View File
@@ -59,6 +59,7 @@ export type {
VarSetOptions,
VarStore,
} from "./types.js";
export { validateName } from "./validation.js";
export type { Variable } from "./variable.js";
export { verify } from "./verify.js";
export { wrapEnvelope } from "./wrap-envelope.js";
+1 -36
View File
@@ -4,7 +4,6 @@ import {
} from "./bootstrap-capable.js";
import {
CasNodeNotFoundError,
InvalidVariableNameError,
MAX_HISTORY,
SchemaMismatchError,
TagLabelConflictError,
@@ -27,6 +26,7 @@ import type {
VarSetOptions,
VarStore,
} from "./types.js";
import { validateName } from "./validation.js";
import type { Variable } from "./variable.js";
// Initialise the xxhash WASM instance once at module load. This allows the
@@ -44,41 +44,6 @@ export type MemoryCasStore = BootstrapCapableStore & {
delete(hash: Hash): boolean;
};
function validateName(name: string): void {
if (name === "") {
throw new InvalidVariableNameError(name, "Name cannot be empty");
}
const match = name.match(/^@([a-zA-Z][a-zA-Z0-9]*)\/(.+)$/);
if (!match) {
throw new InvalidVariableNameError(
name,
"Name must follow @scope/name format (e.g. @myapp/config)",
);
}
const rest = match[2] as string;
if (rest.endsWith("/")) {
throw new InvalidVariableNameError(
name,
"Name cannot end with trailing slash",
);
}
const segments = rest.split("/");
for (const segment of segments) {
if (segment === "") {
throw new InvalidVariableNameError(
name,
"Name contains empty segment (consecutive slashes //)",
);
}
if (!/^[a-zA-Z0-9._-]+$/.test(segment)) {
throw new InvalidVariableNameError(
name,
`Segment "${segment}" contains invalid characters (only a-z, A-Z, 0-9, ., _, - allowed)`,
);
}
}
}
function createCasStore(): MemoryCasStore {
const data = new Map<Hash, CasNode>();
const byType = new Map<Hash, Set<Hash>>();
+49
View File
@@ -0,0 +1,49 @@
import { InvalidVariableNameError } from "./errors.js";
/**
* Validate that a variable name follows the `@scope/name` format.
*
* Rules:
* - Must start with `@<scope>/` where scope is `[a-zA-Z][a-zA-Z0-9]*`
* - Must have at least one segment after the scope
* - Each segment may contain only `[a-zA-Z0-9._-]`
* - No empty segments (consecutive slashes) or trailing slash
*
* Note: this function does NOT enforce reservation of the `@ocas/*` scope —
* that is enforced at the CLI / bootstrap layer.
*
* @throws InvalidVariableNameError when name is malformed
*/
export function validateName(name: string): void {
if (name === "") {
throw new InvalidVariableNameError(name, "Name cannot be empty");
}
const match = name.match(/^@([a-zA-Z][a-zA-Z0-9]*)\/(.+)$/);
if (!match) {
throw new InvalidVariableNameError(
name,
"Name must follow @scope/name format (e.g. @myapp/config)",
);
}
const rest = match[2] as string;
if (rest.endsWith("/")) {
throw new InvalidVariableNameError(
name,
"Name cannot end with trailing slash",
);
}
for (const segment of rest.split("/")) {
if (segment === "") {
throw new InvalidVariableNameError(
name,
"Name contains empty segment (consecutive slashes //)",
);
}
if (!/^[a-zA-Z0-9._-]+$/.test(segment)) {
throw new InvalidVariableNameError(
name,
`Segment "${segment}" contains invalid characters (only a-z, A-Z, 0-9, ., _, - allowed)`,
);
}
}
}
+31
View File
@@ -9,6 +9,7 @@ import {
} 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>;
@@ -224,3 +225,33 @@ describe("In-memory VarStore", () => {
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);
});
});
+30
View File
@@ -558,3 +558,33 @@ describe("createFsStore – listMeta and listSchemas", () => {
}
});
});
// ──────────────────────────────────────────────────────────────────────────────
// E2. OcasStore shape from openStore
// ──────────────────────────────────────────────────────────────────────────────
describe("openStore – OcasStore shape", () => {
let dir: string;
beforeEach(() => {
dir = makeTmpDir();
});
afterEach(() => {
rmSync(dir, { recursive: true, force: true });
});
test("E2. returns object with cas, var, tag sub-stores", async () => {
const store = await openStore(dir);
expect(typeof store.cas).toBe("object");
expect(typeof store.var).toBe("object");
expect(typeof store.tag).toBe("object");
expect(typeof store.cas.put).toBe("function");
expect(typeof store.cas.get).toBe("function");
expect(typeof store.cas.has).toBe("function");
expect(typeof store.var.set).toBe("function");
expect(typeof store.var.get).toBe("function");
expect(typeof store.var.list).toBe("function");
expect(typeof store.var.history).toBe("function");
expect(typeof store.tag.tag).toBe("function");
expect(typeof store.tag.tags).toBe("function");
expect(typeof store.tag.listByTag).toBe("function");
});
});
+136
View File
@@ -0,0 +1,136 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { openStore } from "./store.js";
const T1 = "AAAAAAAAAAAAA";
const T2 = "BBBBBBBBBBBBB";
describe("FsTagStore", () => {
let dir: string;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), "ocas-fs-tag-"));
});
afterEach(() => {
rmSync(dir, { recursive: true, force: true });
});
test("B1. set tag with key/value round-trip + JSONL persisted", async () => {
const store = await openStore(dir);
const result = store.tag.tag(T1, [
{ op: "set", key: "env", value: "prod" },
]);
expect(result).toHaveLength(1);
expect(result[0]?.key).toBe("env");
expect(result[0]?.value).toBe("prod");
expect(store.tag.tags(T1)).toEqual(result);
const jsonl = join(dir, "_tags.jsonl");
expect(existsSync(jsonl)).toBe(true);
const content = readFileSync(jsonl, "utf8");
const lines = content.split("\n").filter((l) => l.length > 0);
expect(lines).toHaveLength(1);
const parsed = JSON.parse(lines[0] as string) as {
key: string;
value: string;
};
expect(parsed.key).toBe("env");
expect(parsed.value).toBe("prod");
});
test("B2. label tag (no value) records value: null", async () => {
const store = await openStore(dir);
store.tag.tag(T1, [{ op: "set", key: "pinned" }]);
const tags = store.tag.tags(T1);
expect(tags).toHaveLength(1);
expect(tags[0]?.value).toBeNull();
});
test("B3. multiple ops in one call sorted by key", async () => {
const store = await openStore(dir);
const result = store.tag.tag(T1, [
{ op: "set", key: "b", value: "2" },
{ op: "set", key: "a", value: "1" },
]);
expect(result.map((t) => t.key)).toEqual(["a", "b"]);
});
test("B4. update existing key overwrites value", async () => {
const store = await openStore(dir);
store.tag.tag(T1, [{ op: "set", key: "env", value: "prod" }]);
store.tag.tag(T1, [{ op: "set", key: "env", value: "dev" }]);
const tags = store.tag.tags(T1);
expect(tags).toHaveLength(1);
expect(tags[0]?.value).toBe("dev");
});
test("B5. delete via tag op removes the entry", async () => {
const store = await openStore(dir);
store.tag.tag(T1, [{ op: "set", key: "env", value: "prod" }]);
store.tag.tag(T1, [{ op: "delete", key: "env" }]);
expect(store.tag.tags(T1)).toEqual([]);
});
test("B6. untag removes listed keys; missing keys silently skipped", async () => {
const store = await openStore(dir);
store.tag.tag(T1, [
{ op: "set", key: "a", value: "1" },
{ op: "set", key: "b", value: "2" },
]);
store.tag.untag(T1, ["a", "missing"]);
expect(store.tag.tags(T1).map((t) => t.key)).toEqual(["b"]);
});
test("B7. listByTag bare key returns all tagged targets", async () => {
const store = await openStore(dir);
store.tag.tag(T1, [{ op: "set", key: "env", value: "prod" }]);
store.tag.tag(T2, [{ op: "set", key: "env", value: "dev" }]);
const listed = store.tag.listByTag("env").sort();
expect(listed).toEqual([T1, T2].sort());
});
test("B8. listByTag key=value filters by exact value", async () => {
const store = await openStore(dir);
store.tag.tag(T1, [{ op: "set", key: "env", value: "prod" }]);
store.tag.tag(T2, [{ op: "set", key: "env", value: "dev" }]);
expect(store.tag.listByTag("env=prod")).toEqual([T1]);
});
test("B9. ListOptions on listByTag (limit, offset)", async () => {
const store = await openStore(dir);
const targets: string[] = [];
for (let i = 0; i < 5; i++) {
const t = `${"C".repeat(12)}${i}`;
targets.push(t);
store.tag.tag(t, [{ op: "set", key: "k", value: String(i) }]);
}
expect(store.tag.listByTag("k", { limit: 2 })).toHaveLength(2);
});
test("B10. persistence across reopen", async () => {
const store = await openStore(dir);
store.tag.tag(T1, [
{ op: "set", key: "env", value: "prod" },
{ op: "set", key: "team", value: "platform" },
]);
const reopened = await openStore(dir);
const tags = reopened.tag.tags(T1);
expect(tags.map((t) => t.key)).toEqual(["env", "team"]);
expect(tags.map((t) => t.value)).toEqual(["prod", "platform"]);
});
test("B11. JSONL replay fidelity (set/delete/untag mix)", async () => {
const store = await openStore(dir);
store.tag.tag(T1, [{ op: "set", key: "a", value: "1" }]);
store.tag.tag(T1, [{ op: "set", key: "b", value: "2" }]);
store.tag.tag(T1, [{ op: "set", key: "c", value: "3" }]);
store.tag.tag(T1, [{ op: "delete", key: "b" }]);
store.tag.untag(T1, ["a"]);
const reopened = await openStore(dir);
const tags = reopened.tag.tags(T1);
expect(tags.map((t) => t.key)).toEqual(["c"]);
expect(tags[0]?.value).toBe("3");
});
});
+194
View File
@@ -0,0 +1,194 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { Hash, OcasStore } from "@ocas/core";
import {
CasNodeNotFoundError,
InvalidVariableNameError,
MAX_HISTORY,
SchemaMismatchError,
TagLabelConflictError,
VariableNotFoundError,
} from "@ocas/core";
import { openStore } from "./store.js";
const META_TYPE_KEY = Symbol.for("@ocas/core/bootstrap-store");
async function setupStore(dir: string): Promise<{
store: OcasStore;
schema: Hash;
put: (payload: unknown) => Hash;
}> {
const store = await openStore(dir);
// biome-ignore lint/suspicious/noExplicitAny: bootstrap symbol access
const meta = (store.cas as any)[META_TYPE_KEY]({ type: "object" }) as Hash;
const schema = store.cas.put(meta, { type: "string" });
return {
store,
schema,
put: (payload) => store.cas.put(schema, payload),
};
}
describe("FsVarStore", () => {
let dir: string;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), "ocas-fs-var-"));
});
afterEach(() => {
rmSync(dir, { recursive: true, force: true });
});
test("A1. set + get round-trip persists to JSONL", async () => {
const { store, schema, put } = await setupStore(dir);
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);
const got = store.var.get("@app/x", schema);
expect(got?.value).toBe(h);
const jsonl = join(dir, "_vars.jsonl");
expect(existsSync(jsonl)).toBe(true);
const content = readFileSync(jsonl, "utf8");
expect(content.length).toBeGreaterThan(0);
const lines = content.split("\n").filter((l) => l.length > 0);
expect(lines.length).toBeGreaterThanOrEqual(1);
const matching = lines
.map((l) => JSON.parse(l) as { name?: string; value?: Hash })
.find((r) => r.name === "@app/x");
expect(matching).toBeDefined();
expect(matching?.value).toBe(h);
});
test("A2. name validation", async () => {
const { store, put } = await setupStore(dir);
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("A3. set throws CasNodeNotFoundError if hash absent", async () => {
const { store } = await setupStore(dir);
expect(() => store.var.set("@app/x", "ZZZZZZZZZZZZZ")).toThrow(
CasNodeNotFoundError,
);
});
test("A4. idempotent same-value set", async () => {
const { store, schema, put } = await setupStore(dir);
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("A5. update via re-set bumps updated and appends history", async () => {
const { store, schema, put } = await setupStore(dir);
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]);
});
test("A6. SchemaMismatchError on update with different schema", async () => {
const { store, put } = await setupStore(dir);
const h = put("v");
store.var.set("@app/x", h);
// biome-ignore lint/suspicious/noExplicitAny: bootstrap symbol access
const meta = (store.cas as any)[META_TYPE_KEY]({ 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("A7. remove clears get and list", async () => {
const { store, schema, put } = await setupStore(dir);
const h = put("v");
store.var.set("@app/x", h);
const removed = store.var.remove("@app/x", schema);
expect(removed).toHaveLength(1);
expect(store.var.get("@app/x", schema)).toBeNull();
const listed = store.var.list({ exactName: "@app/x" });
expect(listed).toHaveLength(0);
});
test("A8. list with ListOptions: sort/limit/offset/desc", async () => {
const { store, put } = await setupStore(dir);
const h1 = put("v1");
const h2 = put("v2");
store.var.set("@user/a", h1);
await new Promise((r) => setTimeout(r, 5));
store.var.set("@user/b", h2);
const limited = store.var.list({ namePrefix: "@user/", limit: 1 });
expect(limited).toHaveLength(1);
const offset = store.var.list({ namePrefix: "@user/", offset: 1 });
expect(offset.map((v) => v.name)).toContain("@user/b");
const desc = store.var.list({ namePrefix: "@user/", desc: true });
expect(desc[0]?.name).toBe("@user/b");
});
test("A9. persistence across reopen", async () => {
const { store, put } = await setupStore(dir);
const h = put("v-persist");
store.var.set("@app/p", h);
store.var.close();
const reopened = await openStore(dir);
const got = reopened.var.list({ exactName: "@app/p" });
expect(got).toHaveLength(1);
expect(got[0]?.value).toBe(h);
});
test("A10. MAX_HISTORY truncation", async () => {
const { store, schema, put } = await setupStore(dir);
for (let i = 0; i < MAX_HISTORY + 3; i++) {
const h = put(`v${i}`);
store.var.set("@app/x", h);
}
const hist = store.var.history("@app/x", schema);
expect(hist).toHaveLength(MAX_HISTORY);
});
test("A11. labels round-trip", async () => {
const { store, schema, put } = await setupStore(dir);
const h = put("v");
store.var.set("@app/x", h, { labels: ["pinned"] });
const got = store.var.get("@app/x", schema);
expect(got?.labels).toEqual(["pinned"]);
});
test("A12. TagLabelConflictError when labels and tags overlap", async () => {
const { store, put } = await setupStore(dir);
const h = put("v");
expect(() =>
store.var.set("@app/x", h, {
tags: { env: "prod" },
labels: ["env"],
}),
).toThrow(TagLabelConflictError);
});
test("A13. update on missing variable throws VariableNotFoundError", async () => {
const { store, put } = await setupStore(dir);
const h = put("v");
expect(() => store.var.update("@app/missing", h)).toThrow(
VariableNotFoundError,
);
});
});
+9 -34
View File
@@ -9,6 +9,7 @@ import type {
CasStore,
Hash,
HistoryEntry,
ListEntry,
Tag,
TagStore,
Variable,
@@ -16,46 +17,19 @@ import type {
VarStore,
} from "@ocas/core";
import {
applyListOptions,
CasNodeNotFoundError,
InvalidVariableNameError,
casListEntry,
MAX_HISTORY,
SchemaMismatchError,
TagLabelConflictError,
VariableNotFoundError,
validateName,
} from "@ocas/core";
const VARS_FILE = "_vars.jsonl";
const TAGS_FILE = "_tags.jsonl";
function validateName(name: string): void {
if (name === "")
throw new InvalidVariableNameError(name, "Name cannot be empty");
const match = name.match(/^@([a-zA-Z][a-zA-Z0-9]*)\/(.+)$/);
if (!match)
throw new InvalidVariableNameError(
name,
"Name must follow @scope/name format (e.g. @myapp/config)",
);
const rest = match[2] as string;
if (rest.endsWith("/"))
throw new InvalidVariableNameError(
name,
"Name cannot end with trailing slash",
);
for (const segment of rest.split("/")) {
if (segment === "")
throw new InvalidVariableNameError(
name,
"Name contains empty segment (consecutive slashes //)",
);
if (!/^[a-zA-Z0-9._-]+$/.test(segment))
throw new InvalidVariableNameError(
name,
`Segment "${segment}" contains invalid characters (only a-z, A-Z, 0-9, ., _, - allowed)`,
);
}
}
type VarRecord = {
name: string;
schema: Hash;
@@ -481,7 +455,7 @@ export function createFsTagStore(dir: string): TagStore {
a.key < b.key ? -1 : a.key > b.key ? 1 : 0,
);
},
listByTag(tag, _options) {
listByTag(tag, options) {
let key = tag;
let value: string | null | undefined;
const eqIdx = tag.indexOf("=");
@@ -491,16 +465,17 @@ export function createFsTagStore(dir: string): TagStore {
}
const targets = byKey.get(key);
if (!targets) return [];
const result: Hash[] = [];
let entries: ListEntry[] = [];
for (const t of targets) {
const tm = byTarget.get(t);
if (!tm) continue;
const tagEntry = tm.get(key);
if (!tagEntry) continue;
if (value !== undefined && tagEntry.value !== value) continue;
result.push(t);
entries.push(casListEntry(t, tagEntry.created));
}
return result;
entries = applyListOptions(entries, options);
return entries.map((e) => e.hash);
},
};
}