This repository has been archived on 2026-06-01. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
json-cas/packages/json-cas-fs/src/store.ts
T
xiaoju 55144d0e4a feat: add list-meta and list-schema commands with persistent meta index
Adds Store.listMeta() and Store.listSchemas() to expose meta-schema
discovery, backed by an in-memory metaSet (memory store) and a
persistent _index/_meta file (FS store). Surfaces both via new
json-cas list-meta and list-schema CLI commands wrapped in
@output/list-meta and @output/list-schema envelope schemas.

The FS store migrates from existing nodes when _meta is absent
(scanning self-referencing nodes) and preserves _meta on subsequent
opens. delete() removes affected hashes from the meta index and
persists the change.

Closes #90

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-01 05:10:25 +00:00

357 lines
9.3 KiB
TypeScript

import {
appendFileSync,
existsSync,
mkdirSync,
readdirSync,
readFileSync,
renameSync,
statSync,
unlinkSync,
writeFileSync,
} from "node:fs";
import { join } from "node:path";
import type { BootstrapCapableStore, CasNode, Hash } from "@uncaged/json-cas";
import {
BOOTSTRAP_STORE,
bootstrap,
cborEncode,
computeHash,
computeSelfHash,
} from "@uncaged/json-cas";
import { decode } from "cborg";
const INDEX_DIR = "_index";
const META_FILE = "_meta";
function loadDir(dir: string, data: Map<Hash, CasNode>): void {
let entries: string[];
try {
entries = readdirSync(dir);
} catch {
return;
}
for (const name of entries) {
if (!name.endsWith(".bin")) continue;
const hash = name.slice(0, -4) as Hash;
try {
const buf = readFileSync(join(dir, name));
const node = decode(new Uint8Array(buf)) as CasNode;
data.set(hash, node);
} catch {
// skip corrupted files
}
}
}
function parseIndexFile(content: string): Hash[] {
if (content.length === 0) return [];
return content.split("\n").filter((line) => line.length > 0) as Hash[];
}
function loadTypeIndex(indexDir: string): Map<Hash, Hash[]> {
const typeIndex = new Map<Hash, Hash[]>();
let entries: string[];
try {
entries = readdirSync(indexDir);
} catch {
return typeIndex;
}
for (const typeHash of entries) {
try {
const content = readFileSync(join(indexDir, typeHash), "utf8");
typeIndex.set(typeHash as Hash, parseIndexFile(content));
} catch {
// skip unreadable index files
}
}
return typeIndex;
}
function buildTypeIndexFromNodes(data: Map<Hash, CasNode>): Map<Hash, Hash[]> {
const typeIndex = new Map<Hash, Hash[]>();
for (const [hash, node] of data) {
const list = typeIndex.get(node.type) ?? [];
list.push(hash);
typeIndex.set(node.type, list);
}
return typeIndex;
}
function writeTypeIndex(indexDir: string, typeIndex: Map<Hash, Hash[]>): void {
mkdirSync(indexDir, { recursive: true });
for (const [typeHash, hashes] of typeIndex) {
const body = hashes.length > 0 ? `${hashes.join("\n")}\n` : "";
writeFileSync(join(indexDir, typeHash), body, "utf8");
}
}
function loadOrMigrateTypeIndex(
dir: string,
data: Map<Hash, CasNode>,
): Map<Hash, Hash[]> {
const indexDir = join(dir, INDEX_DIR);
if (!existsSync(indexDir)) {
const typeIndex = buildTypeIndexFromNodes(data);
if (typeIndex.size > 0) {
writeTypeIndex(indexDir, typeIndex);
}
return typeIndex;
}
return loadTypeIndex(indexDir);
}
function loadOrMigrateMetaSet(
dir: string,
data: Map<Hash, CasNode>,
): Set<Hash> {
const indexDir = join(dir, INDEX_DIR);
const metaPath = join(indexDir, META_FILE);
if (existsSync(metaPath)) {
try {
const content = readFileSync(metaPath, "utf8");
return new Set(parseIndexFile(content));
} catch {
return new Set();
}
}
// Migration: scan loaded nodes for self-referencing nodes (type === hash)
const metaSet = new Set<Hash>();
for (const [hash, node] of data) {
if (node.type === hash) {
metaSet.add(hash);
}
}
if (metaSet.size > 0) {
mkdirSync(indexDir, { recursive: true });
const body = `${[...metaSet].join("\n")}\n`;
writeFileSync(metaPath, body, "utf8");
}
return metaSet;
}
function appendToMetaSet(
indexDir: string,
metaSet: Set<Hash>,
hash: Hash,
): void {
if (metaSet.has(hash)) return;
metaSet.add(hash);
mkdirSync(indexDir, { recursive: true });
appendFileSync(join(indexDir, META_FILE), `${hash}\n`, "utf8");
}
function rewriteMetaSet(indexDir: string, metaSet: Set<Hash>): void {
const metaPath = join(indexDir, META_FILE);
if (metaSet.size === 0) {
try {
unlinkSync(metaPath);
} catch {
// ignore
}
return;
}
mkdirSync(indexDir, { recursive: true });
const body = `${[...metaSet].join("\n")}\n`;
writeFileSync(metaPath, body, "utf8");
}
function appendToTypeIndex(
indexDir: string,
typeIndex: Map<Hash, Hash[]>,
type: Hash,
hash: Hash,
): void {
mkdirSync(indexDir, { recursive: true });
appendFileSync(join(indexDir, type), `${hash}\n`, "utf8");
const list = typeIndex.get(type) ?? [];
list.push(hash);
typeIndex.set(type, list);
}
export function createFsStore(dir: string): BootstrapCapableStore {
const data = new Map<Hash, CasNode>();
loadDir(dir, data);
const indexDir = join(dir, INDEX_DIR);
const typeIndex = loadOrMigrateTypeIndex(dir, data);
const metaSet = loadOrMigrateMetaSet(dir, data);
async function putSelfReferencing(payload: unknown): Promise<Hash> {
const hash = await computeSelfHash(payload);
if (!data.has(hash)) {
const node: CasNode = { type: hash, payload, timestamp: Date.now() };
data.set(hash, node);
mkdirSync(dir, { recursive: true });
const tmp = join(dir, `${hash}.tmp`);
const dest = join(dir, `${hash}.bin`);
writeFileSync(
tmp,
cborEncode({ type: hash, payload, timestamp: node.timestamp }),
);
renameSync(tmp, dest);
appendToTypeIndex(indexDir, typeIndex, hash, hash);
}
appendToMetaSet(indexDir, metaSet, hash);
return hash;
}
const store: BootstrapCapableStore = {
async put(typeHash: Hash, payload: unknown): Promise<Hash> {
const hash = await computeHash(typeHash, payload);
if (!data.has(hash)) {
const node: CasNode = {
type: typeHash,
payload,
timestamp: Date.now(),
};
data.set(hash, node);
mkdirSync(dir, { recursive: true });
const tmp = join(dir, `${hash}.tmp`);
const dest = join(dir, `${hash}.bin`);
writeFileSync(
tmp,
cborEncode({ type: typeHash, payload, timestamp: node.timestamp }),
);
renameSync(tmp, dest);
appendToTypeIndex(indexDir, typeIndex, typeHash, hash);
}
return hash;
},
get(hash: Hash): CasNode | null {
return data.get(hash) ?? null;
},
has(hash: Hash): boolean {
return data.has(hash);
},
listByType(typeHash: Hash): Hash[] {
return typeIndex.get(typeHash) ?? [];
},
listAll(): Hash[] {
return Array.from(data.keys());
},
listMeta(): Hash[] {
return Array.from(metaSet);
},
listSchemas(): Hash[] {
const result = new Set<Hash>();
for (const meta of metaSet) {
result.add(meta);
const list = typeIndex.get(meta);
if (list) {
for (const h of list) result.add(h);
}
}
return Array.from(result);
},
delete(hash: Hash): void {
const node = data.get(hash);
if (node) {
data.delete(hash);
// Delete file
try {
unlinkSync(join(dir, `${hash}.bin`));
} catch {
// ignore if file doesn't exist
}
// Remove from type index
const list = typeIndex.get(node.type);
if (list) {
const idx = list.indexOf(hash);
if (idx !== -1) {
list.splice(idx, 1);
}
if (list.length === 0) {
typeIndex.delete(node.type);
// Delete empty index file
try {
unlinkSync(join(indexDir, node.type));
} catch {
// ignore
}
} else {
// Rewrite index file
const body = `${list.join("\n")}\n`;
writeFileSync(join(indexDir, node.type), body, "utf8");
}
}
// Remove from meta set if applicable
if (metaSet.has(hash)) {
metaSet.delete(hash);
rewriteMetaSet(indexDir, metaSet);
}
}
},
[BOOTSTRAP_STORE]: putSelfReferencing,
};
return store;
}
/**
* Open a filesystem-backed CAS store with automatic directory creation and bootstrap.
* This is an async function that:
* 1. Creates the directory (with recursive: true) if it doesn't exist
* 2. Validates that the path is actually a directory (not a file)
* 3. Creates the store
* 4. Runs bootstrap (which is idempotent)
*
* @param dir - The directory path for the store
* @returns A Promise resolving to the BootstrapCapableStore
* @throws Error if the path exists but is not a directory
*/
export async function openStore(dir: string): Promise<BootstrapCapableStore> {
// Create directory if it doesn't exist
try {
mkdirSync(dir, { recursive: true });
} catch (error) {
if (error instanceof Error && "code" in error) {
const nodeError = error as NodeJS.ErrnoException;
if (nodeError.code === "EACCES") {
throw new Error(`Permission denied: cannot access store at ${dir}`);
}
if (nodeError.code === "ENOTDIR") {
throw new Error(`Path exists but is not a directory: ${dir}`);
}
}
throw error;
}
// Validate that the path is a directory
try {
const stats = statSync(dir);
if (!stats.isDirectory()) {
throw new Error(`Path exists but is not a directory: ${dir}`);
}
} catch (error) {
if (error instanceof Error && "code" in error) {
const nodeError = error as NodeJS.ErrnoException;
if (nodeError.code === "ENOENT") {
throw new Error(`Store not found at ${dir}`);
}
}
throw error;
}
// Create the store
const store = createFsStore(dir);
// Bootstrap (idempotent)
await bootstrap(store);
return store;
}