This commit is contained in:
Rizky 2024-07-19 20:59:02 +07:00
parent c94c316cfa
commit 67a49305ac
4 changed files with 432 additions and 4 deletions

BIN
bun.lockb

Binary file not shown.

View File

@ -20,7 +20,6 @@
}, },
"dependencies": { "dependencies": {
"brotli-wasm": "^2.0.1", "brotli-wasm": "^2.0.1",
"bun-sqlite-key-value": "^1.4.5",
"exit-hook": "^4.0.0", "exit-hook": "^4.0.0",
"firebase-admin": "^12.2.0", "firebase-admin": "^12.2.0",
"prisma": "^5.17.0" "prisma": "^5.17.0"

View File

@ -3,10 +3,9 @@ import { Logger } from "pino";
import { RadixRouter } from "radix3"; import { RadixRouter } from "radix3";
import { PrismaClient } from "../../app/db/db"; import { PrismaClient } from "../../app/db/db";
import admin from "firebase-admin";
import { Database } from "bun:sqlite"; import { Database } from "bun:sqlite";
import { prodIndex } from "./prod-index"; import admin from "firebase-admin";
import { BunSqliteKeyValue } from "bun-sqlite-key-value"; import { BunSqliteKeyValue } from "./kv";
type SingleRoute = { type SingleRoute = {
url: string; url: string;

430
pkgs/utils/kv.ts Normal file
View File

@ -0,0 +1,430 @@
import { Database, type Statement } from "bun:sqlite";
import { serialize, deserialize } from "node:v8";
import { dirname, resolve } from "node:path";
import { existsSync, mkdirSync } from "node:fs";
export interface Item<T> {
key: string;
value: T | undefined;
}
interface Record {
key: string;
value: Buffer | null;
expires: number | null;
}
interface Options {
readonly?: boolean;
create?: boolean; // Defaults to true
readwrite?: boolean; // Defaults to true
ttlMs?: number; // Default TTL milliseconds
}
interface DbOptions extends Omit<Options, "ttlMs"> {
strict: boolean;
}
const MIN_UTF8_CHAR: string = String.fromCodePoint(1);
const MAX_UTF8_CHAR: string = String.fromCodePoint(1_114_111);
export class BunSqliteKeyValue {
db: Database;
ttlMs: number | undefined;
private deleteExpiredStatement: Statement;
private deleteStatement: Statement;
private clearStatement: Statement;
private countStatement: Statement<{ count: number }>;
private setItemStatement: Statement;
private getItemStatement: Statement<Omit<Record, "key">>;
private getAllItemsStatement: Statement<Record>;
private getItemsStartsWithStatement: Statement<Record>;
private getKeyStatement: Statement<Omit<Record, "key" | "value">>;
private getAllKeysStatement: Statement<Omit<Record, "value">>;
private getKeysStartsWithStatement: Statement<Omit<Record, "value">>;
private countExpiringStatement: Statement<{ count: number }>;
private deleteExpiringStatement: Statement;
// - `filename`: The full path of the SQLite database to open.
// Pass an empty string (`""`) or `":memory:"` or undefined for an in-memory database.
// - `options`:
// - ...
// - `ttlMs?: boolean`: Standard time period in milliseconds before
// an entry written to the DB becomes invalid.
constructor(filename?: string, options?: Options) {
// Parse options
const { ttlMs, ...otherOptions } = options ?? {};
this.ttlMs = ttlMs;
const dbOptions: DbOptions = {
...otherOptions,
strict: true,
readwrite: otherOptions?.readwrite ?? true,
create: otherOptions?.create ?? true,
};
// Create database directory
if (
filename?.length &&
filename.toLowerCase() !== ":memory:" &&
dbOptions.create
) {
const dbDir = dirname(resolve(filename));
if (!existsSync(dbDir)) {
console.log(`The "${dbDir}" folder is created.`);
mkdirSync(dbDir, { recursive: true });
}
}
// Open database
this.db = new Database(filename, dbOptions);
this.db.run("PRAGMA journal_mode = WAL");
// Create table and indexes
this.db.run(
"CREATE TABLE IF NOT EXISTS items (key TEXT PRIMARY KEY, value BLOB, expires INT)"
);
this.db.run(
"CREATE UNIQUE INDEX IF NOT EXISTS ix_items_key ON items (key)"
);
this.db.run(
"CREATE INDEX IF NOT EXISTS ix_items_expires ON items (expires)"
);
// Prepare and cache statements
this.clearStatement = this.db.query("DELETE FROM items");
this.deleteStatement = this.db.query("DELETE FROM items WHERE key = $key");
this.deleteExpiredStatement = this.db.query(
"DELETE FROM items WHERE expires < $now"
);
this.setItemStatement = this.db.query(
"INSERT OR REPLACE INTO items (key, value, expires) VALUES ($key, $value, $expires)"
);
this.countStatement = this.db.query("SELECT COUNT(*) AS count FROM items");
this.getAllItemsStatement = this.db.query(
"SELECT key, value, expires FROM items"
);
this.getItemStatement = this.db.query(
"SELECT value, expires FROM items WHERE key = $key"
);
this.getItemsStartsWithStatement = this.db.query(
"SELECT key, value, expires FROM items WHERE key = $key OR key >= $gte AND key < $lt"
);
this.getAllKeysStatement = this.db.query("SELECT key, expires FROM items");
this.getKeyStatement = this.db.query(
"SELECT expires FROM items WHERE key = $key"
);
this.getKeysStartsWithStatement = this.db.query(
"SELECT key, expires FROM items WHERE (key = $key OR key >= $gte AND key < $lt)"
);
this.countExpiringStatement = this.db.query(
"SELECT COUNT(*) as count FROM items WHERE expires IS NOT NULL"
);
this.deleteExpiringStatement = this.db.query(`
DELETE FROM items
WHERE key IN (
SELECT key FROM items
WHERE expires IS NOT NULL
ORDER BY expires ASC
LIMIT $limit
)`);
// Delete expired items
this.deleteExpired();
}
// Delete all expired records
deleteExpired() {
this.deleteExpiredStatement.run({ now: Date.now() });
}
// Delete one or multiple items
delete(keyOrKeys?: string | string[]) {
if (typeof keyOrKeys === "string") {
// Delete one
this.deleteStatement.run({ key: keyOrKeys });
} else if (keyOrKeys?.length) {
// Delete multiple items
this.db.transaction(() => {
keyOrKeys.forEach((key) => {
this.deleteStatement.run({ key });
});
})();
} else {
// Delete all
this.clearStatement.run();
}
}
// Delete all items
clear() {
this.delete();
}
// Explicitly close database
// Removes .sqlite-shm and .sqlite-wal files
close() {
this.db.close();
}
// Returns the number of all items, including those that have already expired.
// First delete the expired items with `deleteExpired()`
// if you want to get the number of items that have not yet expired.
getCount(): number {
return (this.countStatement.get() as { count: number }).count;
}
// Getter for getCount()
get length() {
return this.getCount();
}
// @param ttlMs:
// Time to live in milliseconds.
// Set ttlMs to 0 if you explicitly want to disable expiration.
set<T = any>(key: string, value: T, ttlMs?: number) {
let expires: number | undefined;
ttlMs = ttlMs ?? this.ttlMs;
if (ttlMs !== undefined && ttlMs > 0) {
expires = Date.now() + ttlMs;
}
this.setItemStatement.run({ key, value: serialize(value), expires });
}
// Alias for `set`
setValue = this.set;
put = this.set;
// Adds a large number of entries to the database and takes only
// a small fraction of the time that `set()` would take individually.
setItems<T = any>(items: { key: string; value: T; ttlMs?: number }[]) {
this.db.transaction(() => {
items.forEach(({ key, value, ttlMs }) => {
this.set<T>(key, value, ttlMs);
});
})();
}
// Get one value
get<T = any>(key: string): T | undefined {
const record = this.getItemStatement.get({ key });
if (!record) return;
const { value, expires } = record;
if (expires) {
if (expires < Date.now()) {
this.delete(key);
return;
}
}
return value ? (deserialize(value) as T) : undefined;
}
// Alias for `get`
getValue = this.get;
// Get one item (key, value)
getItem<T = any>(key: string): Item<T> | undefined {
return {
key,
value: this.get<T>(key),
};
}
// Get multiple items (key-value array)
getItems<T = any>(
startsWithOrKeys?: string | string[]
): Item<T>[] | undefined {
let records: Record[];
if (startsWithOrKeys && typeof startsWithOrKeys === "string") {
// Filtered items (startsWith)
// key = "addresses:"
// gte = key + MIN_UTF8_CHAR
// "addresses:aaa"
// "addresses:xxx"
// lt = key + MAX_UTF8_CHAR
const key: string = startsWithOrKeys;
const gte: string = key + MIN_UTF8_CHAR;
const lt: string = key + MAX_UTF8_CHAR;
records = this.getItemsStartsWithStatement.all({ key, gte, lt });
} else if (startsWithOrKeys) {
// Filtered items (array with keys)
records = this.db.transaction(() => {
return (startsWithOrKeys as string[]).map((key: string) => {
const record = this.getItemStatement.get({ key });
return { ...record, key };
});
})();
} else {
// All items
records = this.getAllItemsStatement.all();
}
if (!records.length) return;
const now = Date.now();
const result: Item<T>[] = [];
for (const record of records) {
const { key, value, expires } = record;
if (expires && expires < now) {
this.delete(key);
} else {
result.push({
key,
value: value ? (deserialize(value) as T) : undefined,
});
}
}
if (result.length) {
return result;
}
}
// Alias for getItems
getItemsArray = this.getItems;
// Get multiple values as array
getValues<T = any>(
startsWithOrKeys?: string | string[]
): (T | undefined)[] | undefined {
return this.getItems<T>(startsWithOrKeys)?.map((result) => result.value);
}
// Alias for getValues
getValuesArray = this.getValues;
// Get multiple items as object
getItemsObject<T = any>(
startsWithOrKeys?: string | string[]
): { [key: string]: T | undefined } | undefined {
const items = this.getItems(startsWithOrKeys);
if (!items) return;
return Object.fromEntries(
items.map((item) => [item.key, item.value as T | undefined])
);
}
// Get multiple items as Map()
getItemsMap<T = any>(
startsWithOrKeys?: string | string[]
): Map<string, T | undefined> | undefined {
const items = this.getItems(startsWithOrKeys);
if (!items) return;
return new Map(
items.map((item) => [item.key, item.value as T | undefined])
);
}
// Get multiple values as Set()
getValuesSet<T = any>(
startsWithOrKeys?: string | string[]
): Set<T> | undefined {
const values = this.getValues(startsWithOrKeys);
if (!values) return;
return new Set(values);
}
// Checks if key exists
has(key: string): boolean {
const record = this.getKeyStatement.get({ key });
if (!record) return false;
if (record.expires) {
if (record.expires < Date.now()) {
this.delete(key);
return false;
}
}
return true;
}
// Get multiple keys as array
getKeys(startsWithOrKeys?: string | string[]): string[] | undefined {
let records: (Omit<Record, "value"> | undefined)[];
if (startsWithOrKeys && typeof startsWithOrKeys === "string") {
const key: string = startsWithOrKeys;
const gte: string = key + MIN_UTF8_CHAR;
const lt: string = key + MAX_UTF8_CHAR;
records = this.getKeysStartsWithStatement.all({ key, gte, lt });
} else if (startsWithOrKeys) {
// Filtered items (array with keys)
records = this.db.transaction(() => {
return (startsWithOrKeys as string[]).map((key: string) => {
const record = this.getKeyStatement.get({ key });
return record ? { ...record, key } : undefined;
});
})();
} else {
// All items
records = this.getAllKeysStatement.all();
}
if (!records?.length) return;
const now = Date.now();
const result: string[] = [];
for (const record of records) {
if (!record) continue;
const { key, expires } = record;
if (expires && expires < now) {
this.delete(key);
} else {
result.push(key);
}
}
if (result.length) {
return result;
}
}
getExpiringItemsCount() {
return this.countExpiringStatement.get()!.count;
}
// If there are more expiring items in the database than `maxExpiringItemsInDb`,
// the oldest items are deleted until there are only `maxExpiringItemsInDb` items with
// an expiration date in the database.
deleteOldExpiringItems(maxExpiringItemsInDb: number) {
const count = this.getExpiringItemsCount();
if (count <= maxExpiringItemsInDb) return;
const limit = count - maxExpiringItemsInDb;
this.deleteExpiringStatement.run({ limit });
}
// Alias for deleteOldExpiringItems
deleteOldestExpiringItems = this.deleteOldExpiringItems;
// Proxy
getDataObject(): { [key: string]: any } {
const self = this;
return new Proxy(
{},
{
get(target, property: string, receiver) {
if (property === "length") {
return self.length;
} else {
return self.get(property);
}
},
set(target, property: string, value: any) {
self.set(property, value);
return true;
},
has(target, property: string) {
return self.has(property);
},
deleteProperty(target: {}, property: string) {
self.delete(property);
return true;
},
}
);
}
get dataObject() {
return this.getDataObject();
}
}