From 92a024fc1c1d179c555a602df6cd6d4620a749bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E6=A9=98?= Date: Mon, 1 Jun 2026 14:41:25 +0000 Subject: [PATCH 1/3] feat: add sorting, pagination, and timestamps to list commands Add --sort, --limit, --offset, --desc flags to `list --type`, `list-meta`, `list-schema`, and `var list`. Change Store.listByType to return {hash, created, updated}[] and extend VariableStore.list with the same sort/pagination params. Fixes #27 --- packages/cli/src/index.ts | 86 +++++- .../__snapshots__/edge-cases.test.ts.snap | 6 +- packages/cli/tests/list-meta-schema.test.ts | 28 +- packages/cli/tests/list-pagination.test.ts | 249 ++++++++++++++++++ packages/cli/tests/pipe.test.ts | 8 +- packages/cli/tests/put-get-has.test.ts | 3 +- packages/core/src/bootstrap.test.ts | 4 +- packages/core/src/bootstrap.ts | 30 ++- packages/core/src/gc.ts | 5 +- packages/core/src/index.test.ts | 8 +- packages/core/src/index.ts | 11 +- packages/core/src/list-pagination.test.ts | 191 ++++++++++++++ packages/core/src/list-utils.ts | 43 +++ packages/core/src/mem-store.ts | 14 +- packages/core/src/store.test.ts | 28 +- packages/core/src/store.ts | 25 +- packages/core/src/types.ts | 40 ++- .../core/src/variable-list-pagination.test.ts | 108 ++++++++ packages/core/src/variable-store.ts | 25 +- packages/fs/src/store.test.ts | 24 +- packages/fs/src/store.ts | 30 ++- 21 files changed, 874 insertions(+), 92 deletions(-) create mode 100644 packages/cli/tests/list-pagination.test.ts create mode 100644 packages/core/src/list-pagination.test.ts create mode 100644 packages/core/src/list-utils.ts create mode 100644 packages/core/src/variable-list-pagination.test.ts diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 775a8df..6d49d6a 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -3,7 +3,7 @@ import { existsSync, readFileSync } from "node:fs"; import { homedir } from "node:os"; import { join, resolve } from "node:path"; -import type { Hash, Store, VariableStore } from "@ocas/core"; +import type { Hash, ListOptions, Store, VariableStore } from "@ocas/core"; import { bootstrap, CasNodeNotFoundError, @@ -42,6 +42,9 @@ const VALUE_FLAGS = new Set([ "epsilon", "inline", "type", + "sort", + "limit", + "offset", ]); function parseArgs(argv: string[]): { flags: Flags; positional: string[] } { @@ -230,6 +233,60 @@ function parseTagsLabels(args: string[]): { return { tags, labels, deleteNames }; } +/** + * Parse --sort/--limit/--offset/--desc into a ListOptions object. + * Validates each flag and dies with a clear error on invalid values. + */ +function parseListOptions(): ListOptions { + const opts: ListOptions = {}; + const sortFlag = flags.sort; + if (sortFlag !== undefined) { + if (typeof sortFlag !== "string") { + die("Error: --sort requires a value (created or updated)"); + } + if (sortFlag !== "created" && sortFlag !== "updated") { + die(`Error: --sort must be 'created' or 'updated' (got '${sortFlag}')`); + } + opts.sort = sortFlag; + } + const limitFlag = flags.limit; + if (limitFlag !== undefined) { + if (typeof limitFlag !== "string") { + die("Error: --limit requires a numeric value"); + } + const parsed = Number.parseInt(limitFlag, 10); + if ( + !Number.isFinite(parsed) || + parsed < 0 || + String(parsed) !== limitFlag + ) { + die(`Error: --limit must be a non-negative integer (got '${limitFlag}')`); + } + opts.limit = parsed; + } + const offsetFlag = flags.offset; + if (offsetFlag !== undefined) { + if (typeof offsetFlag !== "string") { + die("Error: --offset requires a numeric value"); + } + const parsed = Number.parseInt(offsetFlag, 10); + if ( + !Number.isFinite(parsed) || + parsed < 0 || + String(parsed) !== offsetFlag + ) { + die( + `Error: --offset must be a non-negative integer (got '${offsetFlag}')`, + ); + } + opts.offset = parsed; + } + if (flags.desc === true) { + opts.desc = true; + } + return opts; +} + // ---- Commands ---- async function cmdPut(args: string[]): Promise { @@ -769,6 +826,7 @@ async function cmdVarList(args: string[]): Promise { const namePrefix = args[0] ?? ""; const schemaInput = flags.schema as string | undefined; const tagFlags = flags.tag; + const listOpts = parseListOptions(); const { store, varStore } = await openStoreAndVarStore(); @@ -792,9 +850,10 @@ async function cmdVarList(args: string[]): Promise { const variables = varStore.list({ namePrefix, - schema, - tags: Object.keys(tags).length > 0 ? tags : undefined, - labels: labels.length > 0 ? labels : undefined, + ...(schema !== undefined ? { schema } : {}), + ...(Object.keys(tags).length > 0 ? { tags } : {}), + ...(labels.length > 0 ? { labels } : {}), + ...listOpts, }); await out( await wrapEnvelope(store, "@ocas/output/var-list", variables), @@ -929,6 +988,7 @@ async function cmdTemplateList(_args: string[]): Promise { const variables = varStore.list({ namePrefix: "@ocas/template/text/", schema: stringHash, + limit: Number.MAX_SAFE_INTEGER, }); const templates = variables.map((v) => ({ @@ -992,27 +1052,33 @@ async function cmdList(_args: string[]): Promise { const typeFlag = flags.type; if (typeof typeFlag !== "string") die("Usage: ocas list --type "); + const opts = parseListOptions(); const { store, varStore } = await openStoreAndVarStore(); try { const typeHash = resolveHash(typeFlag, varStore); - const hashes = Array.from(store.listByType(typeHash)); - await out(await wrapEnvelope(store, "@ocas/output/list", hashes), store); + const entries = store.listByType(typeHash, opts); + await out(await wrapEnvelope(store, "@ocas/output/list", entries), store); } finally { varStore.close(); } } async function cmdListMeta(_args: string[]): Promise { + const opts = parseListOptions(); const store = await openStore(); - const hashes = store.listMeta(); - await out(await wrapEnvelope(store, "@ocas/output/list-meta", hashes), store); + const entries = store.listMeta(opts); + await out( + await wrapEnvelope(store, "@ocas/output/list-meta", entries), + store, + ); } async function cmdListSchema(_args: string[]): Promise { + const opts = parseListOptions(); const store = await openStore(); - const hashes = store.listSchemas(); + const entries = store.listSchemas(opts); await out( - await wrapEnvelope(store, "@ocas/output/list-schema", hashes), + await wrapEnvelope(store, "@ocas/output/list-schema", entries), store, ); } diff --git a/packages/cli/tests/__snapshots__/edge-cases.test.ts.snap b/packages/cli/tests/__snapshots__/edge-cases.test.ts.snap index df0ee99..809db84 100644 --- a/packages/cli/tests/__snapshots__/edge-cases.test.ts.snap +++ b/packages/cli/tests/__snapshots__/edge-cases.test.ts.snap @@ -199,21 +199,21 @@ exports[`Phase 3: Variable System 3.3 var list shows all variables 1`] = ` "name": "@ocas/output/list", "schema": "CTS5P6RD8HMCS", "tags": {}, - "value": "CTCEXSNPWMAQQ", + "value": "7BWZ3JKKMSH4N", }, { "labels": [], "name": "@ocas/output/list-meta", "schema": "CTS5P6RD8HMCS", "tags": {}, - "value": "0V41JBWK72HS3", + "value": "1WQ7C0EV8QGA4", }, { "labels": [], "name": "@ocas/output/list-schema", "schema": "CTS5P6RD8HMCS", "tags": {}, - "value": "AW24Q8BKXQYTE", + "value": "7FYGS2KQ3REM9", }, { "labels": [], diff --git a/packages/cli/tests/list-meta-schema.test.ts b/packages/cli/tests/list-meta-schema.test.ts index e36b9a6..7f3c353 100644 --- a/packages/cli/tests/list-meta-schema.test.ts +++ b/packages/cli/tests/list-meta-schema.test.ts @@ -33,18 +33,22 @@ describe("list-meta CLI command", () => { ["list", "--type", "@ocas/schema"], storePath, ); - const schemaList = envValue(schemaListOut) as string[]; + const schemaList = envValue(schemaListOut) as Array<{ hash: string }>; expect(Array.isArray(schemaList)).toBe(true); const { stdout, stderr, exitCode } = await runCli(["list-meta"], storePath); expect(exitCode).toBe(0); expect(stderr).toBe(""); - const parsed = JSON.parse(stdout) as { type: string; value: string[] }; + const parsed = JSON.parse(stdout) as { + type: string; + value: Array<{ hash: string; created: number; updated: number }>; + }; expect(parsed.type).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/); expect(Array.isArray(parsed.value)).toBe(true); expect(parsed.value).toHaveLength(1); - expect(parsed.value[0]).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/); + const first = parsed.value[0] as { hash: string }; + expect(first.hash).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/); }); test("E1. --json flag yields compact JSON", async () => { @@ -136,10 +140,12 @@ describe("E4. list-schema vs list --type with multiple meta-schema versions", () storePath, ); expect(lsCode).toBe(0); - const lsValue = envValue(lsOut) as string[]; - expect(lsValue).toContain(sM1); - expect(lsValue).toContain(m1); - expect(lsValue).toContain(m2); + const lsHashes = (envValue(lsOut) as Array<{ hash: string }>).map( + (e) => e.hash, + ); + expect(lsHashes).toContain(sM1); + expect(lsHashes).toContain(m1); + expect(lsHashes).toContain(m2); // CLI: list --type must NOT include sM1 const { stdout: ltOut, exitCode: ltCode } = await runCli( @@ -147,10 +153,12 @@ describe("E4. list-schema vs list --type with multiple meta-schema versions", () storePath, ); expect(ltCode).toBe(0); - const ltValue = envValue(ltOut) as string[]; - expect(ltValue).not.toContain(sM1); + const ltHashes = (envValue(ltOut) as Array<{ hash: string }>).map( + (e) => e.hash, + ); + expect(ltHashes).not.toContain(sM1); // list-schema.value.length > list --type .value.length - expect(lsValue.length).toBeGreaterThan(ltValue.length); + expect(lsHashes.length).toBeGreaterThan(ltHashes.length); }); }); diff --git a/packages/cli/tests/list-pagination.test.ts b/packages/cli/tests/list-pagination.test.ts new file mode 100644 index 0000000..a6e983d --- /dev/null +++ b/packages/cli/tests/list-pagination.test.ts @@ -0,0 +1,249 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { envValue, runCli } from "./helpers.js"; + +const HASH_RE = /^[0-9A-HJKMNP-TV-Z]{13}$/; + +let storePath: string; + +beforeEach(() => { + storePath = mkdtempSync(join(tmpdir(), "ocas-list-pagination-")); + mkdirSync(storePath, { recursive: true }); +}); + +afterEach(() => { + rmSync(storePath, { recursive: true, force: true }); +}); + +async function putString(text: string): Promise { + // Use the @ocas/string built-in via library; CLI doesn't expose put-text but + // `put @ocas/string --pipe` works. We'll use --pipe with stdin. + const proc = Bun.spawn( + [ + "bun", + join(import.meta.dir, "../src/index.ts"), + "--home", + storePath, + "put", + "@ocas/string", + "--pipe", + ], + { stdout: "pipe", stderr: "pipe", stdin: "pipe" }, + ); + proc.stdin.write(JSON.stringify(text)); + proc.stdin.end(); + await proc.exited; + const out = await new Response(proc.stdout).text(); + return (JSON.parse(out) as { value: string }).value; +} + +async function makeN(n: number, gap = 2): Promise { + const hashes: string[] = []; + for (let i = 0; i < n; i++) { + hashes.push(await putString(`val-${i}-${Math.random()}`)); + if (gap > 0 && i < n - 1) { + await new Promise((r) => setTimeout(r, gap)); + } + } + return hashes; +} + +describe("CLI list --type pagination", () => { + test("E1. entries are {hash, created, updated} objects", async () => { + await makeN(3); + const { stdout, exitCode } = await runCli( + ["list", "--type", "@ocas/string"], + storePath, + ); + expect(exitCode).toBe(0); + const value = envValue(stdout) as Array<{ + hash: string; + created: number; + updated: number; + }>; + expect(value.length).toBeGreaterThan(0); + for (const e of value) { + expect(e.hash).toMatch(HASH_RE); + expect(typeof e.created).toBe("number"); + expect(typeof e.updated).toBe("number"); + } + }); + + test("E2. --limit 2", async () => { + await makeN(5, 0); + const { stdout, exitCode } = await runCli( + ["list", "--type", "@ocas/string", "--limit", "2"], + storePath, + ); + expect(exitCode).toBe(0); + const value = envValue(stdout) as unknown[]; + expect(value).toHaveLength(2); + }); + + test("E3. --offset 1 skips first", async () => { + await makeN(3); + const { stdout: a } = await runCli( + ["list", "--type", "@ocas/string"], + storePath, + ); + const { stdout: b } = await runCli( + ["list", "--type", "@ocas/string", "--offset", "1", "--limit", "100"], + storePath, + ); + const all = envValue(a) as Array<{ hash: string }>; + const skip = envValue(b) as Array<{ hash: string }>; + expect(skip).toHaveLength(all.length - 1); + expect((skip[0] as { hash: string }).hash).toBe( + (all[1] as { hash: string }).hash, + ); + }); + + test("E4. --desc reverses default order", async () => { + await makeN(3); + const { stdout, exitCode } = await runCli( + ["list", "--type", "@ocas/string", "--desc"], + storePath, + ); + expect(exitCode).toBe(0); + const value = envValue(stdout) as Array<{ created: number }>; + for (let i = 1; i < value.length; i++) { + expect((value[i] as { created: number }).created).toBeLessThanOrEqual( + (value[i - 1] as { created: number }).created, + ); + } + }); + + test("E5. --sort updated accepted; equals created for CAS", async () => { + await makeN(3); + const { stdout: a } = await runCli( + ["list", "--type", "@ocas/string", "--sort", "created"], + storePath, + ); + const { stdout: b } = await runCli( + ["list", "--type", "@ocas/string", "--sort", "updated"], + storePath, + ); + expect(envValue(a)).toEqual(envValue(b)); + }); + + test("E7. invalid --sort exits non-zero", async () => { + const { exitCode, stderr } = await runCli( + ["list", "--type", "@ocas/string", "--sort", "foo"], + storePath, + ); + expect(exitCode).not.toBe(0); + expect(stderr).toContain("--sort"); + }); + + test("E8. invalid --limit exits non-zero", async () => { + const r1 = await runCli( + ["list", "--type", "@ocas/string", "--limit", "-1"], + storePath, + ); + expect(r1.exitCode).not.toBe(0); + expect(r1.stderr).toContain("--limit"); + + const r2 = await runCli( + ["list", "--type", "@ocas/string", "--limit", "abc"], + storePath, + ); + expect(r2.exitCode).not.toBe(0); + expect(r2.stderr).toContain("--limit"); + }); +}); + +describe("CLI list-meta / list-schema pagination", () => { + test("F1. list-meta entries are objects", async () => { + // Bootstrap implicitly happens when running any cli command that opens store + await runCli(["list-meta"], storePath); + const { stdout } = await runCli(["list-meta"], storePath); + const value = envValue(stdout) as Array<{ + hash: string; + created: number; + updated: number; + }>; + expect(value.length).toBeGreaterThanOrEqual(1); + for (const e of value) { + expect(e.hash).toMatch(HASH_RE); + expect(typeof e.created).toBe("number"); + } + }); + + test("F2. list-schema --limit honored", async () => { + const { stdout } = await runCli(["list-schema", "--limit", "3"], storePath); + const value = envValue(stdout) as unknown[]; + expect(value).toHaveLength(3); + }); + + test("F3. list-schema --desc reverses order", async () => { + const { stdout: asc } = await runCli(["list-schema"], storePath); + const { stdout: desc } = await runCli(["list-schema", "--desc"], storePath); + const a = envValue(asc) as Array<{ hash: string }>; + const d = envValue(desc) as Array<{ hash: string }>; + expect(d[0]?.hash).toBe(a[a.length - 1]?.hash); + }); +}); + +describe("CLI var list pagination", () => { + test("G1./G2. --limit and --offset on var list", async () => { + for (let i = 0; i < 4; i++) { + const h = await putString(`tval-${i}`); + await runCli(["var", "set", `myvar-${i}`, h], storePath); + await new Promise((r) => setTimeout(r, 2)); + } + const { stdout: lim } = await runCli( + ["var", "list", "myvar-", "--limit", "2"], + storePath, + ); + expect((envValue(lim) as unknown[]).length).toBe(2); + + const { stdout: off } = await runCli( + ["var", "list", "myvar-", "--offset", "1", "--limit", "10"], + storePath, + ); + const offList = envValue(off) as Array<{ name: string }>; + expect(offList.length).toBe(3); + expect(offList[0]?.name).toBe("myvar-1"); + }); + + test("G3. --desc reverses var list order", async () => { + for (let i = 0; i < 3; i++) { + const h = await putString(`dval-${i}`); + await runCli(["var", "set", `dv-${i}`, h], storePath); + await new Promise((r) => setTimeout(r, 2)); + } + const { stdout } = await runCli( + ["var", "list", "dv-", "--desc"], + storePath, + ); + const list = envValue(stdout) as Array<{ name: string }>; + expect(list[0]?.name).toBe("dv-2"); + }); + + test("G4. --sort updated", async () => { + for (let i = 0; i < 3; i++) { + const h = await putString(`sval-${i}`); + await runCli(["var", "set", `sv-${i}`, h], storePath); + await new Promise((r) => setTimeout(r, 2)); + } + // Re-set sv-0 with NEW value to bump updated + await new Promise((r) => setTimeout(r, 2)); + const newH = await putString("sval-0-new"); + await runCli(["var", "set", "sv-0", newH], storePath); + + const { stdout } = await runCli( + ["var", "list", "sv-", "--sort", "updated"], + storePath, + ); + const list = envValue(stdout) as Array<{ name: string }>; + expect(list[list.length - 1]?.name).toBe("sv-0"); + }); + + test("G6. invalid --sort exits non-zero", async () => { + const r = await runCli(["var", "list", "--sort", "bogus"], storePath); + expect(r.exitCode).not.toBe(0); + expect(r.stderr).toContain("--sort"); + }); +}); diff --git a/packages/cli/tests/pipe.test.ts b/packages/cli/tests/pipe.test.ts index cec546a..5cd065e 100644 --- a/packages/cli/tests/pipe.test.ts +++ b/packages/cli/tests/pipe.test.ts @@ -109,11 +109,11 @@ describe("Phase 8: Pipe Composition", () => { ]); expect(exitCode).toBe(0); - // Downstream consumers (jq, etc.) read the `value` array of hashes. - const value = envValue(stdout) as string[]; + // Downstream consumers (jq, etc.) read the `value` array of {hash,...}. + const value = envValue(stdout) as Array<{ hash: string }>; expect(Array.isArray(value)).toBe(true); - for (const hash of value) { - expect(hash).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/); + for (const entry of value) { + expect(entry.hash).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/); } }); diff --git a/packages/cli/tests/put-get-has.test.ts b/packages/cli/tests/put-get-has.test.ts index 04a4565..7e261a3 100644 --- a/packages/cli/tests/put-get-has.test.ts +++ b/packages/cli/tests/put-get-has.test.ts @@ -96,6 +96,7 @@ describe("Phase 1: CAS Core", () => { test("1.13 list --type returns nodes of that type", async () => { const { stdout, exitCode } = await runCli(["list", "--type", typeHash]); expect(exitCode).toBe(0); - expect(envValue(stdout)).toContain(nodeHash); + const value = envValue(stdout) as Array<{ hash: string }>; + expect(value.map((e) => e.hash)).toContain(nodeHash); }); }); diff --git a/packages/core/src/bootstrap.test.ts b/packages/core/src/bootstrap.test.ts index 648c9c1..9bb6675 100644 --- a/packages/core/src/bootstrap.test.ts +++ b/packages/core/src/bootstrap.test.ts @@ -328,13 +328,13 @@ describe("bootstrap - meta and schemas indexes (D1)", () => { const store = createMemoryStore(); const aliases = await bootstrap(store); const metaHash = aliases["@ocas/schema"]; - expect(store.listMeta()).toContain(metaHash as string); + expect(store.listMeta().map((e) => e.hash)).toContain(metaHash as string); }); test("listSchemas contains meta-schema and all built-in schemas", async () => { const store = createMemoryStore(); const aliases = await bootstrap(store); - const schemas = store.listSchemas(); + const schemas = store.listSchemas().map((e) => e.hash); for (const [, hash] of Object.entries(aliases)) { expect(schemas).toContain(hash); diff --git a/packages/core/src/bootstrap.ts b/packages/core/src/bootstrap.ts index eead489..2f82110 100644 --- a/packages/core/src/bootstrap.ts +++ b/packages/core/src/bootstrap.ts @@ -169,7 +169,15 @@ const OUTPUT_SCHEMAS: ReadonlyArray< "@ocas/output/list", { type: "array", - items: { type: "string", format: "ocas_ref" }, + items: { + type: "object", + properties: { + hash: { type: "string", format: "ocas_ref" }, + created: { type: "number" }, + updated: { type: "number" }, + }, + required: ["hash", "created", "updated"], + }, title: "ocas list result", }, ], @@ -177,7 +185,15 @@ const OUTPUT_SCHEMAS: ReadonlyArray< "@ocas/output/list-meta", { type: "array", - items: { type: "string", format: "ocas_ref" }, + items: { + type: "object", + properties: { + hash: { type: "string", format: "ocas_ref" }, + created: { type: "number" }, + updated: { type: "number" }, + }, + required: ["hash", "created", "updated"], + }, title: "ocas list-meta result", }, ], @@ -185,7 +201,15 @@ const OUTPUT_SCHEMAS: ReadonlyArray< "@ocas/output/list-schema", { type: "array", - items: { type: "string", format: "ocas_ref" }, + items: { + type: "object", + properties: { + hash: { type: "string", format: "ocas_ref" }, + created: { type: "number" }, + updated: { type: "number" }, + }, + required: ["hash", "created", "updated"], + }, title: "ocas list-schema result", }, ], diff --git a/packages/core/src/gc.ts b/packages/core/src/gc.ts index d8e38ec..06a29c1 100644 --- a/packages/core/src/gc.ts +++ b/packages/core/src/gc.ts @@ -17,8 +17,9 @@ export interface GcStats { * - Schema preservation: schemas of reachable nodes are also marked */ export function gc(store: Store, varStore: VariableStore): GcStats { - // Get all variables (no filters → global) - const variables = varStore.list(); + // Get all variables (no filters → global). Pass a very large limit so the + // API-level default of 100 does not silently truncate gc roots. + const variables = varStore.list({ limit: Number.MAX_SAFE_INTEGER }); const scanned = variables.length; // Collect unique root hashes from all variables diff --git a/packages/core/src/index.test.ts b/packages/core/src/index.test.ts index b90222f..319d9fd 100644 --- a/packages/core/src/index.test.ts +++ b/packages/core/src/index.test.ts @@ -148,7 +148,7 @@ describe("createMemoryStore – has", () => { const h2 = await store.put(typeHash, { a: 2 }); const h3 = await store.put(typeHash, { a: 3 }); - const all = store.listByType(typeHash); + const all = store.listByType(typeHash).map((e) => e.hash); expect(all).toHaveLength(3); expect(all).toContain(h1); expect(all).toContain(h2); @@ -179,7 +179,7 @@ describe("createMemoryStore – listByType", () => { const h2 = await store.put(typeHash, { a: 2 }); await store.put(otherType, { b: 1 }); - const byType = store.listByType(typeHash); + const byType = store.listByType(typeHash).map((e) => e.hash); expect(byType).toHaveLength(2); expect(byType).toContain(h1); expect(byType).toContain(h2); @@ -192,7 +192,7 @@ describe("createMemoryStore – listByType", () => { const h1 = await store.put(typeHash, { n: 1 }); await store.put(typeHash, { n: 1 }); - expect(store.listByType(typeHash)).toEqual([h1]); + expect(store.listByType(typeHash).map((e) => e.hash)).toEqual([h1]); }); test("bootstrap node is listed under its self type", async () => { @@ -201,7 +201,7 @@ describe("createMemoryStore – listByType", () => { const hash = builtinSchemas["@ocas/schema"] ?? ""; // All built-in schemas should be typed by the meta-schema - const allTypedByMeta = store.listByType(hash); + const allTypedByMeta = store.listByType(hash).map((e) => e.hash); expect(allTypedByMeta).toContain(hash); // meta-schema itself expect(allTypedByMeta).toContain(builtinSchemas["@ocas/string"] ?? ""); expect(allTypedByMeta).toContain(builtinSchemas["@ocas/number"] ?? ""); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 4fd066a..d136879 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -5,6 +5,7 @@ export { cborEncode } from "./cbor.js"; export { type GcStats, gc } from "./gc.js"; export { computeHash, computeSelfHash } from "./hash.js"; export { renderWithTemplate } from "./liquid-render.js"; +export { applyListOptions, casListEntry } from "./list-utils.js"; export { registerOutputTemplates } from "./output-templates.js"; export { type RenderOptions, @@ -22,7 +23,15 @@ export { walk, } from "./schema.js"; export { createMemoryStore } from "./store.js"; -export type { CasNode, Hash, Store } from "./types.js"; +export { + type CasNode, + DEFAULT_LIST_LIMIT, + type Hash, + type ListEntry, + type ListOptions, + type ListSort, + type Store, +} from "./types.js"; export type { Variable } from "./variable.js"; export { CasNodeNotFoundError, diff --git a/packages/core/src/list-pagination.test.ts b/packages/core/src/list-pagination.test.ts new file mode 100644 index 0000000..20eff98 --- /dev/null +++ b/packages/core/src/list-pagination.test.ts @@ -0,0 +1,191 @@ +import { describe, expect, test } from "bun:test"; +import { BOOTSTRAP_STORE } from "./bootstrap-capable.js"; +import { createMemoryStore } from "./store.js"; + +const HASH_RE = /^[0-9A-HJKMNP-TV-Z]{13}$/; + +async function putN( + store: ReturnType, + type: string, + n: number, + delayMs = 2, +): Promise { + const hashes: string[] = []; + for (let i = 0; i < n; i++) { + hashes.push(await store.put(type, { i })); + if (delayMs > 0 && i < n - 1) { + await new Promise((r) => setTimeout(r, delayMs)); + } + } + return hashes; +} + +describe("listByType - pagination + sort + timestamps", () => { + test("A1. returns objects with hash/created/updated", async () => { + const store = createMemoryStore(); + const m = await store[BOOTSTRAP_STORE]({ type: "object" }); + await putN(store, m, 3, 0); + + const list = store.listByType(m); + for (const e of list) { + expect(e.hash).toMatch(HASH_RE); + expect(typeof e.created).toBe("number"); + expect(typeof e.updated).toBe("number"); + expect(e.created).toBe(e.updated); + } + }); + + test("A2. default sort is created ASC", async () => { + const store = createMemoryStore(); + const m = await store[BOOTSTRAP_STORE]({ type: "object" }); + await putN(store, m, 4); + + const list = store.listByType(m); + for (let i = 1; i < list.length; i++) { + expect((list[i] as { created: number }).created).toBeGreaterThanOrEqual( + (list[i - 1] as { created: number }).created, + ); + } + }); + + test("A3. desc:true reverses order", async () => { + const store = createMemoryStore(); + const m = await store[BOOTSTRAP_STORE]({ type: "object" }); + await putN(store, m, 4); + + const list = store.listByType(m, { desc: true }); + for (let i = 1; i < list.length; i++) { + expect((list[i] as { created: number }).created).toBeLessThanOrEqual( + (list[i - 1] as { created: number }).created, + ); + } + }); + + test("A4. sort: 'updated' is equivalent to 'created' for CAS nodes", async () => { + const store = createMemoryStore(); + const m = await store[BOOTSTRAP_STORE]({ type: "object" }); + await putN(store, m, 4); + + const a = store.listByType(m, { sort: "created" }); + const b = store.listByType(m, { sort: "updated" }); + expect(a).toEqual(b); + }); + + test("A5. limit truncates", async () => { + const store = createMemoryStore(); + const m = await store[BOOTSTRAP_STORE]({ type: "object" }); + await putN(store, m, 5, 0); + expect(store.listByType(m, { limit: 2 })).toHaveLength(2); + }); + + test("A6. offset skips", async () => { + const store = createMemoryStore(); + const m = await store[BOOTSTRAP_STORE]({ type: "object" }); + await putN(store, m, 5); + + const all = store.listByType(m); + const skip = store.listByType(m, { offset: 2, limit: 10 }); + expect(skip).toHaveLength(all.length - 2); + expect(skip[0]).toEqual(all[2] as (typeof all)[number]); + }); + + test("A7. limit:0 returns empty array", async () => { + const store = createMemoryStore(); + const m = await store[BOOTSTRAP_STORE]({ type: "object" }); + await putN(store, m, 3, 0); + expect(store.listByType(m, { limit: 0 })).toEqual([]); + }); + + test("A8. offset past end returns empty array", async () => { + const store = createMemoryStore(); + const m = await store[BOOTSTRAP_STORE]({ type: "object" }); + await putN(store, m, 3, 0); + expect(store.listByType(m, { offset: 100 })).toEqual([]); + }); + + test("A9. default limit is 100", async () => { + const store = createMemoryStore(); + const m = await store[BOOTSTRAP_STORE]({ type: "object" }); + await putN(store, m, 150, 0); + expect(store.listByType(m)).toHaveLength(100); + }); + + test("A10. desc + offset + limit combined", async () => { + const store = createMemoryStore(); + const m = await store[BOOTSTRAP_STORE]({ type: "object" }); + await putN(store, m, 5, 15); + const all = store.listByType(m); + const got = store.listByType(m, { desc: true, offset: 1, limit: 2 }); + expect(got).toHaveLength(2); + // desc order is reverse of `all`; offset 1 + limit 2 → all[n-2], all[n-3] + const n = all.length; + expect(got[0]?.hash).toBe(all[n - 2]?.hash as string); + expect(got[1]?.hash).toBe(all[n - 3]?.hash as string); + }); +}); + +describe("listMeta / listSchemas - pagination", () => { + test("B1. listMeta returns {hash,created,updated}", async () => { + const store = createMemoryStore(); + const h = await store[BOOTSTRAP_STORE]({ type: "object" }); + const list = store.listMeta(); + expect(list).toHaveLength(1); + const e = list[0] as { hash: string; created: number; updated: number }; + expect(e.hash).toBe(h); + expect(typeof e.created).toBe("number"); + expect(typeof e.updated).toBe("number"); + }); + + test("B2. listMeta default limit 100", async () => { + const store = createMemoryStore(); + for (let i = 0; i < 150; i++) { + await store[BOOTSTRAP_STORE]({ type: "object", i }); + } + expect(store.listMeta()).toHaveLength(100); + }); + + test("B3. listMeta limit/offset/desc", async () => { + const store = createMemoryStore(); + for (let i = 0; i < 5; i++) { + await store[BOOTSTRAP_STORE]({ type: "object", i }); + await new Promise((r) => setTimeout(r, 2)); + } + expect(store.listMeta({ limit: 2 })).toHaveLength(2); + const all = store.listMeta(); + const desc = store.listMeta({ desc: true }); + expect(desc[0]).toEqual(all[all.length - 1] as (typeof all)[number]); + }); + + test("B4. listSchemas returns objects, supports limit", async () => { + const store = createMemoryStore(); + const m = await store[BOOTSTRAP_STORE]({ type: "object" }); + await store.put(m, { type: "string" }); + await store.put(m, { type: "number" }); + + const list = store.listSchemas(); + for (const e of list) { + expect(e.hash).toMatch(HASH_RE); + expect(typeof e.created).toBe("number"); + } + expect(store.listSchemas({ limit: 1 })).toHaveLength(1); + }); +}); + +describe("Determinism / edge cases", () => { + test("I1. same-ms timestamps yield deterministic ordering across calls", async () => { + const store = createMemoryStore(); + const m = await store[BOOTSTRAP_STORE]({ type: "object" }); + // No delay → likely same millisecond + await putN(store, m, 5, 0); + const a = store.listByType(m); + const b = store.listByType(m); + expect(b).toEqual(a); + }); + + test("I2. empty store returns []", () => { + const store = createMemoryStore(); + expect(store.listByType("0000000000000")).toEqual([]); + expect(store.listMeta()).toEqual([]); + expect(store.listSchemas()).toEqual([]); + }); +}); diff --git a/packages/core/src/list-utils.ts b/packages/core/src/list-utils.ts new file mode 100644 index 0000000..7faceb5 --- /dev/null +++ b/packages/core/src/list-utils.ts @@ -0,0 +1,43 @@ +import { + DEFAULT_LIST_LIMIT, + type Hash, + type ListEntry, + type ListOptions, +} from "./types.js"; + +/** + * Apply sort/desc/offset/limit to an array of `ListEntry` records. + * Default sort is by `created` ascending; default limit is `DEFAULT_LIST_LIMIT`. + * + * Tiebreaker is the entry hash (lexicographic ascending) so ordering remains + * deterministic for entries sharing the same timestamp. + */ +export function applyListOptions( + entries: ListEntry[], + options?: ListOptions, +): ListEntry[] { + const sort = options?.sort ?? "created"; + const desc = options?.desc ?? false; + const limit = options?.limit ?? DEFAULT_LIST_LIMIT; + const offset = options?.offset ?? 0; + + const sorted = [...entries].sort((a, b) => { + const av = sort === "updated" ? a.updated : a.created; + const bv = sort === "updated" ? b.updated : b.created; + if (av !== bv) return desc ? bv - av : av - bv; + // Hash tiebreaker — stable across calls + if (a.hash === b.hash) return 0; + return desc ? (a.hash < b.hash ? 1 : -1) : a.hash < b.hash ? -1 : 1; + }); + + if (limit <= 0) return []; + return sorted.slice(offset, offset + limit); +} + +/** + * Build a `ListEntry` for a CAS node from its hash and timestamp. + * For immutable CAS nodes `created === updated === timestamp`. + */ +export function casListEntry(hash: Hash, timestamp: number): ListEntry { + return { hash, created: timestamp, updated: timestamp }; +} diff --git a/packages/core/src/mem-store.ts b/packages/core/src/mem-store.ts index 8876b7e..51806c2 100644 --- a/packages/core/src/mem-store.ts +++ b/packages/core/src/mem-store.ts @@ -1,7 +1,7 @@ import type { BootstrapCapableStore } from "./bootstrap-capable.js"; import { BOOTSTRAP_STORE } from "./bootstrap-capable.js"; import { createMemoryStore } from "./store.js"; -import type { CasNode, Hash } from "./types.js"; +import type { CasNode, Hash, ListEntry, ListOptions } from "./types.js"; /** In-memory store wrapper used by schema validation tests. */ export class MemStore implements BootstrapCapableStore { @@ -23,20 +23,20 @@ export class MemStore implements BootstrapCapableStore { return this.#inner.has(hash); } - listByType(typeHash: Hash): Hash[] { - return this.#inner.listByType(typeHash); + listByType(typeHash: Hash, options?: ListOptions): ListEntry[] { + return this.#inner.listByType(typeHash, options); } listAll(): Hash[] { return this.#inner.listAll(); } - listMeta(): Hash[] { - return this.#inner.listMeta(); + listMeta(options?: ListOptions): ListEntry[] { + return this.#inner.listMeta(options); } - listSchemas(): Hash[] { - return this.#inner.listSchemas(); + listSchemas(options?: ListOptions): ListEntry[] { + return this.#inner.listSchemas(options); } delete(hash: Hash): void { diff --git a/packages/core/src/store.test.ts b/packages/core/src/store.test.ts index 6ebbc2f..f6a4b1e 100644 --- a/packages/core/src/store.test.ts +++ b/packages/core/src/store.test.ts @@ -13,8 +13,8 @@ describe("createMemoryStore – meta and schema indexes", () => { const store = createMemoryStore(); const hash = await store[BOOTSTRAP_STORE]({ type: "object" }); - expect(store.listMeta()).toContain(hash); - expect(store.listSchemas()).toContain(hash); + expect(store.listMeta().map((e) => e.hash)).toContain(hash); + expect(store.listSchemas().map((e) => e.hash)).toContain(hash); }); test("B3. regular put does not add hash to metaSet", async () => { @@ -22,8 +22,8 @@ describe("createMemoryStore – meta and schema indexes", () => { const metaHash = await store[BOOTSTRAP_STORE]({ type: "object" }); const schemaHash = await store.put(metaHash, { type: "string" }); - expect(store.listMeta()).not.toContain(schemaHash); - expect(store.listMeta()).toContain(metaHash); + expect(store.listMeta().map((e) => e.hash)).not.toContain(schemaHash); + expect(store.listMeta().map((e) => e.hash)).toContain(metaHash); }); test("B4. schema typed by meta-schema appears in listSchemas", async () => { @@ -31,11 +31,11 @@ describe("createMemoryStore – meta and schema indexes", () => { const m = await store[BOOTSTRAP_STORE]({ type: "object" }); const s = await store.put(m, { type: "string" }); - const schemas = store.listSchemas(); + const schemas = store.listSchemas().map((e) => e.hash); expect(schemas).toContain(m); expect(schemas).toContain(s); - const meta = store.listMeta(); + const meta = store.listMeta().map((e) => e.hash); expect(meta).toContain(m); expect(meta).not.toContain(s); }); @@ -47,12 +47,12 @@ describe("createMemoryStore – meta and schema indexes", () => { const s1 = await store.put(m1, { type: "string" }); const s2 = await store.put(m2, { type: "number" }); - const meta = store.listMeta(); + const meta = store.listMeta().map((e) => e.hash); expect(meta).toContain(m1); expect(meta).toContain(m2); expect(meta).toHaveLength(2); - const schemas = store.listSchemas(); + const schemas = store.listSchemas().map((e) => e.hash); expect(schemas).toContain(m1); expect(schemas).toContain(m2); expect(schemas).toContain(s1); @@ -66,7 +66,7 @@ describe("createMemoryStore – meta and schema indexes", () => { const h2 = await store[BOOTSTRAP_STORE](payload); expect(h1).toBe(h2); - const meta = store.listMeta(); + const meta = store.listMeta().map((e) => e.hash); const occurrences = meta.filter((h) => h === h1).length; expect(occurrences).toBe(1); }); @@ -76,14 +76,14 @@ describe("createMemoryStore – meta and schema indexes", () => { const m = await store[BOOTSTRAP_STORE]({ type: "object" }); const s = await store.put(m, { type: "string" }); - expect(store.listMeta()).toContain(m); - expect(store.listSchemas()).toContain(s); + expect(store.listMeta().map((e) => e.hash)).toContain(m); + expect(store.listSchemas().map((e) => e.hash)).toContain(s); store.delete(m); - expect(store.listMeta()).not.toContain(m); + expect(store.listMeta().map((e) => e.hash)).not.toContain(m); // schemas typed by deleted meta no longer surface - expect(store.listSchemas()).not.toContain(s); - expect(store.listSchemas()).not.toContain(m); + expect(store.listSchemas().map((e) => e.hash)).not.toContain(s); + expect(store.listSchemas().map((e) => e.hash)).not.toContain(m); }); }); diff --git a/packages/core/src/store.ts b/packages/core/src/store.ts index 4c6f9d1..f7ced1f 100644 --- a/packages/core/src/store.ts +++ b/packages/core/src/store.ts @@ -3,7 +3,8 @@ import { type BootstrapCapableStore, } from "./bootstrap-capable.js"; import { computeHash, computeSelfHash } from "./hash.js"; -import type { CasNode, Hash } from "./types.js"; +import { applyListOptions, casListEntry } from "./list-utils.js"; +import type { CasNode, Hash, ListEntry, ListOptions } from "./types.js"; export function createMemoryStore(): BootstrapCapableStore { const data = new Map(); @@ -29,6 +30,15 @@ export function createMemoryStore(): BootstrapCapableStore { return hash; } + function entriesForHashes(hashes: Iterable): ListEntry[] { + const result: ListEntry[] = []; + for (const h of hashes) { + const node = data.get(h); + if (node) result.push(casListEntry(h, node.timestamp)); + } + return result; + } + const store: BootstrapCapableStore = { async put(typeHash: Hash, payload: unknown): Promise { const hash = await computeHash(typeHash, payload); @@ -49,20 +59,21 @@ export function createMemoryStore(): BootstrapCapableStore { return data.has(hash); }, - listByType(typeHash: Hash): Hash[] { + listByType(typeHash: Hash, options?: ListOptions): ListEntry[] { const set = byType.get(typeHash); - return set ? [...set] : []; + if (!set) return []; + return applyListOptions(entriesForHashes(set), options); }, listAll(): Hash[] { return Array.from(data.keys()); }, - listMeta(): Hash[] { - return Array.from(metaSet); + listMeta(options?: ListOptions): ListEntry[] { + return applyListOptions(entriesForHashes(metaSet), options); }, - listSchemas(): Hash[] { + listSchemas(options?: ListOptions): ListEntry[] { const result = new Set(); for (const meta of metaSet) { result.add(meta); @@ -71,7 +82,7 @@ export function createMemoryStore(): BootstrapCapableStore { for (const h of set) result.add(h); } } - return Array.from(result); + return applyListOptions(entriesForHashes(result), options); }, delete(hash: Hash): void { diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index ee88836..66d801f 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -15,6 +15,40 @@ export type CasNode = { timestamp: number; }; +/** + * Sort key for list operations. + * - "created": ordering by node creation timestamp (default). + * - "updated": ordering by mutation timestamp; for immutable CAS nodes this + * is identical to "created". + */ +export type ListSort = "created" | "updated"; + +/** + * Common options shared by list operations on the CAS store. + */ +export type ListOptions = { + sort?: ListSort; + desc?: boolean; + limit?: number; + offset?: number; +}; + +/** + * One entry in the result of a list operation: a hash plus its + * creation/mutation timestamps. For immutable CAS nodes + * `created === updated === node.timestamp`. + */ +export type ListEntry = { + hash: Hash; + created: number; + updated: number; +}; + +/** + * Default limit applied when callers omit `limit` from list options. + */ +export const DEFAULT_LIST_LIMIT = 100; + /** * Content-addressable store interface. * Self-referencing nodes are created only via bootstrap(). @@ -23,9 +57,9 @@ export type Store = { put(typeHash: Hash, payload: unknown): Promise; get(hash: Hash): CasNode | null; has(hash: Hash): boolean; - listByType(typeHash: Hash): Hash[]; + listByType(typeHash: Hash, options?: ListOptions): ListEntry[]; listAll(): Hash[]; - listMeta(): Hash[]; - listSchemas(): Hash[]; + listMeta(options?: ListOptions): ListEntry[]; + listSchemas(options?: ListOptions): ListEntry[]; delete(hash: Hash): void; }; diff --git a/packages/core/src/variable-list-pagination.test.ts b/packages/core/src/variable-list-pagination.test.ts new file mode 100644 index 0000000..d37d1ee --- /dev/null +++ b/packages/core/src/variable-list-pagination.test.ts @@ -0,0 +1,108 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { bootstrap } from "./bootstrap.js"; +import { createMemoryStore } from "./store.js"; +import type { Hash } from "./types.js"; +import { createVariableStore, type VariableStore } from "./variable-store.js"; + +let dbDir: string; +let dbPath: string; +let casStore: ReturnType; +let varStore: VariableStore; +let stringHash: Hash; + +beforeEach(async () => { + dbDir = mkdtempSync(join(tmpdir(), "ocas-var-pagination-")); + dbPath = join(dbDir, "vars.db"); + casStore = createMemoryStore(); + const aliases = await bootstrap(casStore); + stringHash = aliases["@ocas/string"] as Hash; + varStore = createVariableStore(dbPath, casStore); +}); + +afterEach(() => { + varStore.close(); + rmSync(dbDir, { recursive: true, force: true }); +}); + +async function setN(prefix: string, n: number, delayMs = 2): Promise { + const hashes: Hash[] = []; + for (let i = 0; i < n; i++) { + const h = await casStore.put(stringHash, `${prefix}-${i}`); + varStore.set(`${prefix}-${i}`, h); + hashes.push(h); + if (delayMs > 0 && i < n - 1) { + await new Promise((r) => setTimeout(r, delayMs)); + } + } + return hashes; +} + +describe("VariableStore.list - pagination + sort", () => { + test("D1. default sort = created ASC", async () => { + await setN("v", 3); + const list = varStore.list({ namePrefix: "v-" }); + for (let i = 1; i < list.length; i++) { + expect((list[i] as { created: number }).created).toBeGreaterThanOrEqual( + (list[i - 1] as { created: number }).created, + ); + } + }); + + test("D2. sort: 'updated' differs after re-set", async () => { + await setN("u", 3); + await new Promise((r) => setTimeout(r, 5)); + // Re-set u-0 with a NEW value so updated changes + const newHash = await casStore.put(stringHash, "u-0-new"); + varStore.set("u-0", newHash); + + const byUpdated = varStore.list({ + namePrefix: "u-", + sort: "updated", + }); + // u-0 should be last when sorted updated ASC + const last = byUpdated[byUpdated.length - 1] as { name: string }; + expect(last.name).toBe("u-0"); + }); + + test("D3. desc reverses both sort modes", async () => { + await setN("d", 3); + const asc = varStore.list({ namePrefix: "d-" }); + const desc = varStore.list({ namePrefix: "d-", desc: true }); + expect(desc[0]).toEqual(asc[asc.length - 1] as (typeof asc)[number]); + }); + + test("D4. limit/offset honored", async () => { + await setN("p", 5); + expect(varStore.list({ namePrefix: "p-", limit: 2 })).toHaveLength(2); + expect( + varStore.list({ namePrefix: "p-", offset: 2, limit: 10 }), + ).toHaveLength(3); + }); + + test("D5. default limit is 100", async () => { + await setN("big", 105, 0); + const list = varStore.list({ namePrefix: "big-" }); + expect(list).toHaveLength(100); + }); + + test("D6. pagination applied AFTER namePrefix/schema filters", async () => { + await setN("filt", 5); + const list = varStore.list({ + namePrefix: "filt-", + schema: stringHash, + limit: 2, + }); + expect(list).toHaveLength(2); + for (const v of list) { + expect((v as { name: string }).name.startsWith("filt-")).toBe(true); + } + }); + + test("limit: 0 returns empty array", async () => { + await setN("z", 3, 0); + expect(varStore.list({ namePrefix: "z-", limit: 0 })).toEqual([]); + }); +}); diff --git a/packages/core/src/variable-store.ts b/packages/core/src/variable-store.ts index bd9df8c..46076ba 100644 --- a/packages/core/src/variable-store.ts +++ b/packages/core/src/variable-store.ts @@ -1,5 +1,6 @@ import { Database } from "bun:sqlite"; -import type { Hash, Store } from "./types.js"; +import type { Hash, ListSort, Store } from "./types.js"; +import { DEFAULT_LIST_LIMIT } from "./types.js"; import type { Variable } from "./variable.js"; /** @@ -605,7 +606,10 @@ export class VariableStore { } // Remove all schema variants for this name - const variants = this.list({ exactName: name }); + const variants = this.list({ + exactName: name, + limit: Number.MAX_SAFE_INTEGER, + }); if (variants.length === 0) { return []; @@ -629,6 +633,10 @@ export class VariableStore { schema?: Hash; tags?: Record; labels?: string[]; + sort?: ListSort; + desc?: boolean; + limit?: number; + offset?: number; }): Variable[] { // Validate mutually exclusive options if (options?.namePrefix !== undefined && options?.exactName !== undefined) { @@ -642,6 +650,12 @@ export class VariableStore { const schema = options?.schema; const filterTags = options?.tags ?? {}; const filterLabels = options?.labels ?? []; + const sort = options?.sort ?? "created"; + const desc = options?.desc ?? false; + const limit = options?.limit ?? DEFAULT_LIST_LIMIT; + const offset = options?.offset ?? 0; + + if (limit <= 0) return []; // Build query with filters let query = ` @@ -695,7 +709,12 @@ export class VariableStore { query += ` WHERE ${whereClauses.join(" AND ")}`; } - query += " ORDER BY v.created ASC"; + const sortColumn = sort === "updated" ? "v.updated" : "v.created"; + const direction = desc ? "DESC" : "ASC"; + // Tiebreaker: name ASC for stable ordering across same-ms timestamps + query += ` ORDER BY ${sortColumn} ${direction}, v.name ASC`; + query += " LIMIT ? OFFSET ?"; + params.push(limit, offset); const stmt = this.db.prepare(query); const rows = stmt.all(...params) as Array<{ diff --git a/packages/fs/src/store.test.ts b/packages/fs/src/store.test.ts index f52e152..728b673 100644 --- a/packages/fs/src/store.test.ts +++ b/packages/fs/src/store.test.ts @@ -169,7 +169,7 @@ describe("createFsStore – has and list", () => { const h2 = await store.put(typeHash, { a: 2 }); const h3 = await store.put(typeHash, { a: 3 }); - const all = store.listByType(typeHash); + const all = store.listByType(typeHash).map((e) => e.hash); expect(all).toHaveLength(3); expect(all).toContain(h1); expect(all).toContain(h2); @@ -213,7 +213,7 @@ describe("createFsStore – listByType", () => { const h2 = await store.put(typeHash, { a: 2 }); await store.put(otherType, { b: 1 }); - const byType = store.listByType(typeHash); + const byType = store.listByType(typeHash).map((e) => e.hash); expect(byType).toHaveLength(2); expect(byType).toContain(h1); expect(byType).toContain(h2); @@ -227,7 +227,7 @@ describe("createFsStore – listByType", () => { const h2 = await store1.put(typeHash, { x: 2 }); const store2 = createFsStore(dir); - const byType = store2.listByType(typeHash); + const byType = store2.listByType(typeHash).map((e) => e.hash); expect(byType).toHaveLength(2); expect(byType).toContain(h1); expect(byType).toContain(h2); @@ -241,7 +241,7 @@ describe("createFsStore – listByType", () => { await store1.put(typeHash, { n: 7 }); const store2 = createFsStore(dir); - expect(store2.listByType(typeHash)).toEqual([hash]); + expect(store2.listByType(typeHash).map((e) => e.hash)).toEqual([hash]); }); test("rebuilds _index from .bin files when index is missing", async () => { @@ -254,7 +254,7 @@ describe("createFsStore – listByType", () => { rmSync(join(dir, "_index"), { recursive: true, force: true }); const store2 = createFsStore(dir); - expect(store2.listByType(typeHash)).toEqual([h1, h2]); + expect(store2.listByType(typeHash).map((e) => e.hash)).toEqual([h1, h2]); expect(existsSync(join(dir, "_index", typeHash))).toBe(true); expect(readdirSync(join(dir, "_index"))).toContain(typeHash); }); @@ -265,7 +265,7 @@ describe("createFsStore – listByType", () => { const hash = builtinSchemas["@ocas/schema"] ?? ""; const store2 = createFsStore(dir); - expect(store2.listByType(hash)).toContain(hash); + expect(store2.listByType(hash).map((e) => e.hash)).toContain(hash); }); }); @@ -474,7 +474,7 @@ describe("createFsStore – listMeta and listSchemas", () => { const h2 = await store1[BOOTSTRAP_STORE]({ type: "object", v: "b" }); const store2 = createFsStore(dir); - const meta = store2.listMeta(); + const meta = store2.listMeta().map((e) => e.hash); expect(meta).toContain(h1); expect(meta).toContain(h2); expect(meta).toHaveLength(2); @@ -492,13 +492,13 @@ describe("createFsStore – listMeta and listSchemas", () => { expect(existsSync(metaPath)).toBe(false); const store2 = createFsStore(dir); - expect(store2.listMeta()).toContain(h1); + expect(store2.listMeta().map((e) => e.hash)).toContain(h1); expect(existsSync(metaPath)).toBe(true); const content = readFileSync(metaPath, "utf8"); expect(content).toContain(h1); // unrelated type hash not in meta - expect(store2.listMeta()).not.toContain(t); + expect(store2.listMeta().map((e) => e.hash)).not.toContain(t); }); test("C5. existing _meta is not overwritten", async () => { @@ -522,7 +522,7 @@ describe("createFsStore – listMeta and listSchemas", () => { const s2 = await store.put(m, { type: "number" }); const s3 = await store.put(m, { type: "array" }); - const schemas = store.listSchemas(); + const schemas = store.listSchemas().map((e) => e.hash); expect(schemas).toHaveLength(4); expect(schemas).toContain(m); expect(schemas).toContain(s1); @@ -543,8 +543,8 @@ describe("createFsStore – listMeta and listSchemas", () => { expect(content).toContain(h2); const store2 = createFsStore(dir); - expect(store2.listMeta()).not.toContain(h1); - expect(store2.listMeta()).toContain(h2); + expect(store2.listMeta().map((e) => e.hash)).not.toContain(h1); + expect(store2.listMeta().map((e) => e.hash)).toContain(h2); }); test("C8. fresh store with no self-ref puts has empty listMeta", () => { diff --git a/packages/fs/src/store.ts b/packages/fs/src/store.ts index 3fb5196..49170a8 100644 --- a/packages/fs/src/store.ts +++ b/packages/fs/src/store.ts @@ -14,12 +14,16 @@ import type { BootstrapCapableStore, CasNode, Hash, + ListEntry, + ListOptions, VariableStore, } from "@ocas/core"; import { + applyListOptions, BOOTSTRAP_STORE, bootstrap, + casListEntry, cborEncode, computeHash, computeSelfHash, @@ -174,6 +178,18 @@ function appendToTypeIndex( typeIndex.set(type, list); } +function hashesToEntries( + data: Map, + hashes: Iterable, +): ListEntry[] { + const result: ListEntry[] = []; + for (const h of hashes) { + const node = data.get(h); + if (node) result.push(casListEntry(h, node.timestamp)); + } + return result; +} + export function createFsStore(dir: string): BootstrapCapableStore { const data = new Map(); loadDir(dir, data); @@ -237,19 +253,21 @@ export function createFsStore(dir: string): BootstrapCapableStore { return data.has(hash); }, - listByType(typeHash: Hash): Hash[] { - return typeIndex.get(typeHash) ?? []; + listByType(typeHash: Hash, options?: ListOptions): ListEntry[] { + const list = typeIndex.get(typeHash); + if (!list) return []; + return applyListOptions(hashesToEntries(data, list), options); }, listAll(): Hash[] { return Array.from(data.keys()); }, - listMeta(): Hash[] { - return Array.from(metaSet); + listMeta(options?: ListOptions): ListEntry[] { + return applyListOptions(hashesToEntries(data, metaSet), options); }, - listSchemas(): Hash[] { + listSchemas(options?: ListOptions): ListEntry[] { const result = new Set(); for (const meta of metaSet) { result.add(meta); @@ -258,7 +276,7 @@ export function createFsStore(dir: string): BootstrapCapableStore { for (const h of list) result.add(h); } } - return Array.from(result); + return applyListOptions(hashesToEntries(data, result), options); }, delete(hash: Hash): void { From ed19466a3bfb282456d67c57f3b9fd17d5f0ed15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E6=A9=98?= Date: Mon, 1 Jun 2026 14:49:30 +0000 Subject: [PATCH 2/3] docs: add sort/pagination flags to cli card --- .cards/cli.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.cards/cli.md b/.cards/cli.md index e101aad..db36676 100644 --- a/.cards/cli.md +++ b/.cards/cli.md @@ -72,6 +72,10 @@ ocas render --pipe/-p [options] | `--render`, `-r` | Render output inline (equivalent to piping to `ocas render -p`) | | `--inline ` | Inline text content for `template set` | | `--format tree` | Tree display for `walk` | +| `--sort created\|updated` | Sort key for list commands (default: `created`; for CAS nodes both are equivalent) | +| `--limit ` | Max results to return (default: 100) | +| `--offset ` | Skip first N results (default: 0) | +| `--desc` | Sort descending (default: ascending) | ## Variable Names From 190ae672a72f1cfffa7703993db366d12deb386a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E6=A9=98?= Date: Mon, 1 Jun 2026 15:08:44 +0000 Subject: [PATCH 3/3] refactor: remove DEFAULT_LIST_LIMIT from core, add changeset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Core layer: limit=undefined means no limit (return all) - CLI layer: default limit 100 in parseListOptions() - Remove Number.MAX_SAFE_INTEGER sentinel usages - Add changeset for breaking list return type change Addresses review feedback from 小墨 --- .changeset/list-pagination.md | 7 +++++++ packages/cli/src/index.ts | 5 +++-- packages/core/src/gc.ts | 6 +++--- packages/core/src/index.ts | 1 - packages/core/src/list-pagination.test.ts | 10 ++++++---- packages/core/src/list-utils.ts | 19 +++++++++---------- packages/core/src/types.ts | 5 ----- .../core/src/variable-list-pagination.test.ts | 4 ++-- packages/core/src/variable-store.ts | 16 ++++++++++------ 9 files changed, 40 insertions(+), 33 deletions(-) create mode 100644 .changeset/list-pagination.md diff --git a/.changeset/list-pagination.md b/.changeset/list-pagination.md new file mode 100644 index 0000000..1d0d2ea --- /dev/null +++ b/.changeset/list-pagination.md @@ -0,0 +1,7 @@ +--- +"@ocas/core": minor +"@ocas/fs": minor +"@ocas/cli": minor +--- + +**Breaking:** `listByType()`, `listMeta()`, `listSchemas()` now return `ListEntry[]` (with `hash`, `created`, `updated`) instead of `Hash[]`. All list commands support `--sort`, `--limit`, `--offset`, `--desc` flags. `var list` supports the same pagination options via SQL. diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 6d49d6a..c3a9f24 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -238,7 +238,9 @@ function parseTagsLabels(args: string[]): { * Validates each flag and dies with a clear error on invalid values. */ function parseListOptions(): ListOptions { - const opts: ListOptions = {}; + // Default limit applied at the CLI layer only; core treats undefined as + // "no limit" so internal callers (e.g. gc) can fetch full result sets. + const opts: ListOptions = { limit: 100 }; const sortFlag = flags.sort; if (sortFlag !== undefined) { if (typeof sortFlag !== "string") { @@ -988,7 +990,6 @@ async function cmdTemplateList(_args: string[]): Promise { const variables = varStore.list({ namePrefix: "@ocas/template/text/", schema: stringHash, - limit: Number.MAX_SAFE_INTEGER, }); const templates = variables.map((v) => ({ diff --git a/packages/core/src/gc.ts b/packages/core/src/gc.ts index 06a29c1..19de8cd 100644 --- a/packages/core/src/gc.ts +++ b/packages/core/src/gc.ts @@ -17,9 +17,9 @@ export interface GcStats { * - Schema preservation: schemas of reachable nodes are also marked */ export function gc(store: Store, varStore: VariableStore): GcStats { - // Get all variables (no filters → global). Pass a very large limit so the - // API-level default of 100 does not silently truncate gc roots. - const variables = varStore.list({ limit: Number.MAX_SAFE_INTEGER }); + // Get all variables (no filters → global). Omit `limit` so the full + // variable set is returned for use as gc roots. + const variables = varStore.list(); const scanned = variables.length; // Collect unique root hashes from all variables diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index d136879..11c273c 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -25,7 +25,6 @@ export { export { createMemoryStore } from "./store.js"; export { type CasNode, - DEFAULT_LIST_LIMIT, type Hash, type ListEntry, type ListOptions, diff --git a/packages/core/src/list-pagination.test.ts b/packages/core/src/list-pagination.test.ts index 20eff98..943fb60 100644 --- a/packages/core/src/list-pagination.test.ts +++ b/packages/core/src/list-pagination.test.ts @@ -103,11 +103,13 @@ describe("listByType - pagination + sort + timestamps", () => { expect(store.listByType(m, { offset: 100 })).toEqual([]); }); - test("A9. default limit is 100", async () => { + test("A9. core has no default limit (returns all)", async () => { const store = createMemoryStore(); const m = await store[BOOTSTRAP_STORE]({ type: "object" }); await putN(store, m, 150, 0); - expect(store.listByType(m)).toHaveLength(100); + // No CLI-layer cap; with 150 nodes of type m (plus m itself which is + // self-typed), the full set is returned. + expect(store.listByType(m)).toHaveLength(151); }); test("A10. desc + offset + limit combined", async () => { @@ -136,12 +138,12 @@ describe("listMeta / listSchemas - pagination", () => { expect(typeof e.updated).toBe("number"); }); - test("B2. listMeta default limit 100", async () => { + test("B2. listMeta has no default limit (returns all)", async () => { const store = createMemoryStore(); for (let i = 0; i < 150; i++) { await store[BOOTSTRAP_STORE]({ type: "object", i }); } - expect(store.listMeta()).toHaveLength(100); + expect(store.listMeta()).toHaveLength(150); }); test("B3. listMeta limit/offset/desc", async () => { diff --git a/packages/core/src/list-utils.ts b/packages/core/src/list-utils.ts index 7faceb5..5897170 100644 --- a/packages/core/src/list-utils.ts +++ b/packages/core/src/list-utils.ts @@ -1,13 +1,9 @@ -import { - DEFAULT_LIST_LIMIT, - type Hash, - type ListEntry, - type ListOptions, -} from "./types.js"; +import type { Hash, ListEntry, ListOptions } from "./types.js"; /** * Apply sort/desc/offset/limit to an array of `ListEntry` records. - * Default sort is by `created` ascending; default limit is `DEFAULT_LIST_LIMIT`. + * Default sort is by `created` ascending. If `limit` is omitted, all entries + * (after offset) are returned. * * Tiebreaker is the entry hash (lexicographic ascending) so ordering remains * deterministic for entries sharing the same timestamp. @@ -18,7 +14,7 @@ export function applyListOptions( ): ListEntry[] { const sort = options?.sort ?? "created"; const desc = options?.desc ?? false; - const limit = options?.limit ?? DEFAULT_LIST_LIMIT; + const limit = options?.limit; const offset = options?.offset ?? 0; const sorted = [...entries].sort((a, b) => { @@ -30,8 +26,11 @@ export function applyListOptions( return desc ? (a.hash < b.hash ? 1 : -1) : a.hash < b.hash ? -1 : 1; }); - if (limit <= 0) return []; - return sorted.slice(offset, offset + limit); + if (limit !== undefined) { + if (limit <= 0) return []; + return sorted.slice(offset, offset + limit); + } + return sorted.slice(offset); } /** diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 66d801f..4e8ed7a 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -44,11 +44,6 @@ export type ListEntry = { updated: number; }; -/** - * Default limit applied when callers omit `limit` from list options. - */ -export const DEFAULT_LIST_LIMIT = 100; - /** * Content-addressable store interface. * Self-referencing nodes are created only via bootstrap(). diff --git a/packages/core/src/variable-list-pagination.test.ts b/packages/core/src/variable-list-pagination.test.ts index d37d1ee..3f4b8e4 100644 --- a/packages/core/src/variable-list-pagination.test.ts +++ b/packages/core/src/variable-list-pagination.test.ts @@ -82,10 +82,10 @@ describe("VariableStore.list - pagination + sort", () => { ).toHaveLength(3); }); - test("D5. default limit is 100", async () => { + test("D5. core has no default limit (returns all)", async () => { await setN("big", 105, 0); const list = varStore.list({ namePrefix: "big-" }); - expect(list).toHaveLength(100); + expect(list).toHaveLength(105); }); test("D6. pagination applied AFTER namePrefix/schema filters", async () => { diff --git a/packages/core/src/variable-store.ts b/packages/core/src/variable-store.ts index 46076ba..240ec93 100644 --- a/packages/core/src/variable-store.ts +++ b/packages/core/src/variable-store.ts @@ -1,6 +1,5 @@ import { Database } from "bun:sqlite"; import type { Hash, ListSort, Store } from "./types.js"; -import { DEFAULT_LIST_LIMIT } from "./types.js"; import type { Variable } from "./variable.js"; /** @@ -608,7 +607,6 @@ export class VariableStore { // Remove all schema variants for this name const variants = this.list({ exactName: name, - limit: Number.MAX_SAFE_INTEGER, }); if (variants.length === 0) { @@ -652,10 +650,10 @@ export class VariableStore { const filterLabels = options?.labels ?? []; const sort = options?.sort ?? "created"; const desc = options?.desc ?? false; - const limit = options?.limit ?? DEFAULT_LIST_LIMIT; + const limit = options?.limit; const offset = options?.offset ?? 0; - if (limit <= 0) return []; + if (limit !== undefined && limit <= 0) return []; // Build query with filters let query = ` @@ -713,8 +711,14 @@ export class VariableStore { const direction = desc ? "DESC" : "ASC"; // Tiebreaker: name ASC for stable ordering across same-ms timestamps query += ` ORDER BY ${sortColumn} ${direction}, v.name ASC`; - query += " LIMIT ? OFFSET ?"; - params.push(limit, offset); + if (limit !== undefined) { + query += " LIMIT ? OFFSET ?"; + params.push(limit, offset); + } else if (offset > 0) { + // SQLite requires LIMIT when using OFFSET; use -1 to mean "no limit". + query += " LIMIT -1 OFFSET ?"; + params.push(offset); + } const stmt = this.db.prepare(query); const rows = stmt.all(...params) as Array<{