Files
electronics-inventory/lib/kv-store.mjs
mikael-lovqvists-claude-agent ef2e53ea18 Initial electronics inventory webapp
KV-store backed Express 5 app for tracking electronic components,
their arbitrary fields, and inventory locations (physical, BOM, digital).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 19:11:13 +00:00

201 lines
5.3 KiB
JavaScript

import fs from 'node:fs';
function DEFAULT_SERIALIZER_FACTORY() {
const s = new Serializer();
//TODO - buffers and possibly typed arrays
s.register_constructor(BigInt, 'Bi', (raw) => raw.toString(), (stored) => BigInt(stored));
s.register_constructor(Buffer, 'Bu', (raw) => raw.toString('base64'), (stored) => Buffer.from(stored, 'base64'));
s.register_default('P', (raw) => (JSON.stringify(raw), raw), (stored) => stored);
return s;
}
class Serializer {
constructor(constructor_lut=new Map(), identity_lut=new Map(), type_lut=new Map()) {
Object.assign(this, { constructor_lut, identity_lut, type_lut, default: null });
}
serialize(value) {
const { constructor_lut, identity_lut } = this;
const by_identity = identity_lut.get(value);
if (by_identity) {
const { type_id, serialize_value } = by_identity;
return [ type_id, serialize_value(value) ];
}
const by_constructor = constructor_lut.get(value.constructor);
if (by_constructor) {
const { type_id, serialize_value } = by_constructor;
return [ type_id, serialize_value(value) ];
}
return [this.default.type_id, this.default.serialize_value(value)];
}
deserialize(value) {
if (value === undefined) {
throw new Error(`Deserialize called without value`);
}
const [ type_id, raw_value ] = value;
const { type_lut } = this;
const operation = type_lut.get(type_id);
if (!operation) {
throw new Error(`Unknown type_id: ${type_id}`);
}
const { deserialize_value } = operation;
return deserialize_value(raw_value);
}
register_default(type_id, serialize_value, deserialize_value) {
const { type_lut } = this;
const operation = { selector: 'default', type_id, serialize_value, deserialize_value };
type_lut.set(type_id, operation);
this.default = operation;
}
register_constructor(constructor, type_id, serialize_value, deserialize_value) {
const { constructor_lut, type_lut } = this;
const operation = { selector: 'constructor', constructor, type_id, serialize_value, deserialize_value };
constructor_lut.set(constructor, operation);
type_lut.set(type_id, operation);
}
register_identity(identity, type_id, serialize_value, deserialize_value) {
const { identity_lut, type_lut } = this;
const operation = { selector: 'identity', identity, type_id, serialize_value, deserialize_value };
identity_lut.set(identity, operation);
type_lut.set(type_id, operation);
}
}
const DEFAULT_SETTINGS = {
auto_load: false,
auto_store: false,
auto_store_events: [
'SIGINT',
'SIGTERM',
'exit',
],
debounce_flush_timeout: null,
}
export class Simple_KeyValue_Store {
#flush_debounce_timer = null
constructor(storage_path, settings=DEFAULT_SETTINGS, data=new Map(), serializer=DEFAULT_SERIALIZER_FACTORY()) {
const actual_settings = { ...DEFAULT_SETTINGS, ...settings };
Object.assign(this, { storage_path, data, serializer, ...actual_settings });
if (this.auto_load) {
this.load();
}
if (this.auto_store) {
for (const event of this.auto_store_events) {
process.on(event, () => (this.store(), process.exit()) );
}
}
}
trigger_flush_debouncer() {
if (this.#flush_debounce_timer) {
clearTimeout(this.#flush_debounce_timer);
this.#flush_debounce_timer = null;
}
const { debounce_flush_timeout } = this;
if (debounce_flush_timeout) {
this.#flush_debounce_timer = setTimeout(() => {
this.#flush_debounce_timer = null;
this.store();
}, debounce_flush_timeout);
}
}
create_pending_filename() {
const suffix = process.hrtime.bigint().toString(16);
const { storage_path } = this;
return `${storage_path}-${suffix}.tmp`;
}
get(key) {
const { serializer } = this;
const raw_entry = this.data.get(key);
if (raw_entry !== undefined) {
return serializer.deserialize(raw_entry);
}
}
set(key, value) {
const { serializer } = this;
this.data.set(key, serializer.serialize(value));
this.trigger_flush_debouncer();
}
delete(key) {
const result = this.data.delete(key);
if (result) {
this.trigger_flush_debouncer();
}
return result;
}
load() {
const { data, storage_path } = this;
if (!fs.existsSync(storage_path)) {
return;
}
const file_contents = fs.readFileSync(storage_path, 'utf-8');
for (const line of file_contents.split('\n')) {
if (!line) continue;
const [key, value] = JSON.parse(line);
data.set(key, value);
}
}
store() {
if (this.#flush_debounce_timer) {
clearTimeout(this.#flush_debounce_timer);
this.#flush_debounce_timer = null;
}
const { data, storage_path } = this;
const pending_out_path = this.create_pending_filename();
const out_fd = fs.openSync(pending_out_path, 'w');
try {
for (const [key, value] of data.entries()) {
fs.writeSync(out_fd, JSON.stringify([key, value]) + '\n');
}
fs.closeSync(out_fd);
fs.renameSync(pending_out_path, storage_path);
} finally {
try { fs.closeSync(out_fd); } catch {}
try { fs.unlinkSync(pending_out_path); } catch {}
}
}
}
/*
const kvs = new Simple_KeyValue_Store('./data.ndjson', { auto_load: true, auto_store: true, debounce_flush_timeout: 10_000 });
console.log(kvs.get('hello'), kvs.get('shello'), kvs.get('Path'));
kvs.set('hello', 123456789n)
console.log(Buffer.from([123, 10, 20]));
kvs.set('Path', Buffer.from([123, 10, 20]));
process.exit() //Exit immediately instead of waiting for debounce_timer (this also prevents double store - we should document this properly)
*/