@holo-js/cache-db 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,59 @@
1
+ import { CacheDriverContract } from '@holo-js/cache';
2
+ import { HoloProjectConnectionConfig, DatabaseContext, DatabaseContextOptions, SupportedDatabaseDriver } from '@holo-js/db';
3
+
4
+ declare const DEFAULT_CACHE_DATABASE_TABLE = "cache";
5
+ declare const DEFAULT_CACHE_DATABASE_LOCK_TABLE = "cache_locks";
6
+ type DatabaseCacheDriverOptions = {
7
+ readonly name: string;
8
+ readonly connectionName: string;
9
+ readonly table: string;
10
+ readonly lockTable: string;
11
+ readonly prefix?: string;
12
+ readonly connection: HoloProjectConnectionConfig | string;
13
+ readonly now?: () => number;
14
+ readonly sleep?: (milliseconds: number) => Promise<void>;
15
+ readonly ownerFactory?: () => string;
16
+ };
17
+ type DatabaseCacheEntryRow = {
18
+ readonly key: string;
19
+ readonly payload: string;
20
+ readonly expires_at: number | null;
21
+ };
22
+ type DatabaseCacheLockRow = {
23
+ readonly name: string;
24
+ readonly owner: string;
25
+ readonly expires_at: number;
26
+ };
27
+ type DatabaseReadResult<TValue> = {
28
+ readonly state: 'missing';
29
+ } | {
30
+ readonly state: 'hit';
31
+ readonly value: TValue;
32
+ };
33
+ declare function defaultSleep(milliseconds: number): Promise<void>;
34
+ declare function resolveDriver(connection: HoloProjectConnectionConfig | string): SupportedDatabaseDriver;
35
+ declare function createDatabaseContextOptions(connectionName: string, connection: HoloProjectConnectionConfig | string): DatabaseContextOptions;
36
+ declare function createDatabaseConnection(connectionName: string, connection: HoloProjectConnectionConfig | string): DatabaseContext;
37
+ declare function prepareCacheDatabaseTables(connection: DatabaseContext, tableName?: string, lockTableName?: string): Promise<void>;
38
+ declare function renderCacheTableMigration(tableName?: string, lockTableName?: string): string;
39
+ declare function isDatabaseCacheTableMissingError(error: unknown, tableName: string, lockTableName: string): boolean;
40
+ declare function normalizeDatabaseCacheTableError(error: unknown, tableName: string, lockTableName: string): never;
41
+ declare function withDatabaseCacheTableGuard<TValue>(tableName: string, lockTableName: string, callback: () => Promise<TValue>): Promise<TValue>;
42
+ declare function readEntry(connection: DatabaseContext, tableName: string, key: string, now: number): Promise<DatabaseReadResult<DatabaseCacheEntryRow>>;
43
+ declare function readLock(connection: DatabaseContext, tableName: string, name: string, now: number): Promise<DatabaseReadResult<DatabaseCacheLockRow>>;
44
+ declare function createDatabaseCacheDriver(options: DatabaseCacheDriverOptions): CacheDriverContract;
45
+ declare const cacheDbInternals: {
46
+ createDatabaseConnection: typeof createDatabaseConnection;
47
+ createDatabaseContextOptions: typeof createDatabaseContextOptions;
48
+ defaultSleep: typeof defaultSleep;
49
+ isDatabaseCacheTableMissingError: typeof isDatabaseCacheTableMissingError;
50
+ normalizeDatabaseCacheTableError: typeof normalizeDatabaseCacheTableError;
51
+ prepareCacheDatabaseTables: typeof prepareCacheDatabaseTables;
52
+ readEntry: typeof readEntry;
53
+ readLock: typeof readLock;
54
+ renderCacheTableMigration: typeof renderCacheTableMigration;
55
+ resolveDriver: typeof resolveDriver;
56
+ withDatabaseCacheTableGuard: typeof withDatabaseCacheTableGuard;
57
+ };
58
+
59
+ export { DEFAULT_CACHE_DATABASE_LOCK_TABLE, DEFAULT_CACHE_DATABASE_TABLE, type DatabaseCacheDriverOptions, cacheDbInternals, createDatabaseCacheDriver };
package/dist/index.mjs ADDED
@@ -0,0 +1,378 @@
1
+ // src/index.ts
2
+ import { randomUUID } from "crypto";
3
+ import {
4
+ CacheConfigError,
5
+ CacheInvalidNumericMutationError,
6
+ CacheLockAcquisitionError,
7
+ deserializeCacheValue,
8
+ serializeCacheValue
9
+ } from "@holo-js/cache";
10
+ import {
11
+ createConnectionManager,
12
+ createSchemaService,
13
+ createRuntimeConnectionOptions,
14
+ TableQueryBuilder
15
+ } from "@holo-js/db";
16
+ var DEFAULT_CACHE_DATABASE_TABLE = "cache";
17
+ var DEFAULT_CACHE_DATABASE_LOCK_TABLE = "cache_locks";
18
+ function defaultSleep(milliseconds) {
19
+ return new Promise((resolveDelay) => {
20
+ setTimeout(resolveDelay, milliseconds);
21
+ });
22
+ }
23
+ function resolveDriver(connection) {
24
+ if (typeof connection === "string") {
25
+ if (connection === ":memory:" || connection.startsWith("file:") || connection.startsWith("/") || connection.startsWith("./") || connection.startsWith("../") || connection.endsWith(".db") || connection.endsWith(".sqlite") || connection.endsWith(".sqlite3")) {
26
+ return "sqlite";
27
+ }
28
+ if (connection.startsWith("postgres://") || connection.startsWith("postgresql://")) {
29
+ return "postgres";
30
+ }
31
+ if (connection.startsWith("mysql://") || connection.startsWith("mysql2://")) {
32
+ return "mysql";
33
+ }
34
+ return "sqlite";
35
+ }
36
+ return connection.driver ?? (connection.url ? resolveDriver(connection.url) : "sqlite");
37
+ }
38
+ function createDatabaseContextOptions(connectionName, connection) {
39
+ const driver = resolveDriver(connection);
40
+ if (driver === "sqlite") {
41
+ const url = typeof connection === "string" ? connection : connection.url ?? connection.filename ?? ":memory:";
42
+ return createRuntimeConnectionOptions(driver, url, Boolean(typeof connection === "string" ? false : connection.logging), void 0, connectionName);
43
+ }
44
+ const parsedPort = typeof connection === "string" ? void 0 : typeof connection.port === "number" ? connection.port : typeof connection.port === "string" && /^\d+$/.test(connection.port.trim()) ? Number(connection.port.trim()) : void 0;
45
+ const port = typeof parsedPort === "number" && Number.isInteger(parsedPort) && parsedPort > 0 && parsedPort <= 65535 ? parsedPort : void 0;
46
+ return createRuntimeConnectionOptions(
47
+ driver,
48
+ typeof connection === "string" ? { url: connection } : {
49
+ url: connection.url,
50
+ host: connection.host,
51
+ port,
52
+ username: connection.username,
53
+ password: connection.password,
54
+ database: connection.database,
55
+ ssl: connection.ssl
56
+ },
57
+ Boolean(typeof connection === "string" ? false : connection.logging),
58
+ typeof connection === "string" ? void 0 : connection.schema,
59
+ connectionName
60
+ );
61
+ }
62
+ function createDatabaseConnection(connectionName, connection) {
63
+ return createConnectionManager({
64
+ defaultConnection: connectionName,
65
+ connections: {
66
+ [connectionName]: createDatabaseContextOptions(connectionName, connection)
67
+ }
68
+ }).connection(connectionName);
69
+ }
70
+ async function prepareCacheDatabaseTables(connection, tableName = DEFAULT_CACHE_DATABASE_TABLE, lockTableName = DEFAULT_CACHE_DATABASE_LOCK_TABLE) {
71
+ const schema = createSchemaService(connection);
72
+ await connection.initialize();
73
+ if (!await schema.hasTable(tableName)) {
74
+ await schema.createTable(tableName, (table) => {
75
+ table.string("key").primaryKey();
76
+ table.text("payload");
77
+ table.bigInteger("expires_at").nullable();
78
+ table.index(["expires_at"], `${tableName.replaceAll(".", "_")}_expires_at_index`);
79
+ });
80
+ }
81
+ if (!await schema.hasTable(lockTableName)) {
82
+ await schema.createTable(lockTableName, (table) => {
83
+ table.string("name").primaryKey();
84
+ table.string("owner");
85
+ table.bigInteger("expires_at");
86
+ table.index(["expires_at"], `${lockTableName.replaceAll(".", "_")}_expires_at_index`);
87
+ });
88
+ }
89
+ }
90
+ function renderCacheTableMigration(tableName = DEFAULT_CACHE_DATABASE_TABLE, lockTableName = DEFAULT_CACHE_DATABASE_LOCK_TABLE) {
91
+ return [
92
+ "import { defineMigration, type MigrationContext } from '@holo-js/db'",
93
+ "",
94
+ "export default defineMigration({",
95
+ " async up({ schema }: MigrationContext) {",
96
+ ` await schema.createTable('${tableName}', (table) => {`,
97
+ " table.string('key').primaryKey()",
98
+ " table.text('payload')",
99
+ " table.bigInteger('expires_at').nullable()",
100
+ ` table.index(['expires_at'], '${tableName.replaceAll(".", "_")}_expires_at_index')`,
101
+ " })",
102
+ ` await schema.createTable('${lockTableName}', (table) => {`,
103
+ " table.string('name').primaryKey()",
104
+ " table.string('owner')",
105
+ " table.bigInteger('expires_at')",
106
+ ` table.index(['expires_at'], '${lockTableName.replaceAll(".", "_")}_expires_at_index')`,
107
+ " })",
108
+ " },",
109
+ " async down({ schema }: MigrationContext) {",
110
+ ` await schema.dropTable('${lockTableName}')`,
111
+ ` await schema.dropTable('${tableName}')`,
112
+ " },",
113
+ "})",
114
+ ""
115
+ ].join("\n");
116
+ }
117
+ function resolveExecutionResultAffectedRows(result) {
118
+ return result.affectedRows ?? 0;
119
+ }
120
+ function isDatabaseCacheTableMissingError(error, tableName, lockTableName) {
121
+ if (!(error instanceof Error)) {
122
+ return false;
123
+ }
124
+ const message = error.message.toLowerCase();
125
+ const errorCode = typeof Reflect.get(error, "code") === "string" ? String(Reflect.get(error, "code")).toUpperCase() : void 0;
126
+ const mentionsCacheTable = message.includes(tableName.toLowerCase()) || message.includes(lockTableName.toLowerCase());
127
+ if (errorCode === "SQLITE_ERROR" || errorCode === "ER_NO_SUCH_TABLE" || errorCode === "42P01") {
128
+ return mentionsCacheTable || message.includes("no such table") || message.includes("does not exist");
129
+ }
130
+ return (message.includes("no such table") || message.includes("does not exist") || message.includes("doesn't exist")) && mentionsCacheTable;
131
+ }
132
+ function normalizeDatabaseCacheTableError(error, tableName, lockTableName) {
133
+ if (isDatabaseCacheTableMissingError(error, tableName, lockTableName)) {
134
+ throw new CacheConfigError(
135
+ `[@holo-js/cache] Database cache tables "${tableName}" and "${lockTableName}" are missing. Run "holo cache:table" and then "holo migrate".`,
136
+ { cause: error }
137
+ );
138
+ }
139
+ throw error;
140
+ }
141
+ async function withDatabaseCacheTableGuard(tableName, lockTableName, callback) {
142
+ try {
143
+ return await callback();
144
+ } catch (error) {
145
+ normalizeDatabaseCacheTableError(error, tableName, lockTableName);
146
+ }
147
+ }
148
+ async function readEntry(connection, tableName, key, now) {
149
+ const row = await new TableQueryBuilder(tableName, connection).where("key", key).first();
150
+ if (!row) {
151
+ return { state: "missing" };
152
+ }
153
+ if (typeof row.expires_at === "number" && row.expires_at <= now) {
154
+ await new TableQueryBuilder(tableName, connection).where("key", key).delete();
155
+ return { state: "missing" };
156
+ }
157
+ return {
158
+ state: "hit",
159
+ value: row
160
+ };
161
+ }
162
+ async function readLock(connection, tableName, name, now) {
163
+ const row = await new TableQueryBuilder(tableName, connection).where("name", name).first();
164
+ if (!row) {
165
+ return { state: "missing" };
166
+ }
167
+ if (row.expires_at <= now) {
168
+ await new TableQueryBuilder(tableName, connection).where("name", name).delete();
169
+ return { state: "missing" };
170
+ }
171
+ return {
172
+ state: "hit",
173
+ value: row
174
+ };
175
+ }
176
+ function createDatabaseLock(connection, tableName, entryTableName, name, seconds, now, sleep, ownerFactory) {
177
+ const owner = ownerFactory();
178
+ async function tryAcquire() {
179
+ return withDatabaseCacheTableGuard(entryTableName, tableName, async () => connection.transaction(async (tx) => {
180
+ const locks = new TableQueryBuilder(tableName, tx);
181
+ const existing = await readLock(tx, tableName, name, now());
182
+ if (existing.state === "hit") {
183
+ return false;
184
+ }
185
+ const inserted = await locks.insertOrIgnore({
186
+ name,
187
+ owner,
188
+ expires_at: now() + seconds * 1e3
189
+ });
190
+ return resolveExecutionResultAffectedRows(inserted) > 0;
191
+ }));
192
+ }
193
+ async function withCallback(callback) {
194
+ if (!callback) {
195
+ return true;
196
+ }
197
+ try {
198
+ return await callback();
199
+ } finally {
200
+ await lock.release();
201
+ }
202
+ }
203
+ const lock = {
204
+ name,
205
+ async get(callback) {
206
+ if (!await tryAcquire()) {
207
+ return false;
208
+ }
209
+ return withCallback(callback);
210
+ },
211
+ async release() {
212
+ const deleted = await withDatabaseCacheTableGuard(entryTableName, tableName, async () => connection.transaction(async (tx) => {
213
+ const current = await readLock(tx, tableName, name, now());
214
+ if (current.state === "missing" || current.value.owner !== owner) {
215
+ return 0;
216
+ }
217
+ return resolveExecutionResultAffectedRows(
218
+ await new TableQueryBuilder(tableName, tx).where("name", name).delete()
219
+ );
220
+ }));
221
+ return deleted > 0;
222
+ },
223
+ async block(waitSeconds, callback) {
224
+ const deadline = now() + waitSeconds * 1e3;
225
+ while (true) {
226
+ if (await tryAcquire()) {
227
+ return withCallback(callback);
228
+ }
229
+ if (now() >= deadline) {
230
+ return false;
231
+ }
232
+ await sleep(10);
233
+ }
234
+ }
235
+ };
236
+ return lock;
237
+ }
238
+ function createDatabaseCacheDriver(options) {
239
+ const now = options.now ?? Date.now;
240
+ const sleep = options.sleep ?? defaultSleep;
241
+ const ownerFactory = options.ownerFactory ?? randomUUID;
242
+ const connection = createDatabaseConnection(options.connectionName, options.connection);
243
+ const entryTableName = options.table;
244
+ const lockTableName = options.lockTable;
245
+ const prefix = options.prefix ?? "";
246
+ async function writeEntry(tx, input) {
247
+ await new TableQueryBuilder(entryTableName, tx).upsert({
248
+ key: input.key,
249
+ payload: input.payload,
250
+ expires_at: input.expiresAt ?? null
251
+ }, ["key"], ["payload", "expires_at"]);
252
+ return true;
253
+ }
254
+ async function mutateNumericValue(key, amount) {
255
+ const result = await createDatabaseLock(
256
+ connection,
257
+ lockTableName,
258
+ entryTableName,
259
+ `__numeric__:${key}`,
260
+ 1,
261
+ now,
262
+ sleep,
263
+ ownerFactory
264
+ ).block(1, async () => {
265
+ return connection.transaction(async (tx) => {
266
+ const entry = await readEntry(tx, entryTableName, key, now());
267
+ const currentValue = entry.state === "hit" ? deserializeCacheValue(entry.value.payload) : 0;
268
+ if (typeof currentValue !== "number" || !Number.isFinite(currentValue)) {
269
+ throw new CacheInvalidNumericMutationError(`[@holo-js/cache] Cache key "${key}" does not contain a numeric value.`);
270
+ }
271
+ const nextValue = currentValue + amount;
272
+ await writeEntry(tx, {
273
+ key,
274
+ payload: serializeCacheValue(nextValue),
275
+ expiresAt: entry.state === "hit" ? entry.value.expires_at ?? void 0 : void 0
276
+ });
277
+ return nextValue;
278
+ });
279
+ });
280
+ if (result === false) {
281
+ throw new CacheLockAcquisitionError(`[@holo-js/cache] Could not acquire database cache mutation lock for "${key}".`);
282
+ }
283
+ if (result === true) {
284
+ throw new CacheLockAcquisitionError(`[@holo-js/cache] Database cache mutation lock for "${key}" returned no numeric result.`);
285
+ }
286
+ return result;
287
+ }
288
+ return {
289
+ name: options.name,
290
+ driver: "database",
291
+ async get(key) {
292
+ const entry = await withDatabaseCacheTableGuard(entryTableName, lockTableName, async () => {
293
+ return readEntry(connection, entryTableName, key, now());
294
+ });
295
+ if (entry.state === "missing") {
296
+ return Object.freeze({ hit: false });
297
+ }
298
+ return Object.freeze({
299
+ hit: true,
300
+ payload: entry.value.payload,
301
+ expiresAt: entry.value.expires_at ?? void 0
302
+ });
303
+ },
304
+ async put(input) {
305
+ return withDatabaseCacheTableGuard(entryTableName, lockTableName, async () => connection.transaction(async (tx) => {
306
+ return writeEntry(tx, input);
307
+ }));
308
+ },
309
+ async add(input) {
310
+ return withDatabaseCacheTableGuard(entryTableName, lockTableName, async () => connection.transaction(async (tx) => {
311
+ const existing = await readEntry(tx, entryTableName, input.key, now());
312
+ if (existing.state === "hit") {
313
+ return false;
314
+ }
315
+ const inserted = await new TableQueryBuilder(entryTableName, tx).insertOrIgnore({
316
+ key: input.key,
317
+ payload: input.payload,
318
+ expires_at: input.expiresAt ?? null
319
+ });
320
+ return resolveExecutionResultAffectedRows(inserted) > 0;
321
+ }));
322
+ },
323
+ async forget(key) {
324
+ return withDatabaseCacheTableGuard(entryTableName, lockTableName, async () => {
325
+ return resolveExecutionResultAffectedRows(
326
+ await new TableQueryBuilder(entryTableName, connection).where("key", key).delete()
327
+ ) > 0;
328
+ });
329
+ },
330
+ async flush() {
331
+ await withDatabaseCacheTableGuard(entryTableName, lockTableName, async () => connection.transaction(async (tx) => {
332
+ if (!prefix) {
333
+ await new TableQueryBuilder(lockTableName, tx).delete();
334
+ await new TableQueryBuilder(entryTableName, tx).delete();
335
+ return;
336
+ }
337
+ const hasSqlWildcard = /[%_[]/.test(prefix);
338
+ if (hasSqlWildcard) {
339
+ const upperBound = `${prefix}\uFFFF`;
340
+ await new TableQueryBuilder(lockTableName, tx).where("name", ">=", prefix).where("name", "<", upperBound).delete();
341
+ await new TableQueryBuilder(entryTableName, tx).where("key", ">=", prefix).where("key", "<", upperBound).delete();
342
+ return;
343
+ }
344
+ const likePattern = `${prefix}%`;
345
+ await new TableQueryBuilder(lockTableName, tx).whereLike("name", likePattern).delete();
346
+ await new TableQueryBuilder(entryTableName, tx).whereLike("key", likePattern).delete();
347
+ }));
348
+ },
349
+ async increment(key, amount) {
350
+ return mutateNumericValue(key, amount);
351
+ },
352
+ async decrement(key, amount) {
353
+ return mutateNumericValue(key, -amount);
354
+ },
355
+ lock(name, seconds) {
356
+ return createDatabaseLock(connection, lockTableName, entryTableName, name, seconds, now, sleep, ownerFactory);
357
+ }
358
+ };
359
+ }
360
+ var cacheDbInternals = {
361
+ createDatabaseConnection,
362
+ createDatabaseContextOptions,
363
+ defaultSleep,
364
+ isDatabaseCacheTableMissingError,
365
+ normalizeDatabaseCacheTableError,
366
+ prepareCacheDatabaseTables,
367
+ readEntry,
368
+ readLock,
369
+ renderCacheTableMigration,
370
+ resolveDriver,
371
+ withDatabaseCacheTableGuard
372
+ };
373
+ export {
374
+ DEFAULT_CACHE_DATABASE_LOCK_TABLE,
375
+ DEFAULT_CACHE_DATABASE_TABLE,
376
+ cacheDbInternals,
377
+ createDatabaseCacheDriver
378
+ };
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "@holo-js/cache-db",
3
+ "version": "0.1.3",
4
+ "description": "Holo-JS Framework - DB-backed cache driver",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "exports": {
8
+ ".": {
9
+ "types": "./dist/index.d.ts",
10
+ "import": "./dist/index.mjs",
11
+ "default": "./dist/index.mjs"
12
+ }
13
+ },
14
+ "main": "./dist/index.mjs",
15
+ "types": "./dist/index.d.ts",
16
+ "files": [
17
+ "dist"
18
+ ],
19
+ "scripts": {
20
+ "build": "tsup",
21
+ "stub": "tsup",
22
+ "typecheck": "tsc -p tsconfig.json --noEmit",
23
+ "test": "vitest --run"
24
+ },
25
+ "peerDependencies": {
26
+ "@holo-js/cache": "^0.1.3",
27
+ "@holo-js/db": "^0.1.3"
28
+ },
29
+ "dependencies": {
30
+ "tslib": "^2.8.1"
31
+ },
32
+ "devDependencies": {
33
+ "@holo-js/cache": "workspace:*",
34
+ "@holo-js/db": "workspace:*",
35
+ "@types/node": "catalog:",
36
+ "tsup": "catalog:",
37
+ "typescript": "catalog:",
38
+ "vitest": "catalog:"
39
+ }
40
+ }