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:
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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>>();
|
||||
|
||||
@@ -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)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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,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);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user