From 9965e75c227df37278a54118f42dd502df4ae43a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E6=A9=98?= Date: Tue, 2 Jun 2026 09:27:44 +0000 Subject: [PATCH] feat(fs): add FsStore var/tag test coverage and share validateName 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). --- packages/cli/src/prompts/usage-doc.test.ts | 19 ++ packages/cli/src/prompts/usage.md | 5 +- packages/core/src/index.ts | 1 + packages/core/src/store.ts | 37 +--- packages/core/src/validation.ts | 49 ++++++ packages/core/src/var-store.test.ts | 31 ++++ packages/fs/src/store.test.ts | 30 ++++ packages/fs/src/tag-store.test.ts | 136 +++++++++++++++ packages/fs/src/var-store.test.ts | 194 +++++++++++++++++++++ packages/fs/src/var-store.ts | 43 +---- 10 files changed, 473 insertions(+), 72 deletions(-) create mode 100644 packages/cli/src/prompts/usage-doc.test.ts create mode 100644 packages/core/src/validation.ts create mode 100644 packages/fs/src/tag-store.test.ts create mode 100644 packages/fs/src/var-store.test.ts diff --git a/packages/cli/src/prompts/usage-doc.test.ts b/packages/cli/src/prompts/usage-doc.test.ts new file mode 100644 index 0000000..1c70ce1 --- /dev/null +++ b/packages/cli/src/prompts/usage-doc.test.ts @@ -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/); + }); +}); diff --git a/packages/cli/src/prompts/usage.md b/packages/cli/src/prompts/usage.md index eb24037..2d8573c 100644 --- a/packages/cli/src/prompts/usage.md +++ b/packages/cli/src/prompts/usage.md @@ -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 diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 88a7889..72c406c 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -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"; diff --git a/packages/core/src/store.ts b/packages/core/src/store.ts index aceb0af..bb8cad9 100644 --- a/packages/core/src/store.ts +++ b/packages/core/src/store.ts @@ -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(); const byType = new Map>(); diff --git a/packages/core/src/validation.ts b/packages/core/src/validation.ts new file mode 100644 index 0000000..5933f34 --- /dev/null +++ b/packages/core/src/validation.ts @@ -0,0 +1,49 @@ +import { InvalidVariableNameError } from "./errors.js"; + +/** + * Validate that a variable name follows the `@scope/name` format. + * + * Rules: + * - Must start with `@/` 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)`, + ); + } + } +} diff --git a/packages/core/src/var-store.test.ts b/packages/core/src/var-store.test.ts index 5a140b1..3eda251 100644 --- a/packages/core/src/var-store.test.ts +++ b/packages/core/src/var-store.test.ts @@ -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; @@ -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); + }); +}); diff --git a/packages/fs/src/store.test.ts b/packages/fs/src/store.test.ts index 7fd5e9b..7149a1b 100644 --- a/packages/fs/src/store.test.ts +++ b/packages/fs/src/store.test.ts @@ -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"); + }); +}); diff --git a/packages/fs/src/tag-store.test.ts b/packages/fs/src/tag-store.test.ts new file mode 100644 index 0000000..a546e51 --- /dev/null +++ b/packages/fs/src/tag-store.test.ts @@ -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"); + }); +}); diff --git a/packages/fs/src/var-store.test.ts b/packages/fs/src/var-store.test.ts new file mode 100644 index 0000000..7345598 --- /dev/null +++ b/packages/fs/src/var-store.test.ts @@ -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, + ); + }); +}); diff --git a/packages/fs/src/var-store.ts b/packages/fs/src/var-store.ts index a07d36d..187c5fa 100644 --- a/packages/fs/src/var-store.ts +++ b/packages/fs/src/var-store.ts @@ -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); }, }; } -- 2.43.0