feat: add sorting, pagination, and timestamps to list commands #28
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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": [],
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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}$/);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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"] ?? "");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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<{
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user