@comic-vine/sqlite-store
Version:
SQLite store implementations for Comic Vine client caching, deduplication, and rate limiting using Drizzle ORM
1,216 lines (1,210 loc) • 41.3 kB
JavaScript
'use strict';
var Database = require('better-sqlite3');
var drizzleOrm = require('drizzle-orm');
var betterSqlite3 = require('drizzle-orm/better-sqlite3');
var sqliteCore = require('drizzle-orm/sqlite-core');
var crypto = require('crypto');
var client = require('@comic-vine/client');
function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
var Database__default = /*#__PURE__*/_interopDefault(Database);
var __async = (__this, __arguments, generator) => {
return new Promise((resolve, reject) => {
var fulfilled = (value) => {
try {
step(generator.next(value));
} catch (e) {
reject(e);
}
};
var rejected = (value) => {
try {
step(generator.throw(value));
} catch (e) {
reject(e);
}
};
var step = (x) => x.done ? resolve(x.value) : Promise.resolve(x.value).then(fulfilled, rejected);
step((generator = generator.apply(__this, __arguments)).next());
});
};
var cacheTable = sqliteCore.sqliteTable("cache", {
hash: sqliteCore.text("hash").primaryKey(),
value: sqliteCore.blob("value", { mode: "json" }).notNull(),
expiresAt: sqliteCore.integer("expires_at").notNull(),
createdAt: sqliteCore.integer("created_at").notNull()
});
var dedupeTable = sqliteCore.sqliteTable("dedupe_jobs", {
hash: sqliteCore.text("hash").primaryKey(),
jobId: sqliteCore.text("job_id").notNull(),
status: sqliteCore.text("status").notNull(),
// 'pending', 'completed', 'failed'
result: sqliteCore.blob("result", { mode: "json" }),
error: sqliteCore.text("error"),
createdAt: sqliteCore.integer("created_at").notNull(),
updatedAt: sqliteCore.integer("updated_at").notNull()
});
var rateLimitTable = sqliteCore.sqliteTable("rate_limits", {
resource: sqliteCore.text("resource").notNull(),
timestamp: sqliteCore.integer("timestamp").notNull(),
id: sqliteCore.integer("id").primaryKey({ autoIncrement: true })
});
// src/sqlite-cache-store.ts
var SQLiteCacheStore = class {
constructor({
/** File path or existing `better-sqlite3` connection. Defaults to `':memory:'`. */
database = ":memory:",
/** Cleanup interval in milliseconds. Defaults to 1 minute. */
cleanupIntervalMs = 6e4,
/** Maximum allowed size (in bytes) for a single cache entry. Defaults to 5 MiB. */
maxEntrySizeBytes = 5 * 1024 * 1024
} = {}) {
/** Indicates whether this store is responsible for managing (and therefore closing) the SQLite connection */
this.isConnectionManaged = false;
this.isDestroyed = false;
let sqliteInstance;
let isConnectionManaged = false;
if (typeof database === "string") {
sqliteInstance = new Database__default.default(database);
isConnectionManaged = true;
} else {
sqliteInstance = database;
}
this.sqlite = sqliteInstance;
this.isConnectionManaged = isConnectionManaged;
this.db = betterSqlite3.drizzle(sqliteInstance);
this.cleanupIntervalMs = cleanupIntervalMs;
this.maxEntrySizeBytes = maxEntrySizeBytes;
this.initializeDatabase();
this.startCleanupInterval();
}
get(hash) {
return __async(this, null, function* () {
if (this.isDestroyed) {
throw new Error("Cache store has been destroyed");
}
const result = yield this.db.select().from(cacheTable).where(drizzleOrm.eq(cacheTable.hash, hash)).limit(1);
if (result.length === 0) {
return void 0;
}
const item = result[0];
if (!item) {
return void 0;
}
const now = Date.now();
if (now >= item.expiresAt) {
yield this.db.delete(cacheTable).where(drizzleOrm.eq(cacheTable.hash, hash));
return void 0;
}
try {
if (item.value === "__UNDEFINED__") {
return void 0;
}
return JSON.parse(item.value);
} catch (e) {
yield this.db.delete(cacheTable).where(drizzleOrm.eq(cacheTable.hash, hash));
return void 0;
}
});
}
set(hash, value, ttlSeconds) {
return __async(this, null, function* () {
if (this.isDestroyed) {
throw new Error("Cache store has been destroyed");
}
const now = Date.now();
const expiresAt = ttlSeconds <= 0 ? now : now + ttlSeconds * 1e3;
let serializedValue;
try {
if (value === void 0) {
serializedValue = "__UNDEFINED__";
} else {
serializedValue = JSON.stringify(value);
}
} catch (error) {
throw new Error(
`Failed to serialize value: ${error instanceof Error ? error.message : String(error)}`
);
}
if (Buffer.byteLength(serializedValue, "utf8") > this.maxEntrySizeBytes) {
return;
}
yield this.db.insert(cacheTable).values({
hash,
value: serializedValue,
expiresAt,
createdAt: now
}).onConflictDoUpdate({
target: cacheTable.hash,
set: {
value: serializedValue,
expiresAt,
createdAt: now
}
});
});
}
delete(hash) {
return __async(this, null, function* () {
if (this.isDestroyed) {
throw new Error("Cache store has been destroyed");
}
yield this.db.delete(cacheTable).where(drizzleOrm.eq(cacheTable.hash, hash));
});
}
clear() {
return __async(this, null, function* () {
if (this.isDestroyed) {
throw new Error("Cache store has been destroyed");
}
yield this.db.delete(cacheTable);
});
}
/**
* Get cache statistics
*/
getStats() {
return __async(this, null, function* () {
var _a, _b, _c, _d;
const now = Date.now();
const totalResult = yield this.db.select({ count: drizzleOrm.count() }).from(cacheTable);
const expiredResult = yield this.db.select({ count: drizzleOrm.count() }).from(cacheTable).where(drizzleOrm.lt(cacheTable.expiresAt, now));
const pageCount = Number(
this.sqlite.pragma("page_count", { simple: true })
);
const pageSize = Number(this.sqlite.pragma("page_size", { simple: true }));
const safePageCount = Number.isFinite(pageCount) ? pageCount : 0;
const safePageSize = Number.isFinite(pageSize) ? pageSize : 0;
const databaseSizeKB = Math.round(safePageCount * safePageSize / 1024);
return {
databaseSizeKB,
expiredItems: (_b = (_a = expiredResult[0]) == null ? void 0 : _a.count) != null ? _b : 0,
totalItems: (_d = (_c = totalResult[0]) == null ? void 0 : _c.count) != null ? _d : 0
};
});
}
/**
* Manually trigger cleanup of expired items
*/
cleanup() {
return __async(this, null, function* () {
const now = Date.now();
yield this.db.delete(cacheTable).where(drizzleOrm.lt(cacheTable.expiresAt, now));
});
}
/**
* Close the database connection
*/
close() {
return __async(this, null, function* () {
if (this.cleanupInterval) {
clearInterval(this.cleanupInterval);
this.cleanupInterval = void 0;
}
this.isDestroyed = true;
if (this.isConnectionManaged && typeof this.sqlite.close === "function") {
this.sqlite.close();
}
});
}
/**
* Alias for close() to match test expectations
*/
destroy() {
this.close();
}
initializeDatabase() {
this.db.run(drizzleOrm.sql`
CREATE TABLE IF NOT EXISTS cache (
hash TEXT PRIMARY KEY,
value BLOB NOT NULL,
expires_at INTEGER NOT NULL,
created_at INTEGER NOT NULL
)
`);
this.db.run(drizzleOrm.sql`
CREATE INDEX IF NOT EXISTS idx_cache_expires_at ON cache(expires_at)
`);
}
startCleanupInterval() {
this.cleanupInterval = setInterval(() => __async(this, null, function* () {
yield this.cleanup();
}), this.cleanupIntervalMs);
if (typeof this.cleanupInterval.unref === "function") {
this.cleanupInterval.unref();
}
}
cleanupExpiredItems() {
return __async(this, null, function* () {
const now = Date.now();
yield this.db.delete(cacheTable).where(drizzleOrm.lt(cacheTable.expiresAt, now));
});
}
};
var SQLiteDedupeStore = class {
constructor({
/** File path or existing `better-sqlite3` Database instance. Defaults to `':memory:'`. */
database = ":memory:",
/** Job timeout in milliseconds. Preferred over timeoutMs. */
jobTimeoutMs,
/** Legacy alias for jobTimeoutMs. */
timeoutMs,
/** Cleanup interval in milliseconds. Defaults to 1 minute. */
cleanupIntervalMs = 6e4
} = {}) {
/** Indicates whether this store manages (and should close) the SQLite connection */
this.isConnectionManaged = false;
this.jobPromises = /* @__PURE__ */ new Map();
this.jobResolvers = /* @__PURE__ */ new Map();
this.isDestroyed = false;
var _a;
let sqliteInstance;
let isConnectionManaged = false;
if (typeof database === "string") {
sqliteInstance = new Database__default.default(database);
isConnectionManaged = true;
} else {
sqliteInstance = database;
}
this.sqlite = sqliteInstance;
this.isConnectionManaged = isConnectionManaged;
this.db = betterSqlite3.drizzle(sqliteInstance);
this.jobTimeoutMs = (_a = timeoutMs != null ? timeoutMs : jobTimeoutMs) != null ? _a : 3e5;
this.cleanupIntervalMs = cleanupIntervalMs;
this.initializeDatabase();
this.startCleanupInterval();
}
startCleanupInterval() {
if (this.cleanupIntervalMs > 0) {
this.cleanupInterval = setInterval(() => {
this.cleanupExpiredJobs().catch(() => {
});
}, this.cleanupIntervalMs);
if (typeof this.cleanupInterval.unref === "function") {
this.cleanupInterval.unref();
}
}
}
cleanupExpiredJobs() {
return __async(this, null, function* () {
const noTimeoutConfigured = this.jobTimeoutMs <= 0;
if (noTimeoutConfigured) {
return;
}
const now = Date.now();
const expiredThreshold = now - this.jobTimeoutMs;
yield this.db.delete(dedupeTable).where(
drizzleOrm.and(
drizzleOrm.eq(dedupeTable.status, "pending"),
drizzleOrm.lt(dedupeTable.createdAt, expiredThreshold)
)
);
});
}
waitFor(hash) {
return __async(this, null, function* () {
if (this.isDestroyed) {
throw new Error("Dedupe store has been destroyed");
}
const existingPromise = this.jobPromises.get(hash);
if (existingPromise) {
return existingPromise;
}
const result = yield this.db.select().from(dedupeTable).where(drizzleOrm.eq(dedupeTable.hash, hash)).limit(1);
if (result.length === 0) {
return void 0;
}
const job = result[0];
if (!job) {
return void 0;
}
if (job.status === "completed") {
try {
if (job.result === "__UNDEFINED__" || job.result === "__NULL__") {
return void 0;
} else if (job.result) {
return JSON.parse(job.result);
}
return void 0;
} catch (e) {
return void 0;
}
}
if (job.status === "failed") {
return void 0;
}
const promise = new Promise((resolve, reject) => {
this.jobResolvers.set(hash, { resolve, reject });
if (this.jobTimeoutMs > 0) {
setTimeout(() => {
const resolver = this.jobResolvers.get(hash);
if (resolver) {
this.jobResolvers.delete(hash);
this.jobPromises.delete(hash);
this.db.update(dedupeTable).set({
status: "failed",
error: "Job timed out",
updatedAt: Date.now()
}).where(drizzleOrm.eq(dedupeTable.hash, hash)).then(() => {
resolve(void 0);
}).catch(() => {
resolve(void 0);
});
}
}, this.jobTimeoutMs);
}
});
this.jobPromises.set(hash, promise);
return promise;
});
}
register(hash) {
return __async(this, null, function* () {
if (this.isDestroyed) {
throw new Error("Dedupe store has been destroyed");
}
const existingJob = yield this.db.select().from(dedupeTable).where(drizzleOrm.eq(dedupeTable.hash, hash)).limit(1);
if (existingJob.length > 0) {
const job = existingJob[0];
if (job && job.status === "pending") {
return job.jobId;
}
}
const jobId = crypto.randomUUID();
const now = Date.now();
yield this.db.insert(dedupeTable).values({
hash,
jobId,
status: "pending",
createdAt: now,
updatedAt: now
}).onConflictDoUpdate({
target: dedupeTable.hash,
set: {
jobId,
status: "pending",
updatedAt: now
}
});
return jobId;
});
}
complete(hash, value) {
return __async(this, null, function* () {
var _a;
if (this.isDestroyed) {
throw new Error("Dedupe store has been destroyed");
}
let serializedResult;
if (value === void 0) {
serializedResult = "__UNDEFINED__";
} else if (value === null) {
serializedResult = "__NULL__";
} else {
try {
serializedResult = JSON.stringify(value);
} catch (error) {
throw new Error(
`Failed to serialize result: ${error instanceof Error ? error.message : String(error)}`
);
}
}
const now = Date.now();
const existingJob = yield this.db.select().from(dedupeTable).where(drizzleOrm.eq(dedupeTable.hash, hash)).limit(1);
if (existingJob.length > 0 && ((_a = existingJob[0]) == null ? void 0 : _a.status) === "completed") {
return;
}
yield this.db.update(dedupeTable).set({
status: "completed",
result: serializedResult,
updatedAt: now
}).where(drizzleOrm.eq(dedupeTable.hash, hash));
const resolver = this.jobResolvers.get(hash);
if (resolver) {
resolver.resolve(value);
this.jobResolvers.delete(hash);
this.jobPromises.delete(hash);
}
});
}
fail(hash, error) {
return __async(this, null, function* () {
if (this.isDestroyed) {
throw new Error("Dedupe store has been destroyed");
}
const now = Date.now();
yield this.db.update(dedupeTable).set({
status: "failed",
error: error.message,
updatedAt: now
}).where(drizzleOrm.eq(dedupeTable.hash, hash));
const resolver = this.jobResolvers.get(hash);
if (resolver) {
resolver.reject(error);
this.jobResolvers.delete(hash);
this.jobPromises.delete(hash);
}
});
}
isInProgress(hash) {
return __async(this, null, function* () {
if (this.isDestroyed) {
throw new Error("Dedupe store has been destroyed");
}
const result = yield this.db.select().from(dedupeTable).where(drizzleOrm.eq(dedupeTable.hash, hash)).limit(1);
if (result.length === 0) {
return false;
}
const job = result[0];
if (!job) {
return false;
}
const jobExpired = this.jobTimeoutMs > 0 && Date.now() - job.createdAt >= this.jobTimeoutMs;
if (jobExpired) {
yield this.db.delete(dedupeTable).where(drizzleOrm.eq(dedupeTable.hash, hash));
return false;
}
return job.status === "pending";
});
}
getResult(hash) {
return __async(this, null, function* () {
const result = yield this.db.select().from(dedupeTable).where(drizzleOrm.eq(dedupeTable.hash, hash)).limit(1);
if (result.length === 0) {
return void 0;
}
const job = result[0];
if (!job) {
return void 0;
}
const now = Date.now();
const isExpired = now - job.createdAt > this.jobTimeoutMs;
if (isExpired) {
yield this.db.delete(dedupeTable).where(drizzleOrm.eq(dedupeTable.hash, hash));
return void 0;
}
if (job.status === "completed") {
try {
if (job.result === "__UNDEFINED__") {
return void 0;
} else if (job.result === "__NULL__") {
return null;
} else if (job.result) {
return JSON.parse(job.result);
}
return void 0;
} catch (e) {
return void 0;
}
}
return void 0;
});
}
/**
* Get statistics about dedupe jobs
*/
getStats() {
return __async(this, null, function* () {
var _a, _b, _c, _d, _e;
const now = Date.now();
const expiredTime = now - this.jobTimeoutMs;
const totalResult = yield this.db.select({ count: drizzleOrm.count() }).from(dedupeTable);
const pendingResult = yield this.db.select({ count: drizzleOrm.count() }).from(dedupeTable).where(drizzleOrm.eq(dedupeTable.status, "pending"));
const completedResult = yield this.db.select({ count: drizzleOrm.count() }).from(dedupeTable).where(drizzleOrm.eq(dedupeTable.status, "completed"));
const failedResult = yield this.db.select({ count: drizzleOrm.count() }).from(dedupeTable).where(drizzleOrm.eq(dedupeTable.status, "failed"));
const expiredResult = yield this.db.select({ count: drizzleOrm.count() }).from(dedupeTable).where(drizzleOrm.lt(dedupeTable.createdAt, expiredTime));
return {
totalJobs: ((_a = totalResult[0]) == null ? void 0 : _a.count) || 0,
pendingJobs: ((_b = pendingResult[0]) == null ? void 0 : _b.count) || 0,
completedJobs: ((_c = completedResult[0]) == null ? void 0 : _c.count) || 0,
failedJobs: ((_d = failedResult[0]) == null ? void 0 : _d.count) || 0,
expiredJobs: ((_e = expiredResult[0]) == null ? void 0 : _e.count) || 0
};
});
}
/**
* Clean up expired jobs
*/
cleanup() {
return __async(this, null, function* () {
const now = Date.now();
const expiredTime = now - this.jobTimeoutMs;
yield this.db.delete(dedupeTable).where(drizzleOrm.lt(dedupeTable.createdAt, expiredTime));
});
}
/**
* Clear all jobs
*/
clear() {
return __async(this, null, function* () {
yield this.db.delete(dedupeTable);
this.jobPromises.clear();
this.jobResolvers.clear();
});
}
/**
* Close the database connection
*/
close() {
return __async(this, null, function* () {
if (this.cleanupInterval) {
clearInterval(this.cleanupInterval);
this.cleanupInterval = void 0;
}
this.jobPromises.clear();
this.jobResolvers.clear();
this.isDestroyed = true;
if (this.isConnectionManaged && typeof this.sqlite.close === "function") {
this.sqlite.close();
}
});
}
/**
* Alias for close() to match test expectations
*/
destroy() {
this.close();
}
initializeDatabase() {
this.db.run(drizzleOrm.sql`
CREATE TABLE IF NOT EXISTS dedupe_jobs (
hash TEXT PRIMARY KEY,
job_id TEXT NOT NULL,
status TEXT NOT NULL,
result BLOB,
error TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
)
`);
this.db.run(drizzleOrm.sql`
CREATE INDEX IF NOT EXISTS idx_dedupe_status ON dedupe_jobs(status)
`);
}
};
var SQLiteRateLimitStore = class {
constructor({
/** File path or existing `better-sqlite3` Database instance. Defaults to `':memory:'`. */
database = ":memory:",
/** Global/default rate-limit config applied when a resource-specific override is not provided. */
defaultConfig = client.DEFAULT_RATE_LIMIT,
/** Optional per-resource overrides. */
resourceConfigs = /* @__PURE__ */ new Map()
} = {}) {
/** Indicates whether this store manages (and should close) the SQLite connection */
this.isConnectionManaged = false;
this.resourceConfigs = /* @__PURE__ */ new Map();
this.isDestroyed = false;
let sqliteInstance;
let isConnectionManaged = false;
if (typeof database === "string") {
sqliteInstance = new Database__default.default(database);
isConnectionManaged = true;
} else {
sqliteInstance = database;
}
this.sqlite = sqliteInstance;
this.isConnectionManaged = isConnectionManaged;
this.db = betterSqlite3.drizzle(sqliteInstance);
this.defaultConfig = defaultConfig;
this.resourceConfigs = resourceConfigs;
this.initializeDatabase();
}
canProceed(resource) {
return __async(this, null, function* () {
var _a;
if (this.isDestroyed) {
throw new Error("Rate limit store has been destroyed");
}
const config = this.resourceConfigs.get(resource) || this.defaultConfig;
const now = Date.now();
const windowStart = now - config.windowMs;
yield this.cleanupExpiredRequests(resource, windowStart);
const result = yield this.db.select({ count: drizzleOrm.count() }).from(rateLimitTable).where(
drizzleOrm.and(
drizzleOrm.eq(rateLimitTable.resource, resource),
drizzleOrm.gte(rateLimitTable.timestamp, windowStart)
)
);
const currentCount = ((_a = result[0]) == null ? void 0 : _a.count) || 0;
return currentCount < config.limit;
});
}
record(resource) {
return __async(this, null, function* () {
if (this.isDestroyed) {
throw new Error("Rate limit store has been destroyed");
}
const now = Date.now();
yield this.db.insert(rateLimitTable).values({
resource,
timestamp: now
});
});
}
getStatus(resource) {
return __async(this, null, function* () {
var _a;
if (this.isDestroyed) {
throw new Error("Rate limit store has been destroyed");
}
const config = this.resourceConfigs.get(resource) || this.defaultConfig;
const now = Date.now();
const windowStart = now - config.windowMs;
yield this.cleanupExpiredRequests(resource, windowStart);
const result = yield this.db.select({ count: drizzleOrm.count() }).from(rateLimitTable).where(
drizzleOrm.and(
drizzleOrm.eq(rateLimitTable.resource, resource),
drizzleOrm.gte(rateLimitTable.timestamp, windowStart)
)
);
const currentRequests = ((_a = result[0]) == null ? void 0 : _a.count) || 0;
const remaining = Math.max(0, config.limit - currentRequests);
const resetTime = new Date(now + config.windowMs);
return {
remaining,
resetTime,
limit: config.limit
};
});
}
reset(resource) {
return __async(this, null, function* () {
if (this.isDestroyed) {
throw new Error("Rate limit store has been destroyed");
}
yield this.db.delete(rateLimitTable).where(drizzleOrm.eq(rateLimitTable.resource, resource));
});
}
getWaitTime(resource) {
return __async(this, null, function* () {
var _a, _b;
if (this.isDestroyed) {
throw new Error("Rate limit store has been destroyed");
}
const config = this.resourceConfigs.get(resource) || this.defaultConfig;
if (config.limit === 0) {
return config.windowMs;
}
const now = Date.now();
const windowStart = now - config.windowMs;
yield this.cleanupExpiredRequests(resource, windowStart);
const countResult = yield this.db.select({ count: drizzleOrm.count() }).from(rateLimitTable).where(
drizzleOrm.and(
drizzleOrm.eq(rateLimitTable.resource, resource),
drizzleOrm.gte(rateLimitTable.timestamp, windowStart)
)
);
const currentRequests = ((_a = countResult[0]) == null ? void 0 : _a.count) || 0;
if (currentRequests < config.limit) {
return 0;
}
const oldestResult = yield this.db.select({ timestamp: rateLimitTable.timestamp }).from(rateLimitTable).where(
drizzleOrm.and(
drizzleOrm.eq(rateLimitTable.resource, resource),
drizzleOrm.gte(rateLimitTable.timestamp, windowStart)
)
).orderBy(rateLimitTable.timestamp).limit(1);
if (oldestResult.length === 0) {
return 0;
}
const oldestTimestamp = (_b = oldestResult[0]) == null ? void 0 : _b.timestamp;
if (oldestTimestamp === void 0) {
return 0;
}
const timeUntilOldestExpires = oldestTimestamp + config.windowMs - now;
return Math.max(0, timeUntilOldestExpires);
});
}
/**
* Set rate limit configuration for a specific resource
*/
setResourceConfig(resource, config) {
this.resourceConfigs.set(resource, config);
}
/**
* Get rate limit configuration for a resource
*/
getResourceConfig(resource) {
return this.resourceConfigs.get(resource) || this.defaultConfig;
}
/**
* Get statistics for all resources
*/
getStats() {
return __async(this, null, function* () {
var _a;
if (this.isDestroyed) {
throw new Error("Rate limit store has been destroyed");
}
const totalResult = yield this.db.select({ count: drizzleOrm.count() }).from(rateLimitTable);
const resourcesResult = yield this.db.select({ resource: rateLimitTable.resource }).from(rateLimitTable).groupBy(rateLimitTable.resource);
const uniqueResources = resourcesResult.length;
const rateLimitedResources = [];
for (const { resource } of resourcesResult) {
const canProceed = yield this.canProceed(resource);
if (!canProceed) {
rateLimitedResources.push(resource);
}
}
return {
totalRequests: ((_a = totalResult[0]) == null ? void 0 : _a.count) || 0,
uniqueResources,
rateLimitedResources
};
});
}
/**
* Clean up all rate limit data
*/
clear() {
return __async(this, null, function* () {
if (this.isDestroyed) {
throw new Error("Rate limit store has been destroyed");
}
yield this.db.delete(rateLimitTable);
});
}
/**
* Clean up expired requests for all resources
*/
cleanup() {
return __async(this, null, function* () {
const now = Date.now();
const resources = yield this.db.select({ resource: rateLimitTable.resource }).from(rateLimitTable).groupBy(rateLimitTable.resource);
for (const { resource } of resources) {
const config = this.resourceConfigs.get(resource) || this.defaultConfig;
const windowStart = now - config.windowMs;
yield this.cleanupExpiredRequests(resource, windowStart);
}
});
}
/**
* Close the database connection
*/
close() {
return __async(this, null, function* () {
this.isDestroyed = true;
if (this.isConnectionManaged && typeof this.sqlite.close === "function") {
this.sqlite.close();
}
});
}
/**
* Alias for close() to match test expectations
*/
destroy() {
this.close();
}
cleanupExpiredRequests(resource, windowStart) {
return __async(this, null, function* () {
yield this.db.delete(rateLimitTable).where(
drizzleOrm.and(
drizzleOrm.eq(rateLimitTable.resource, resource),
drizzleOrm.lt(rateLimitTable.timestamp, windowStart)
)
);
});
}
initializeDatabase() {
this.db.run(drizzleOrm.sql`
CREATE TABLE IF NOT EXISTS rate_limits (
id INTEGER PRIMARY KEY AUTOINCREMENT,
resource TEXT NOT NULL,
timestamp INTEGER NOT NULL
)
`);
this.db.run(drizzleOrm.sql`
CREATE INDEX IF NOT EXISTS idx_rate_limit_resource ON rate_limits(resource)
`);
this.db.run(drizzleOrm.sql`
CREATE INDEX IF NOT EXISTS idx_rate_limit_timestamp ON rate_limits(timestamp)
`);
}
};
var DEFAULT_RATE_LIMIT2 = {
limit: 200,
windowMs: 36e5
// 1 hour
};
var SqliteAdaptiveRateLimitStore = class {
constructor({
database = ":memory:",
defaultConfig = DEFAULT_RATE_LIMIT2,
resourceConfigs = /* @__PURE__ */ new Map(),
adaptiveConfig = {}
} = {}) {
/** Indicates whether this store manages (and should close) the SQLite connection */
this.isConnectionManaged = false;
this.resourceConfigs = /* @__PURE__ */ new Map();
this.isDestroyed = false;
this.activityMetrics = /* @__PURE__ */ new Map();
this.lastCapacityUpdate = /* @__PURE__ */ new Map();
this.cachedCapacity = /* @__PURE__ */ new Map();
let sqliteInstance;
let isConnectionManaged = false;
if (typeof database === "string") {
sqliteInstance = new Database__default.default(database);
isConnectionManaged = true;
} else {
sqliteInstance = database;
}
this.sqlite = sqliteInstance;
this.isConnectionManaged = isConnectionManaged;
this.db = betterSqlite3.drizzle(sqliteInstance);
this.defaultConfig = defaultConfig;
this.resourceConfigs = resourceConfigs;
this.capacityCalculator = new client.AdaptiveCapacityCalculator(adaptiveConfig);
this.initializeDatabase();
}
canProceed(resource, priority = "background") {
return __async(this, null, function* () {
if (this.isDestroyed) {
throw new Error("Rate limit store has been destroyed");
}
yield this.ensureActivityMetrics(resource);
const metrics = this.getOrCreateActivityMetrics(resource);
const capacity = this.calculateCurrentCapacity(resource, metrics);
if (priority === "background" && capacity.backgroundPaused) {
return false;
}
const currentUserRequests = yield this.getCurrentUsage(resource, "user");
const currentBackgroundRequests = yield this.getCurrentUsage(
resource,
"background"
);
if (priority === "user") {
return currentUserRequests < capacity.userReserved;
} else {
return currentBackgroundRequests < capacity.backgroundMax;
}
});
}
record(resource, priority = "background") {
return __async(this, null, function* () {
if (this.isDestroyed) {
throw new Error("Rate limit store has been destroyed");
}
const now = Date.now();
this.db.run(drizzleOrm.sql`
INSERT INTO rate_limits (resource, timestamp, priority)
VALUES (${resource}, ${now}, ${priority})
`);
const metrics = this.getOrCreateActivityMetrics(resource);
if (priority === "user") {
metrics.recentUserRequests.push(now);
this.cleanupOldRequests(metrics.recentUserRequests);
} else {
metrics.recentBackgroundRequests.push(now);
this.cleanupOldRequests(metrics.recentBackgroundRequests);
}
metrics.userActivityTrend = this.capacityCalculator.calculateActivityTrend(
metrics.recentUserRequests
);
});
}
getStatus(resource) {
return __async(this, null, function* () {
if (this.isDestroyed) {
throw new Error("Rate limit store has been destroyed");
}
yield this.ensureActivityMetrics(resource);
const metrics = this.getOrCreateActivityMetrics(resource);
const capacity = this.calculateCurrentCapacity(resource, metrics);
const currentUserUsage = yield this.getCurrentUsage(resource, "user");
const currentBackgroundUsage = yield this.getCurrentUsage(
resource,
"background"
);
const config = this.resourceConfigs.get(resource) || this.defaultConfig;
return {
remaining: capacity.userReserved - currentUserUsage + (capacity.backgroundMax - currentBackgroundUsage),
resetTime: new Date(Date.now() + config.windowMs),
limit: this.getResourceLimit(resource),
adaptive: {
userReserved: capacity.userReserved,
backgroundMax: capacity.backgroundMax,
backgroundPaused: capacity.backgroundPaused,
recentUserActivity: this.capacityCalculator.getRecentActivity(
metrics.recentUserRequests
),
reason: capacity.reason
}
};
});
}
reset(resource) {
return __async(this, null, function* () {
if (this.isDestroyed) {
throw new Error("Rate limit store has been destroyed");
}
yield this.db.delete(rateLimitTable).where(drizzleOrm.eq(rateLimitTable.resource, resource));
this.activityMetrics.delete(resource);
this.cachedCapacity.delete(resource);
this.lastCapacityUpdate.delete(resource);
});
}
getWaitTime(resource, priority = "background") {
return __async(this, null, function* () {
if (this.isDestroyed) {
throw new Error("Rate limit store has been destroyed");
}
const config = this.resourceConfigs.get(resource) || this.defaultConfig;
if (config.limit === 0) {
return config.windowMs;
}
const canProceed = yield this.canProceed(resource, priority);
if (canProceed) {
return 0;
}
yield this.ensureActivityMetrics(resource);
const metrics = this.getOrCreateActivityMetrics(resource);
const capacity = this.calculateCurrentCapacity(resource, metrics);
if (priority === "background" && capacity.backgroundPaused) {
return this.capacityCalculator.config.recalculationIntervalMs;
}
const now = Date.now();
const windowStart = now - config.windowMs;
const oldestResult = this.sqlite.prepare(
`
SELECT timestamp
FROM rate_limits
WHERE resource = ? AND COALESCE(priority, 'background') = ? AND timestamp >= ?
ORDER BY timestamp
LIMIT 1
`
).get(resource, priority, windowStart);
if (!oldestResult) {
return 0;
}
const oldestTimestamp = oldestResult.timestamp;
if (!oldestTimestamp) {
return 0;
}
const timeUntilOldestExpires = oldestTimestamp + config.windowMs - now;
return Math.max(0, timeUntilOldestExpires);
});
}
/**
* Set rate limit configuration for a specific resource
*/
setResourceConfig(resource, config) {
this.resourceConfigs.set(resource, config);
}
/**
* Get rate limit configuration for a resource
*/
getResourceConfig(resource) {
return this.resourceConfigs.get(resource) || this.defaultConfig;
}
/**
* Get statistics for all resources
*/
getStats() {
return __async(this, null, function* () {
var _a;
if (this.isDestroyed) {
throw new Error("Rate limit store has been destroyed");
}
const totalResult = yield this.db.select({ count: drizzleOrm.count() }).from(rateLimitTable);
const resourcesResult = yield this.db.select({ resource: rateLimitTable.resource }).from(rateLimitTable).groupBy(rateLimitTable.resource);
const uniqueResources = resourcesResult.length;
const rateLimitedResources = [];
for (const { resource } of resourcesResult) {
const canProceed = yield this.canProceed(resource);
if (!canProceed) {
rateLimitedResources.push(resource);
}
}
return {
totalRequests: ((_a = totalResult[0]) == null ? void 0 : _a.count) || 0,
uniqueResources,
rateLimitedResources
};
});
}
/**
* Clean up all rate limit data
*/
clear() {
return __async(this, null, function* () {
if (this.isDestroyed) {
throw new Error("Rate limit store has been destroyed");
}
yield this.db.delete(rateLimitTable);
this.activityMetrics.clear();
this.cachedCapacity.clear();
this.lastCapacityUpdate.clear();
});
}
/**
* Clean up expired requests for all resources
*/
cleanup() {
return __async(this, null, function* () {
const now = Date.now();
const resources = yield this.db.select({ resource: rateLimitTable.resource }).from(rateLimitTable).groupBy(rateLimitTable.resource);
for (const { resource } of resources) {
const config = this.resourceConfigs.get(resource) || this.defaultConfig;
const windowStart = now - config.windowMs;
yield this.cleanupExpiredRequests(resource, windowStart);
}
});
}
/**
* Close the database connection
*/
close() {
return __async(this, null, function* () {
this.isDestroyed = true;
if (this.isConnectionManaged && typeof this.sqlite.close === "function") {
this.sqlite.close();
}
});
}
/**
* Alias for close() to match test expectations
*/
destroy() {
this.close();
}
// Private helper methods for adaptive functionality
calculateCurrentCapacity(resource, metrics) {
const lastUpdate = this.lastCapacityUpdate.get(resource) || 0;
const recalcInterval = this.capacityCalculator.config.recalculationIntervalMs;
if (Date.now() - lastUpdate < recalcInterval) {
return this.cachedCapacity.get(resource) || this.getDefaultCapacity(resource);
}
const totalLimit = this.getResourceLimit(resource);
const capacity = this.capacityCalculator.calculateDynamicCapacity(
resource,
totalLimit,
metrics
);
this.cachedCapacity.set(resource, capacity);
this.lastCapacityUpdate.set(resource, Date.now());
return capacity;
}
getOrCreateActivityMetrics(resource) {
if (!this.activityMetrics.has(resource)) {
this.activityMetrics.set(resource, {
recentUserRequests: [],
recentBackgroundRequests: [],
userActivityTrend: "none"
});
}
return this.activityMetrics.get(resource);
}
ensureActivityMetrics(resource) {
return __async(this, null, function* () {
if (this.activityMetrics.has(resource)) {
return;
}
const now = Date.now();
const windowStart = now - this.capacityCalculator.config.monitoringWindowMs;
const recentRequests = this.sqlite.prepare(
`
SELECT timestamp, COALESCE(priority, 'background') as priority
FROM rate_limits
WHERE resource = ? AND timestamp >= ?
ORDER BY timestamp
`
).all(resource, windowStart);
const metrics = {
recentUserRequests: [],
recentBackgroundRequests: [],
userActivityTrend: "none"
};
for (const request of recentRequests) {
if (request.priority === "user") {
metrics.recentUserRequests.push(request.timestamp);
} else {
metrics.recentBackgroundRequests.push(request.timestamp);
}
}
metrics.userActivityTrend = this.capacityCalculator.calculateActivityTrend(
metrics.recentUserRequests
);
this.activityMetrics.set(resource, metrics);
});
}
getCurrentUsage(resource, priority) {
return __async(this, null, function* () {
const config = this.resourceConfigs.get(resource) || this.defaultConfig;
const now = Date.now();
const windowStart = now - config.windowMs;
yield this.cleanupExpiredRequests(resource, windowStart);
const result = this.sqlite.prepare(
`
SELECT COUNT(*) as count
FROM rate_limits
WHERE resource = ? AND priority = ? AND timestamp >= ?
`
).get(resource, priority, windowStart);
return result.count || 0;
});
}
cleanupOldRequests(requests) {
const cutoff = Date.now() - this.capacityCalculator.config.monitoringWindowMs;
while (requests.length > 0 && requests[0] < cutoff) {
requests.shift();
}
}
getResourceLimit(resource) {
const config = this.resourceConfigs.get(resource) || this.defaultConfig;
return config.limit;
}
getDefaultCapacity(resource) {
const limit = this.getResourceLimit(resource);
return {
userReserved: Math.floor(limit * 0.3),
backgroundMax: Math.floor(limit * 0.7),
backgroundPaused: false,
reason: "Default capacity allocation"
};
}
cleanupExpiredRequests(resource, windowStart) {
return __async(this, null, function* () {
yield this.db.delete(rateLimitTable).where(
drizzleOrm.and(
drizzleOrm.eq(rateLimitTable.resource, resource),
drizzleOrm.lt(rateLimitTable.timestamp, windowStart)
)
);
});
}
initializeDatabase() {
this.db.run(drizzleOrm.sql`
CREATE TABLE IF NOT EXISTS rate_limits (
id INTEGER PRIMARY KEY AUTOINCREMENT,
resource TEXT NOT NULL,
timestamp INTEGER NOT NULL,
priority TEXT NOT NULL DEFAULT 'background'
)
`);
try {
this.db.run(drizzleOrm.sql`
ALTER TABLE rate_limits ADD COLUMN priority TEXT DEFAULT 'background'
`);
} catch (e) {
}
this.db.run(drizzleOrm.sql`
CREATE INDEX IF NOT EXISTS idx_rate_limit_resource ON rate_limits(resource)
`);
this.db.run(drizzleOrm.sql`
CREATE INDEX IF NOT EXISTS idx_rate_limit_timestamp ON rate_limits(timestamp)
`);
this.db.run(drizzleOrm.sql`
CREATE INDEX IF NOT EXISTS idx_rate_limit_resource_priority_timestamp
ON rate_limits(resource, priority, timestamp)
`);
}
};
exports.SQLiteCacheStore = SQLiteCacheStore;
exports.SQLiteDedupeStore = SQLiteDedupeStore;
exports.SQLiteRateLimitStore = SQLiteRateLimitStore;
exports.SqliteAdaptiveRateLimitStore = SqliteAdaptiveRateLimitStore;
exports.cacheTable = cacheTable;
exports.dedupeTable = dedupeTable;
exports.rateLimitTable = rateLimitTable;
//# sourceMappingURL=index.cjs.map
//# sourceMappingURL=index.cjs.map