feat(library/store): on-disk JSON metadata store for the local cache
check / check (push) Successful in 13s
check / check (push) Successful in 13s
Add `MetadataStore` (src/library/store.ts): one metadata.json holding the user id, schema version, collections cursor, and decrypted collection and file records. Loaded whole into RAM with Maps for id lookups; rewritten whole through the download layer's fsync atomic writer (temp, fsync, rename, dir fsync). Binary keys are base64-encoded on disk. A missing, unparseable, or wrong-schema file loads as an empty store, because the file is only a cache the refresh unit repopulates. Directory 0700, file 0600, matching session.json. No lock file; no sync() beyond the writer. This unit only stores; issue 42's refresh unit populates it. (closes #41) Model: opus-4-8
This commit is contained in:
@@ -0,0 +1,188 @@
|
|||||||
|
// On-disk JSON metadata store for the local cache.
|
||||||
|
//
|
||||||
|
// The store keeps one `metadata.json` file holding the account's server
|
||||||
|
// state: the user id, a schema version, the cursor for the incremental
|
||||||
|
// collections listing, and the decrypted collection and file records. The
|
||||||
|
// whole file is read into RAM on load and rewritten as a whole on save; there
|
||||||
|
// is no partial update and no lock file. A separate refresh unit populates the
|
||||||
|
// store from the server — this module only stores what it is given.
|
||||||
|
//
|
||||||
|
// The file is a cache, so it is never trusted to exist or to be intact: a
|
||||||
|
// missing or unreadable file loads as an empty store rather than an error, and
|
||||||
|
// the refresh unit then repopulates it.
|
||||||
|
|
||||||
|
import { mkdir, chmod, readFile } from "node:fs/promises";
|
||||||
|
import { dirname } from "node:path";
|
||||||
|
|
||||||
|
import { writeAtomic } from "../download/index.js";
|
||||||
|
import type { Collection, EnteFile, Microseconds } from "../model/types.js";
|
||||||
|
|
||||||
|
// Bumped only when the on-disk shape changes incompatibly. A file written
|
||||||
|
// under a different version is discarded on load (see `load`): re-fetching
|
||||||
|
// from the server is always safe and cheaper than migrating a cache.
|
||||||
|
export const METADATA_SCHEMA_VERSION = 1;
|
||||||
|
|
||||||
|
// Directory and file modes match `session.json`: the records hold decrypted
|
||||||
|
// key material, so on a shared machine only the owner may read them.
|
||||||
|
const DIR_MODE = 0o700;
|
||||||
|
const FILE_MODE = 0o600;
|
||||||
|
|
||||||
|
// On-disk shapes. They mirror the in-memory model exactly except for the
|
||||||
|
// binary `key`, which JSON cannot hold and which is stored as base64.
|
||||||
|
type StoredCollection = Omit<Collection, "key"> & { key: string };
|
||||||
|
type StoredFile = Omit<EnteFile, "key"> & { key: string };
|
||||||
|
|
||||||
|
interface StoredMetadata {
|
||||||
|
schemaVersion: number;
|
||||||
|
userID: number;
|
||||||
|
collectionsSinceTime: Microseconds;
|
||||||
|
collections: StoredCollection[];
|
||||||
|
files: StoredFile[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const encodeKey = (key: Uint8Array): string =>
|
||||||
|
Buffer.from(key).toString("base64");
|
||||||
|
|
||||||
|
const decodeKey = (encoded: string): Uint8Array =>
|
||||||
|
new Uint8Array(Buffer.from(encoded, "base64"));
|
||||||
|
|
||||||
|
// A file membership is identified by the pair (collectionID, fileID): the same
|
||||||
|
// underlying file can belong to several collections, each a distinct record
|
||||||
|
// with its own key.
|
||||||
|
const fileKey = (collectionID: number, fileID: number): string =>
|
||||||
|
`${collectionID}:${fileID}`;
|
||||||
|
|
||||||
|
export class MetadataStore {
|
||||||
|
readonly path: string;
|
||||||
|
readonly schemaVersion = METADATA_SCHEMA_VERSION;
|
||||||
|
userID = 0;
|
||||||
|
collectionsSinceTime: Microseconds = 0;
|
||||||
|
|
||||||
|
private readonly collections = new Map<number, Collection>();
|
||||||
|
private readonly files = new Map<string, EnteFile>();
|
||||||
|
|
||||||
|
private constructor(path: string) {
|
||||||
|
this.path = path;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load the store at `path`. A missing file, an unreadable one, unparseable
|
||||||
|
// contents, or a mismatched schema version all yield an empty store bound
|
||||||
|
// to that path — never a thrown error, because the file is only a cache.
|
||||||
|
static async load(path: string): Promise<MetadataStore> {
|
||||||
|
const store = new MetadataStore(path);
|
||||||
|
|
||||||
|
let raw: string;
|
||||||
|
try {
|
||||||
|
raw = await readFile(path, "utf8");
|
||||||
|
} catch {
|
||||||
|
return store;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw) as StoredMetadata;
|
||||||
|
if (parsed.schemaVersion !== METADATA_SCHEMA_VERSION) {
|
||||||
|
return store;
|
||||||
|
}
|
||||||
|
store.userID = parsed.userID ?? 0;
|
||||||
|
store.collectionsSinceTime = parsed.collectionsSinceTime ?? 0;
|
||||||
|
for (const stored of parsed.collections ?? []) {
|
||||||
|
const collection: Collection = {
|
||||||
|
...stored,
|
||||||
|
key: decodeKey(stored.key),
|
||||||
|
};
|
||||||
|
store.collections.set(collection.id, collection);
|
||||||
|
}
|
||||||
|
for (const stored of parsed.files ?? []) {
|
||||||
|
const file: EnteFile = {
|
||||||
|
...stored,
|
||||||
|
key: decodeKey(stored.key),
|
||||||
|
};
|
||||||
|
store.files.set(fileKey(file.collectionID, file.id), file);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Any corruption discards the partial result: a half-read cache is
|
||||||
|
// worse than an empty one, since the refresh unit will rebuild it.
|
||||||
|
return new MetadataStore(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
return store;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rewrite the whole file. The directory is created 0700 and the file left
|
||||||
|
// 0600; the write itself is the download layer's durable atomic writer
|
||||||
|
// (temp file, fsync, rename, dir fsync), so a reader never sees a partial
|
||||||
|
// file and a crash cannot leave a truncated one. There is no lock file and
|
||||||
|
// no `sync()` beyond the writer's own fsyncs.
|
||||||
|
async save(): Promise<void> {
|
||||||
|
const model: StoredMetadata = {
|
||||||
|
schemaVersion: METADATA_SCHEMA_VERSION,
|
||||||
|
userID: this.userID,
|
||||||
|
collectionsSinceTime: this.collectionsSinceTime,
|
||||||
|
collections: [...this.collections.values()].map((c) => ({
|
||||||
|
...c,
|
||||||
|
key: encodeKey(c.key),
|
||||||
|
})),
|
||||||
|
files: [...this.files.values()].map((f) => ({
|
||||||
|
...f,
|
||||||
|
key: encodeKey(f.key),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
|
||||||
|
const dir = dirname(this.path);
|
||||||
|
// chmod after mkdir so the mode is 0700 even when the directory
|
||||||
|
// already existed with a looser mode; mkdir alone would not tighten
|
||||||
|
// an existing directory.
|
||||||
|
await mkdir(dir, { recursive: true, mode: DIR_MODE });
|
||||||
|
await chmod(dir, DIR_MODE);
|
||||||
|
|
||||||
|
const payload = new TextEncoder().encode(
|
||||||
|
JSON.stringify(model, null, 2),
|
||||||
|
);
|
||||||
|
await writeAtomic(this.path, payload);
|
||||||
|
// The atomic writer's temp file inherits the default mode; tighten the
|
||||||
|
// renamed file to 0600. The 0700 directory already keeps other users
|
||||||
|
// out during the brief window before this runs.
|
||||||
|
await chmod(this.path, FILE_MODE);
|
||||||
|
}
|
||||||
|
|
||||||
|
getCollection(id: number): Collection | undefined {
|
||||||
|
return this.collections.get(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
listCollections(): Collection[] {
|
||||||
|
return [...this.collections.values()];
|
||||||
|
}
|
||||||
|
|
||||||
|
putCollection(collection: Collection): void {
|
||||||
|
this.collections.set(collection.id, collection);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Removing a collection also drops its file memberships: a file record is
|
||||||
|
// only meaningful as part of a collection the cache still knows about.
|
||||||
|
deleteCollection(id: number): void {
|
||||||
|
this.collections.delete(id);
|
||||||
|
for (const [key, file] of this.files) {
|
||||||
|
if (file.collectionID === id) {
|
||||||
|
this.files.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getFile(collectionID: number, fileID: number): EnteFile | undefined {
|
||||||
|
return this.files.get(fileKey(collectionID, fileID));
|
||||||
|
}
|
||||||
|
|
||||||
|
listFiles(collectionID: number): EnteFile[] {
|
||||||
|
return [...this.files.values()].filter(
|
||||||
|
(f) => f.collectionID === collectionID,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
putFile(file: EnteFile): void {
|
||||||
|
this.files.set(fileKey(file.collectionID, file.id), file);
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteFile(collectionID: number, fileID: number): void {
|
||||||
|
this.files.delete(fileKey(collectionID, fileID));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user