init
This commit is contained in:
330
utils/kvdb.js
Normal file
330
utils/kvdb.js
Normal file
@@ -0,0 +1,330 @@
|
||||
/*
|
||||
vibecoded :sob:
|
||||
*/
|
||||
|
||||
import { Database } from "bun:sqlite";
|
||||
|
||||
export class KVDB {
|
||||
/**
|
||||
* @param {string} [filename="db.sqlite"] - The SQLite database file (e.g., "db.sqlite" or ":memory:")
|
||||
* @param {string} [table="main_kv"] - The table/context name (defaults to "main_kv")
|
||||
*/
|
||||
constructor(filename = "db.sqlite", table = "main_kv") {
|
||||
this.db = new Database(filename);
|
||||
this.db.run("PRAGMA journal_mode = WAL;");
|
||||
|
||||
// Sanitize table name to prevent SQL injection in DDL
|
||||
this.tableName = table.replace(/[^a-zA-Z0-9_]/g, "");
|
||||
|
||||
this.db.run(`
|
||||
CREATE TABLE IF NOT EXISTS "${this.tableName}" (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT
|
||||
)
|
||||
`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch database table/context without recreating database connections
|
||||
* @param {string} newTableName
|
||||
* @returns {KVDB}
|
||||
*/
|
||||
table(newTableName) {
|
||||
return new KVDB(this.db.filename, newTableName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a key or nested dot-notation property.
|
||||
* Examples:
|
||||
* await db.set("user", { name: "Alex" })
|
||||
* await db.set("user.age", 30)
|
||||
* @param {string} keyPath
|
||||
* @param {any} value
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async set(keyPath, value) {
|
||||
const parts = keyPath.split(".");
|
||||
const rootKey = parts[0];
|
||||
|
||||
if (parts.length === 1) {
|
||||
this.db.run(
|
||||
`INSERT INTO "${this.tableName}" (key, value) VALUES (?, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value`,
|
||||
[rootKey, JSON.stringify(value)]
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const jsonPath = `$.${parts.slice(1).join(".")}`;
|
||||
|
||||
this.db.run(
|
||||
`INSERT INTO "${this.tableName}" (key, value) VALUES (?, '{}')
|
||||
ON CONFLICT(key) DO NOTHING`,
|
||||
[rootKey]
|
||||
);
|
||||
|
||||
this.db.run(
|
||||
`UPDATE "${this.tableName}"
|
||||
SET value = json_set(value, ?, json(?))
|
||||
WHERE key = ?`,
|
||||
[jsonPath, JSON.stringify(value), rootKey]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a key/nested property, OR find an element inside an array using a predicate function.
|
||||
* Examples:
|
||||
* await db.get("user.roles") -> returns whole array
|
||||
* await db.get("user.roles", (r) => r.id === "admin") -> returns matching role object
|
||||
* @param {string} keyPath
|
||||
* @param {Function} [predicate] - Optional matching function for array items
|
||||
* @returns {Promise<any>}
|
||||
*/
|
||||
async get(keyPath, predicate) {
|
||||
const parts = keyPath.split(".");
|
||||
const rootKey = parts[0];
|
||||
let data = null;
|
||||
|
||||
if (parts.length === 1) {
|
||||
const row = this.db
|
||||
.query(`SELECT value FROM "${this.tableName}" WHERE key = ?`)
|
||||
.get(rootKey);
|
||||
data = row ? JSON.parse(row.value) : null;
|
||||
} else {
|
||||
const jsonPath = `$.${parts.slice(1).join(".")}`;
|
||||
const row = this.db
|
||||
.query(
|
||||
`SELECT json_extract(value, ?) AS result FROM "${this.tableName}" WHERE key = ?`
|
||||
)
|
||||
.get(jsonPath, rootKey);
|
||||
|
||||
if (row && row.result !== null) {
|
||||
if (typeof row.result === "string") {
|
||||
try {
|
||||
data = JSON.parse(row.result);
|
||||
} catch {
|
||||
data = row.result;
|
||||
}
|
||||
} else {
|
||||
data = row.result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof predicate === "function") {
|
||||
if (!Array.isArray(data)) return null;
|
||||
return data.find(predicate) ?? null;
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a key/property exists, OR check if an array contains a specific item / predicate match.
|
||||
* Examples:
|
||||
* await db.has("user.roles") -> true/false
|
||||
* await db.has("user.roles", (r) => r.id === "admin") -> true/false
|
||||
* await db.has("user.roles", "admin_role_id") -> true/false
|
||||
* @param {string} keyPath
|
||||
* @param {any | Function} [itemOrPredicate]
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
async has(keyPath, itemOrPredicate) {
|
||||
if (itemOrPredicate !== undefined) {
|
||||
const arr = await this.get(keyPath);
|
||||
if (!Array.isArray(arr)) return false;
|
||||
|
||||
if (typeof itemOrPredicate === "function") {
|
||||
return arr.some(itemOrPredicate);
|
||||
}
|
||||
|
||||
if (typeof itemOrPredicate === "object" && itemOrPredicate !== null) {
|
||||
const targetString = JSON.stringify(itemOrPredicate);
|
||||
return arr.some((el) => JSON.stringify(el) === targetString);
|
||||
}
|
||||
|
||||
return arr.includes(itemOrPredicate);
|
||||
}
|
||||
|
||||
const parts = keyPath.split(".");
|
||||
const rootKey = parts[0];
|
||||
|
||||
if (parts.length === 1) {
|
||||
const row = this.db
|
||||
.query(`SELECT 1 FROM "${this.tableName}" WHERE key = ?`)
|
||||
.get(rootKey);
|
||||
return Boolean(row);
|
||||
}
|
||||
|
||||
const jsonPath = `$.${parts.slice(1).join(".")}`;
|
||||
const row = this.db
|
||||
.query(
|
||||
`SELECT json_type(value, ?) AS type FROM "${this.tableName}" WHERE key = ?`
|
||||
)
|
||||
.get(jsonPath, rootKey);
|
||||
|
||||
return Boolean(row && row.type !== null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a key, property, OR remove item(s) from an array using a predicate or value.
|
||||
* Examples:
|
||||
* await db.delete("user.roles") -> deletes the roles key/property entirely
|
||||
* await db.delete("user.roles", (r) => r.id === "admin") -> pulls matching item from array
|
||||
* @param {string} keyPath
|
||||
* @param {any | Function} [itemOrPredicate]
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async delete(keyPath, itemOrPredicate) {
|
||||
if (itemOrPredicate !== undefined) {
|
||||
const arr = await this.get(keyPath);
|
||||
if (!Array.isArray(arr)) return;
|
||||
|
||||
let filtered;
|
||||
if (typeof itemOrPredicate === "function") {
|
||||
filtered = arr.filter((item) => !itemOrPredicate(item));
|
||||
} else if (typeof itemOrPredicate === "object" && itemOrPredicate !== null) {
|
||||
const targetString = JSON.stringify(itemOrPredicate);
|
||||
filtered = arr.filter((item) => JSON.stringify(item) !== targetString);
|
||||
} else {
|
||||
filtered = arr.filter((item) => item !== itemOrPredicate);
|
||||
}
|
||||
|
||||
await this.set(keyPath, filtered);
|
||||
return;
|
||||
}
|
||||
|
||||
const parts = keyPath.split(".");
|
||||
const rootKey = parts[0];
|
||||
|
||||
if (parts.length === 1) {
|
||||
this.db.run(`DELETE FROM "${this.tableName}" WHERE key = ?`, [rootKey]);
|
||||
return;
|
||||
}
|
||||
|
||||
const jsonPath = `$.${parts.slice(1).join(".")}`;
|
||||
this.db.run(
|
||||
`UPDATE "${this.tableName}"
|
||||
SET value = json_remove(value, ?)
|
||||
WHERE key = ?`,
|
||||
[jsonPath, rootKey]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Appends an element to an array at the target key or nested path.
|
||||
* @param {string} keyPath
|
||||
* @param {any} value
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async push(keyPath, value) {
|
||||
const parts = keyPath.split(".");
|
||||
const rootKey = parts[0];
|
||||
const jsonPath = parts.length === 1 ? "$" : `$.${parts.slice(1).join(".")}`;
|
||||
|
||||
if (parts.length === 1) {
|
||||
this.db.run(
|
||||
`INSERT INTO "${this.tableName}" (key, value) VALUES (?, '[]')
|
||||
ON CONFLICT(key) DO NOTHING`,
|
||||
[rootKey]
|
||||
);
|
||||
} else {
|
||||
this.db.run(
|
||||
`INSERT INTO "${this.tableName}" (key, value) VALUES (?, '{}')
|
||||
ON CONFLICT(key) DO NOTHING`,
|
||||
[rootKey]
|
||||
);
|
||||
this.db.run(
|
||||
`UPDATE "${this.tableName}"
|
||||
SET value = json_set(value, ?, json('[]'))
|
||||
WHERE key = ? AND json_type(value, ?) IS NULL`,
|
||||
[jsonPath, rootKey, jsonPath]
|
||||
);
|
||||
}
|
||||
|
||||
const appendPath = `${jsonPath}[#]`;
|
||||
this.db.run(
|
||||
`UPDATE "${this.tableName}"
|
||||
SET value = json_insert(value, ?, json(?))
|
||||
WHERE key = ?`,
|
||||
[appendPath, JSON.stringify(value), rootKey]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates matching item(s) in an array using a predicate and an update callback/object.
|
||||
* @param {string} keyPath
|
||||
* @param {Function} predicate
|
||||
* @param {Object | Function} updateFnOrObject
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
async update(keyPath, predicate, updateFnOrObject) {
|
||||
const arr = await this.get(keyPath);
|
||||
|
||||
if (!Array.isArray(arr)) return false;
|
||||
|
||||
let updatedAny = false;
|
||||
|
||||
const newArr = arr.map((item) => {
|
||||
if (!predicate(item)) return item;
|
||||
|
||||
updatedAny = true;
|
||||
|
||||
if (typeof updateFnOrObject === "function") {
|
||||
return updateFnOrObject(item);
|
||||
}
|
||||
|
||||
if (typeof item === "object" && item !== null && typeof updateFnOrObject === "object") {
|
||||
return { ...item, ...updateFnOrObject };
|
||||
}
|
||||
|
||||
return updateFnOrObject;
|
||||
});
|
||||
|
||||
if (updatedAny) {
|
||||
await this.set(keyPath, newArr);
|
||||
}
|
||||
|
||||
return updatedAny;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renames a key or nested property path without changing its value.
|
||||
* Examples:
|
||||
* await db.rename("user.roles", "user.rolesOld")
|
||||
* await db.rename("user.roles", "rolesOld")
|
||||
* await db.rename("user", "userOld")
|
||||
* @param {string} oldKeyPath
|
||||
* @param {string} newKeyPath
|
||||
* @returns {Promise<boolean>} True if target key existed and was renamed
|
||||
*/
|
||||
async rename(oldKeyPath, newKeyPath) {
|
||||
if (!(await this.has(oldKeyPath))) return false;
|
||||
|
||||
const oldParts = oldKeyPath.split(".");
|
||||
let targetNewPath = newKeyPath;
|
||||
|
||||
if (oldParts.length > 1 && !newKeyPath.startsWith(oldParts.slice(0, -1).join(".") + ".")) {
|
||||
const parentPath = oldParts.slice(0, -1).join(".");
|
||||
targetNewPath = `${parentPath}.${newKeyPath}`;
|
||||
}
|
||||
|
||||
const value = await this.get(oldKeyPath);
|
||||
|
||||
const oldRoot = oldParts[0];
|
||||
const newParts = targetNewPath.split(".");
|
||||
const newRoot = newParts[0];
|
||||
|
||||
if (oldParts.length === 1 && newParts.length === 1) {
|
||||
this.db.run(
|
||||
`UPDATE "${this.tableName}" SET key = ? WHERE key = ?`,
|
||||
[newRoot, oldRoot]
|
||||
);
|
||||
} else {
|
||||
await this.set(targetNewPath, value);
|
||||
await this.delete(oldKeyPath);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user