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>
This commit is contained in:
3
lib/ids.mjs
Normal file
3
lib/ids.mjs
Normal file
@@ -0,0 +1,3 @@
|
||||
export function generate_id() {
|
||||
return Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
|
||||
}
|
||||
201
lib/kv-store.mjs
Normal file
201
lib/kv-store.mjs
Normal file
@@ -0,0 +1,201 @@
|
||||
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)
|
||||
*/
|
||||
82
lib/storage.mjs
Normal file
82
lib/storage.mjs
Normal file
@@ -0,0 +1,82 @@
|
||||
import { mkdirSync } from 'node:fs';
|
||||
import { Simple_KeyValue_Store } from './kv-store.mjs';
|
||||
|
||||
mkdirSync('./data', { recursive: true });
|
||||
|
||||
const store = new Simple_KeyValue_Store('./data/inventory.ndjson', {
|
||||
auto_load: true,
|
||||
auto_store: true,
|
||||
debounce_flush_timeout: 5000,
|
||||
});
|
||||
|
||||
// --- Field definitions ---
|
||||
|
||||
export function list_fields() {
|
||||
const result = [];
|
||||
for (const [key] of store.data.entries()) {
|
||||
if (key.startsWith('f:')) {
|
||||
result.push(store.get(key));
|
||||
}
|
||||
}
|
||||
return result.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
export function get_field(id) {
|
||||
return store.get(`f:${id}`) ?? null;
|
||||
}
|
||||
|
||||
export function set_field(field) {
|
||||
store.set(`f:${field.id}`, field);
|
||||
}
|
||||
|
||||
export function delete_field(id) {
|
||||
return store.delete(`f:${id}`);
|
||||
}
|
||||
|
||||
// --- Components ---
|
||||
|
||||
export function list_components() {
|
||||
const result = [];
|
||||
for (const [key] of store.data.entries()) {
|
||||
if (key.startsWith('c:')) {
|
||||
result.push(store.get(key));
|
||||
}
|
||||
}
|
||||
return result.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
export function get_component(id) {
|
||||
return store.get(`c:${id}`) ?? null;
|
||||
}
|
||||
|
||||
export function set_component(component) {
|
||||
store.set(`c:${component.id}`, component);
|
||||
}
|
||||
|
||||
export function delete_component(id) {
|
||||
return store.delete(`c:${id}`);
|
||||
}
|
||||
|
||||
// --- Inventory entries ---
|
||||
|
||||
export function list_inventory() {
|
||||
const result = [];
|
||||
for (const [key] of store.data.entries()) {
|
||||
if (key.startsWith('i:')) {
|
||||
result.push(store.get(key));
|
||||
}
|
||||
}
|
||||
return result.sort((a, b) => a.created_at - b.created_at);
|
||||
}
|
||||
|
||||
export function get_inventory_entry(id) {
|
||||
return store.get(`i:${id}`) ?? null;
|
||||
}
|
||||
|
||||
export function set_inventory_entry(entry) {
|
||||
store.set(`i:${entry.id}`, entry);
|
||||
}
|
||||
|
||||
export function delete_inventory_entry(id) {
|
||||
return store.delete(`i:${id}`);
|
||||
}
|
||||
Reference in New Issue
Block a user