feat: add sorting, pagination, and timestamps to list commands #28

Merged
xiaomo merged 3 commits from fix/27-list-sort-pagination into main 2026-06-01 15:09:52 +00:00
23 changed files with 884 additions and 91 deletions
+4
View File
@@ -72,6 +72,10 @@ ocas render --pipe/-p [options]
| `--render`, `-r` | Render output inline (equivalent to piping to `ocas render -p`) |
| `--inline <text>` | 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 <n>` | Max results to return (default: 100) |
| `--offset <n>` | Skip first N results (default: 0) |
| `--desc` | Sort descending (default: ascending) |
## Variable Names
+7
View File
@@ -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.
+77 -10
View File
@@ -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,62 @@ 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 {
// 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") {
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<void> {
@@ -769,6 +828,7 @@ async function cmdVarList(args: string[]): Promise<void> {
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 +852,10 @@ async function cmdVarList(args: string[]): Promise<void> {
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),
@@ -992,27 +1053,33 @@ async function cmdList(_args: string[]): Promise<void> {
const typeFlag = flags.type;
if (typeof typeFlag !== "string")
die("Usage: ocas list --type <hash-or-name>");
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<void> {
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<void> {
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,
);
}
@@ -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": [],
+18 -10
View File
@@ -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 <M2 hash> 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 <newest>.value.length
expect(lsValue.length).toBeGreaterThan(ltValue.length);
expect(lsHashes.length).toBeGreaterThan(ltHashes.length);
});
});
+249
View File
@@ -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<string> {
// 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<string[]> {
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");
});
});
+4 -4
View File
@@ -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}$/);
}
});
+2 -1
View File
@@ -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);
});
});
+2 -2
View File
@@ -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);
+27 -3
View File
@@ -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",
},
],
+2 -1
View File
@@ -17,7 +17,8 @@ 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)
// 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;
+4 -4
View File
@@ -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"] ?? "");
+9 -1
View File
@@ -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,14 @@ export {
walk,
} from "./schema.js";
export { createMemoryStore } from "./store.js";
export type { CasNode, Hash, Store } from "./types.js";
export {
type CasNode,
type Hash,
type ListEntry,
type ListOptions,
type ListSort,
type Store,
} from "./types.js";
export type { Variable } from "./variable.js";
export {
CasNodeNotFoundError,
+193
View File
@@ -0,0 +1,193 @@
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<typeof createMemoryStore>,
type: string,
n: number,
delayMs = 2,
): Promise<string[]> {
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. 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);
// 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 () => {
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 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(150);
});
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([]);
});
});
+42
View File
@@ -0,0 +1,42 @@
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. 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.
*/
export function applyListOptions(
entries: ListEntry[],
options?: ListOptions,
): ListEntry[] {
const sort = options?.sort ?? "created";
const desc = options?.desc ?? false;
const limit = options?.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 !== undefined) {
if (limit <= 0) return [];
return sorted.slice(offset, offset + limit);
}
return sorted.slice(offset);
}
/**
* 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 };
}
+7 -7
View File
@@ -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 {
+14 -14
View File
@@ -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);
});
});
+18 -7
View File
@@ -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<Hash, CasNode>();
@@ -29,6 +30,15 @@ export function createMemoryStore(): BootstrapCapableStore {
return hash;
}
function entriesForHashes(hashes: Iterable<Hash>): 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<Hash> {
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<Hash>();
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 {
+32 -3
View File
@@ -15,6 +15,35 @@ export type CasNode<T = unknown> = {
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;
};
/**
* Content-addressable store interface.
* Self-referencing nodes are created only via bootstrap().
@@ -23,9 +52,9 @@ export type Store = {
put(typeHash: Hash, payload: unknown): Promise<Hash>;
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;
};
@@ -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<typeof createMemoryStore>;
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<Hash[]> {
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. core has no default limit (returns all)", async () => {
await setN("big", 105, 0);
const list = varStore.list({ namePrefix: "big-" });
expect(list).toHaveLength(105);
});
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([]);
});
});
+26 -3
View File
@@ -1,5 +1,5 @@
import { Database } from "bun:sqlite";
import type { Hash, Store } from "./types.js";
import type { Hash, ListSort, Store } from "./types.js";
import type { Variable } from "./variable.js";
/**
@@ -605,7 +605,9 @@ export class VariableStore {
}
// Remove all schema variants for this name
const variants = this.list({ exactName: name });
const variants = this.list({
exactName: name,
});
if (variants.length === 0) {
return [];
@@ -629,6 +631,10 @@ export class VariableStore {
schema?: Hash;
tags?: Record<string, string>;
labels?: string[];
sort?: ListSort;
desc?: boolean;
limit?: number;
offset?: number;
}): Variable[] {
// Validate mutually exclusive options
if (options?.namePrefix !== undefined && options?.exactName !== undefined) {
@@ -642,6 +648,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;
const offset = options?.offset ?? 0;
if (limit !== undefined && limit <= 0) return [];
// Build query with filters
let query = `
@@ -695,7 +707,18 @@ 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`;
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<{
+12 -12
View File
@@ -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", () => {
+24 -6
View File
@@ -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<Hash, CasNode>,
hashes: Iterable<Hash>,
): 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<Hash, CasNode>();
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<Hash>();
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 {