Big News: Socket raises $60M Series C at a $1B valuation to secure software supply chains for AI-driven development.Announcement
Sign In

@frontmcp/storage-sqlite

Package Overview
Dependencies
Maintainers
1
Versions
39
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@frontmcp/storage-sqlite - npm Package Compare versions

Comparing version
1.0.4
to
1.1.0-beta.1
+140
sqlite-task.store.d.ts
/**
* SQLite Task Store
*
* Implements the FrontMCP `TaskStore` interface on top of better-sqlite3.
* Suitable for single-host deployments — including the CLI runner — where
* tasks outlive a single HTTP or stdio session but don't need cross-node
* pub/sub. Pub/sub is served by an in-process EventEmitter (same pattern as
* SqliteElicitationStore).
*
* Schema:
* ```sql
* CREATE TABLE IF NOT EXISTS mcp_tasks (
* task_id TEXT PRIMARY KEY,
* session_id TEXT NOT NULL,
* status TEXT NOT NULL,
* expires_at INTEGER NOT NULL,
* created_at INTEGER NOT NULL,
* updated_at INTEGER NOT NULL,
* executor_pid INTEGER,
* record_json TEXT NOT NULL
* );
* ```
*
* @module storage-sqlite/sqlite-task.store
*/
import type Database from 'better-sqlite3';
import type { SqliteStorageOptions } from './sqlite.options';
export interface TaskJsonRpcError {
code: number;
message: string;
data?: unknown;
}
export type TaskOutcome = {
kind: 'ok';
data: unknown;
} | {
kind: 'error';
error: TaskJsonRpcError;
};
export type TaskStatus = 'working' | 'input_required' | 'completed' | 'failed' | 'cancelled';
export interface TaskRecord {
taskId: string;
sessionId: string;
status: TaskStatus;
statusMessage?: string;
createdAt: string;
lastUpdatedAt: string;
ttlMs: number | null;
pollIntervalMs?: number;
expiresAt: number;
request: {
method: 'tools/call';
params: Record<string, unknown>;
};
outcome?: TaskOutcome;
progressToken?: string | number;
executor?: {
host: 'in-process' | 'cli';
pid?: number;
spawnedAt?: string;
};
}
export type TaskTerminalCallback = (record: TaskRecord) => void;
export type TaskCancelCallback = () => void;
export type TaskUnsubscribe = () => Promise<void>;
export interface TaskListPage {
tasks: TaskRecord[];
nextCursor?: string;
}
export interface TaskStoreInterface {
create(record: TaskRecord): Promise<void>;
get(taskId: string, sessionId: string): Promise<TaskRecord | null>;
update(taskId: string, sessionId: string, patch: Partial<TaskRecord>): Promise<TaskRecord | null>;
delete(taskId: string, sessionId: string): Promise<void>;
list(sessionId: string, opts?: {
cursor?: string;
pageSize?: number;
}): Promise<TaskListPage>;
subscribeTerminal(taskId: string, sessionId: string, cb: TaskTerminalCallback): Promise<TaskUnsubscribe>;
publishTerminal(record: TaskRecord): Promise<void>;
subscribeCancel(taskId: string, sessionId: string, cb: TaskCancelCallback): Promise<TaskUnsubscribe>;
publishCancel(taskId: string, sessionId: string): Promise<void>;
destroy?(): Promise<void>;
}
export interface SqliteTaskLogger {
debug?: (message: string, meta?: Record<string, unknown>) => void;
warn?: (message: string, meta?: Record<string, unknown>) => void;
error?: (message: string, meta?: Record<string, unknown>) => void;
}
/** Returns `true` if the process with the given PID is alive. */
export type ProcessLivenessProbe = (pid: number) => boolean;
export interface SqliteTaskStoreOptions extends SqliteStorageOptions {
logger?: SqliteTaskLogger;
/**
* Override the PID liveness check (primarily for tests).
* Defaults to `process.kill(pid, 0)` semantics.
*/
livenessProbe?: ProcessLivenessProbe;
}
export declare class SqliteTaskStore implements TaskStoreInterface {
private readonly db;
private readonly emitter;
private readonly logger?;
private readonly liveness;
private readonly cleanupTimer;
/**
* AES-256-GCM key derived from `options.encryption.secret`, applied to the
* `record_json` column on write and reversed on read. Other columns
* (`task_id`, `session_id`, `status`, `expires_at`, `executor_pid`) are kept
* in plaintext so the indexes remain usable.
*/
private readonly encryptionKey;
private stmts;
constructor(options: SqliteTaskStoreOptions);
private initSchema;
private prepareStatements;
private prepared;
create(record: TaskRecord): Promise<void>;
get(taskId: string, sessionId: string): Promise<TaskRecord | null>;
update(taskId: string, sessionId: string, patch: Partial<TaskRecord>): Promise<TaskRecord | null>;
delete(taskId: string, sessionId: string): Promise<void>;
list(sessionId: string, opts?: {
cursor?: string;
pageSize?: number;
}): Promise<TaskListPage>;
subscribeTerminal(taskId: string, _sessionId: string, cb: TaskTerminalCallback): Promise<TaskUnsubscribe>;
publishTerminal(record: TaskRecord): Promise<void>;
subscribeCancel(taskId: string, _sessionId: string, cb: TaskCancelCallback): Promise<TaskUnsubscribe>;
publishCancel(taskId: string, _sessionId: string): Promise<void>;
destroy(): Promise<void>;
purgeExpired(): number;
getDatabase(): Database.Database;
private rowToRecord;
/**
* Serialize + optionally encrypt a TaskRecord before it lands in the DB.
* Only the `record_json` column is encrypted; indexed columns stay plaintext.
*/
private serializeRecord;
}
//# sourceMappingURL=sqlite-task.store.d.ts.map
{"version":3,"file":"sqlite-task.store.d.ts","sourceRoot":"","sources":["../src/sqlite-task.store.ts"],"names":[],"mappings":"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"}
+274
-1

@@ -374,2 +374,274 @@ var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {

// libs/storage-sqlite/src/sqlite-task.store.ts
import { EventEmitter as EventEmitter2 } from "node:events";
var defaultLivenessProbe = (pid) => {
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
};
var DEFAULT_PAGE_SIZE = 50;
var SqliteTaskStore = class {
db;
emitter = new EventEmitter2();
logger;
liveness;
cleanupTimer;
/**
* AES-256-GCM key derived from `options.encryption.secret`, applied to the
* `record_json` column on write and reversed on read. Other columns
* (`task_id`, `session_id`, `status`, `expires_at`, `executor_pid`) are kept
* in plaintext so the indexes remain usable.
*/
encryptionKey = null;
stmts = null;
constructor(options) {
const BetterSqlite3 = __require("better-sqlite3");
try {
this.db = new BetterSqlite3(options.path);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
throw new Error(`SqliteTaskStore: failed to open database at "${options.path}": ${message}`);
}
if (options.walMode !== false) {
this.db.pragma("journal_mode = WAL");
}
this.logger = options.logger;
this.liveness = options.livenessProbe ?? defaultLivenessProbe;
if (options.encryption?.secret) {
this.encryptionKey = deriveEncryptionKey(options.encryption.secret);
}
this.emitter.setMaxListeners(200);
this.initSchema();
this.prepareStatements();
const cleanupInterval = options.ttlCleanupIntervalMs ?? 6e4;
if (cleanupInterval > 0) {
this.cleanupTimer = setInterval(() => this.purgeExpired(), cleanupInterval);
this.cleanupTimer.unref?.();
} else {
this.cleanupTimer = null;
}
}
initSchema() {
this.db.exec(`
CREATE TABLE IF NOT EXISTS mcp_tasks (
task_id TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
status TEXT NOT NULL,
expires_at INTEGER NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
executor_pid INTEGER,
record_json TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_mcp_tasks_session ON mcp_tasks (session_id);
CREATE INDEX IF NOT EXISTS idx_mcp_tasks_status ON mcp_tasks (status);
CREATE INDEX IF NOT EXISTS idx_mcp_tasks_expires ON mcp_tasks (expires_at);
`);
}
prepareStatements() {
this.stmts = {
insert: this.db.prepare(
"INSERT INTO mcp_tasks (task_id, session_id, status, expires_at, created_at, updated_at, executor_pid, record_json) VALUES (?, ?, ?, ?, ?, ?, ?, ?)"
),
get: this.db.prepare("SELECT * FROM mcp_tasks WHERE task_id = ? AND session_id = ?"),
update: this.db.prepare(
"UPDATE mcp_tasks SET status = ?, expires_at = ?, updated_at = ?, executor_pid = ?, record_json = ? WHERE task_id = ? AND session_id = ?"
),
del: this.db.prepare("DELETE FROM mcp_tasks WHERE task_id = ? AND session_id = ?"),
listBySession: this.db.prepare(
"SELECT * FROM mcp_tasks WHERE session_id = ? AND expires_at > ? ORDER BY created_at ASC, task_id ASC LIMIT ? OFFSET ?"
),
countBySession: this.db.prepare("SELECT COUNT(*) as n FROM mcp_tasks WHERE session_id = ? AND expires_at > ?"),
cleanup: this.db.prepare("DELETE FROM mcp_tasks WHERE expires_at <= ?")
};
}
prepared() {
if (!this.stmts) throw new Error("SqliteTaskStore: prepared statements not initialized");
return this.stmts;
}
// ───────────────────── CRUD ─────────────────────
async create(record) {
if (record.expiresAt <= Date.now()) {
this.logger?.warn?.("[SqliteTaskStore] create: record already expired", { taskId: record.taskId });
return;
}
this.prepared().insert.run(
record.taskId,
record.sessionId,
record.status,
record.expiresAt,
new Date(record.createdAt).getTime(),
new Date(record.lastUpdatedAt).getTime(),
record.executor?.pid ?? null,
this.serializeRecord(record)
);
}
async get(taskId, sessionId) {
const row = this.prepared().get.get(taskId, sessionId);
if (!row) return null;
if (row.expires_at <= Date.now()) {
this.prepared().del.run(taskId, sessionId);
return null;
}
const record = this.rowToRecord(row);
if ((record.status === "working" || record.status === "input_required") && record.executor?.host === "cli" && typeof record.executor.pid === "number" && !this.liveness(record.executor.pid)) {
const patched = await this.update(taskId, sessionId, {
status: "failed",
statusMessage: "Task runner exited before completing the task"
});
if (patched) {
await this.publishTerminal(patched);
return patched;
}
}
return record;
}
async update(taskId, sessionId, patch) {
const row = this.prepared().get.get(taskId, sessionId);
if (!row) return null;
if (row.expires_at <= Date.now()) {
this.prepared().del.run(taskId, sessionId);
return null;
}
const existing = this.rowToRecord(row);
const merged = {
...existing,
...patch,
taskId: existing.taskId,
sessionId: existing.sessionId,
createdAt: existing.createdAt,
lastUpdatedAt: (/* @__PURE__ */ new Date()).toISOString()
};
if (merged.expiresAt <= Date.now()) {
this.prepared().del.run(taskId, sessionId);
return null;
}
this.prepared().update.run(
merged.status,
merged.expiresAt,
new Date(merged.lastUpdatedAt).getTime(),
merged.executor?.pid ?? null,
this.serializeRecord(merged),
taskId,
sessionId
);
return merged;
}
async delete(taskId, sessionId) {
this.prepared().del.run(taskId, sessionId);
}
async list(sessionId, opts = {}) {
const pageSize = Math.max(1, Math.min(opts.pageSize ?? DEFAULT_PAGE_SIZE, 500));
const offset = opts.cursor ? decodeCursor(opts.cursor) : 0;
const rows = this.prepared().listBySession.all(sessionId, Date.now(), pageSize, offset);
const tasks = [];
for (const row of rows) {
let record = this.rowToRecord(row);
if ((record.status === "working" || record.status === "input_required") && record.executor?.host === "cli" && typeof record.executor.pid === "number" && !this.liveness(record.executor.pid)) {
const patched = await this.update(record.taskId, sessionId, {
status: "failed",
statusMessage: "Task runner exited before completing the task"
});
if (patched) {
await this.publishTerminal(patched);
record = patched;
}
}
tasks.push(record);
}
const total = this.prepared().countBySession.get(sessionId, Date.now()).n;
const page = { tasks };
if (offset + rows.length < total) {
page.nextCursor = encodeCursor(offset + rows.length);
}
return page;
}
// ───────────────────── Pub/Sub (in-process) ─────────────────────
async subscribeTerminal(taskId, _sessionId, cb) {
const channel = `terminal:${taskId}`;
const handler = (record) => cb(record);
this.emitter.on(channel, handler);
return async () => {
this.emitter.removeListener(channel, handler);
};
}
async publishTerminal(record) {
this.emitter.emit(`terminal:${record.taskId}`, record);
}
async subscribeCancel(taskId, _sessionId, cb) {
const channel = `cancel:${taskId}`;
const handler = () => cb();
this.emitter.on(channel, handler);
return async () => {
this.emitter.removeListener(channel, handler);
};
}
async publishCancel(taskId, _sessionId) {
this.emitter.emit(`cancel:${taskId}`);
}
// ───────────────────── Misc ─────────────────────
async destroy() {
if (this.cleanupTimer) clearInterval(this.cleanupTimer);
this.emitter.removeAllListeners();
try {
this.db.close();
} catch {
}
}
purgeExpired() {
try {
const res = this.prepared().cleanup.run(Date.now());
return res.changes;
} catch (err) {
this.logger?.warn?.("[SqliteTaskStore] purgeExpired failed", {
error: err instanceof Error ? err.message : String(err)
});
return 0;
}
}
getDatabase() {
return this.db;
}
rowToRecord(row) {
const plaintext = this.encryptionKey ? decryptValue(this.encryptionKey, row.record_json) : row.record_json;
return JSON.parse(plaintext);
}
/**
* Serialize + optionally encrypt a TaskRecord before it lands in the DB.
* Only the `record_json` column is encrypted; indexed columns stay plaintext.
*/
serializeRecord(record) {
const json = JSON.stringify(record);
return this.encryptionKey ? encryptValue(this.encryptionKey, json) : json;
}
};
function encodeCursor(offset) {
return toB64Url(new TextEncoder().encode(JSON.stringify({ offset })));
}
function decodeCursor(cursor) {
try {
const bytes = fromB64Url(cursor);
const parsed = JSON.parse(new TextDecoder().decode(bytes));
if (typeof parsed?.offset === "number" && parsed.offset >= 0 && Number.isInteger(parsed.offset)) {
return parsed.offset;
}
} catch {
}
return 0;
}
function toB64Url(data) {
const b64 = typeof Buffer !== "undefined" ? Buffer.from(data).toString("base64") : btoa(Array.from(data, (b) => String.fromCodePoint(b)).join(""));
return b64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
}
function fromB64Url(data) {
let b64 = data.replace(/-/g, "+").replace(/_/g, "/");
while (b64.length % 4) b64 += "=";
if (typeof Buffer !== "undefined") return new Uint8Array(Buffer.from(b64, "base64"));
const bin = atob(b64);
return Uint8Array.from(bin, (c) => c.codePointAt(0) ?? 0);
}
// libs/storage-sqlite/src/sqlite-event.store.ts

@@ -522,3 +794,3 @@ var SqliteEventStore = class {

// libs/storage-sqlite/src/sqlite.options.ts
import { z } from "zod";
import { z } from "@frontmcp/lazy-zod";
var sqliteStorageOptionsSchema = z.object({

@@ -537,2 +809,3 @@ path: z.string().min(1),

SqliteSessionStore,
SqliteTaskStore,
decryptValue,

@@ -539,0 +812,0 @@ deriveEncryptionKey,

+3
-3
{
"name": "@frontmcp/storage-sqlite",
"version": "1.0.4",
"version": "1.1.0-beta.1",
"description": "SQLite storage backend for FrontMCP - local session, elicitation, and event persistence without Redis",

@@ -47,7 +47,7 @@ "author": "AgentFront <info@agentfront.dev>",

"dependencies": {
"@frontmcp/utils": "1.0.4",
"@frontmcp/utils": "1.1.0-beta.1",
"better-sqlite3": "^12.6.2"
},
"peerDependencies": {
"zod": "^4.0.0"
"@frontmcp/lazy-zod": "1.1.0-beta.1"
},

@@ -54,0 +54,0 @@ "devDependencies": {

@@ -12,2 +12,4 @@ /**

export type { SqliteElicitationStoreOptions, ElicitationStoreInterface } from './sqlite-elicitation.store';
export { SqliteTaskStore } from './sqlite-task.store';
export type { SqliteTaskStoreOptions, TaskStoreInterface, TaskRecord as SqliteTaskRecord, TaskStatus as SqliteTaskStatus, TaskOutcome as SqliteTaskOutcome, TaskListPage as SqliteTaskListPage, ProcessLivenessProbe, SqliteTaskLogger, } from './sqlite-task.store';
export { SqliteEventStore } from './sqlite-event.store';

@@ -14,0 +16,0 @@ export type { SqliteEventStoreOptions, EventStoreInterface } from './sqlite-event.store';

@@ -1,1 +0,1 @@

{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,EAAE,aAAa,EAAE,MAAM,mBAAmB,CAAC;AAClD,OAAO,EAAE,kBAAkB,EAAE,MAAM,wBAAwB,CAAC;AAC5D,YAAY,EAAE,yBAAyB,EAAE,qBAAqB,EAAE,iBAAiB,EAAE,MAAM,wBAAwB,CAAC;AAClH,OAAO,EAAE,sBAAsB,EAAE,MAAM,4BAA4B,CAAC;AACpE,YAAY,EAAE,6BAA6B,EAAE,yBAAyB,EAAE,MAAM,4BAA4B,CAAC;AAC3G,OAAO,EAAE,gBAAgB,EAAE,MAAM,sBAAsB,CAAC;AACxD,YAAY,EAAE,uBAAuB,EAAE,mBAAmB,EAAE,MAAM,sBAAsB,CAAC;AACzF,OAAO,EAAE,mBAAmB,EAAE,YAAY,EAAE,YAAY,EAAE,MAAM,cAAc,CAAC;AAC/E,OAAO,EAAE,0BAA0B,EAAE,MAAM,kBAAkB,CAAC;AAC9D,YAAY,EAAE,oBAAoB,EAAE,yBAAyB,EAAE,0BAA0B,EAAE,MAAM,kBAAkB,CAAC"}
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,EAAE,aAAa,EAAE,MAAM,mBAAmB,CAAC;AAClD,OAAO,EAAE,kBAAkB,EAAE,MAAM,wBAAwB,CAAC;AAC5D,YAAY,EAAE,yBAAyB,EAAE,qBAAqB,EAAE,iBAAiB,EAAE,MAAM,wBAAwB,CAAC;AAClH,OAAO,EAAE,sBAAsB,EAAE,MAAM,4BAA4B,CAAC;AACpE,YAAY,EAAE,6BAA6B,EAAE,yBAAyB,EAAE,MAAM,4BAA4B,CAAC;AAC3G,OAAO,EAAE,eAAe,EAAE,MAAM,qBAAqB,CAAC;AACtD,YAAY,EACV,sBAAsB,EACtB,kBAAkB,EAClB,UAAU,IAAI,gBAAgB,EAC9B,UAAU,IAAI,gBAAgB,EAC9B,WAAW,IAAI,iBAAiB,EAChC,YAAY,IAAI,kBAAkB,EAClC,oBAAoB,EACpB,gBAAgB,GACjB,MAAM,qBAAqB,CAAC;AAC7B,OAAO,EAAE,gBAAgB,EAAE,MAAM,sBAAsB,CAAC;AACxD,YAAY,EAAE,uBAAuB,EAAE,mBAAmB,EAAE,MAAM,sBAAsB,CAAC;AACzF,OAAO,EAAE,mBAAmB,EAAE,YAAY,EAAE,YAAY,EAAE,MAAM,cAAc,CAAC;AAC/E,OAAO,EAAE,0BAA0B,EAAE,MAAM,kBAAkB,CAAC;AAC9D,YAAY,EAAE,oBAAoB,EAAE,yBAAyB,EAAE,0BAA0B,EAAE,MAAM,kBAAkB,CAAC"}
+281
-7

@@ -27,2 +27,3 @@ "use strict";

SqliteSessionStore: () => SqliteSessionStore,
SqliteTaskStore: () => SqliteTaskStore,
decryptValue: () => decryptValue,

@@ -394,2 +395,274 @@ deriveEncryptionKey: () => deriveEncryptionKey,

// libs/storage-sqlite/src/sqlite-task.store.ts
var import_node_events2 = require("node:events");
var defaultLivenessProbe = (pid) => {
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
};
var DEFAULT_PAGE_SIZE = 50;
var SqliteTaskStore = class {
db;
emitter = new import_node_events2.EventEmitter();
logger;
liveness;
cleanupTimer;
/**
* AES-256-GCM key derived from `options.encryption.secret`, applied to the
* `record_json` column on write and reversed on read. Other columns
* (`task_id`, `session_id`, `status`, `expires_at`, `executor_pid`) are kept
* in plaintext so the indexes remain usable.
*/
encryptionKey = null;
stmts = null;
constructor(options) {
const BetterSqlite3 = require("better-sqlite3");
try {
this.db = new BetterSqlite3(options.path);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
throw new Error(`SqliteTaskStore: failed to open database at "${options.path}": ${message}`);
}
if (options.walMode !== false) {
this.db.pragma("journal_mode = WAL");
}
this.logger = options.logger;
this.liveness = options.livenessProbe ?? defaultLivenessProbe;
if (options.encryption?.secret) {
this.encryptionKey = deriveEncryptionKey(options.encryption.secret);
}
this.emitter.setMaxListeners(200);
this.initSchema();
this.prepareStatements();
const cleanupInterval = options.ttlCleanupIntervalMs ?? 6e4;
if (cleanupInterval > 0) {
this.cleanupTimer = setInterval(() => this.purgeExpired(), cleanupInterval);
this.cleanupTimer.unref?.();
} else {
this.cleanupTimer = null;
}
}
initSchema() {
this.db.exec(`
CREATE TABLE IF NOT EXISTS mcp_tasks (
task_id TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
status TEXT NOT NULL,
expires_at INTEGER NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
executor_pid INTEGER,
record_json TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_mcp_tasks_session ON mcp_tasks (session_id);
CREATE INDEX IF NOT EXISTS idx_mcp_tasks_status ON mcp_tasks (status);
CREATE INDEX IF NOT EXISTS idx_mcp_tasks_expires ON mcp_tasks (expires_at);
`);
}
prepareStatements() {
this.stmts = {
insert: this.db.prepare(
"INSERT INTO mcp_tasks (task_id, session_id, status, expires_at, created_at, updated_at, executor_pid, record_json) VALUES (?, ?, ?, ?, ?, ?, ?, ?)"
),
get: this.db.prepare("SELECT * FROM mcp_tasks WHERE task_id = ? AND session_id = ?"),
update: this.db.prepare(
"UPDATE mcp_tasks SET status = ?, expires_at = ?, updated_at = ?, executor_pid = ?, record_json = ? WHERE task_id = ? AND session_id = ?"
),
del: this.db.prepare("DELETE FROM mcp_tasks WHERE task_id = ? AND session_id = ?"),
listBySession: this.db.prepare(
"SELECT * FROM mcp_tasks WHERE session_id = ? AND expires_at > ? ORDER BY created_at ASC, task_id ASC LIMIT ? OFFSET ?"
),
countBySession: this.db.prepare("SELECT COUNT(*) as n FROM mcp_tasks WHERE session_id = ? AND expires_at > ?"),
cleanup: this.db.prepare("DELETE FROM mcp_tasks WHERE expires_at <= ?")
};
}
prepared() {
if (!this.stmts) throw new Error("SqliteTaskStore: prepared statements not initialized");
return this.stmts;
}
// ───────────────────── CRUD ─────────────────────
async create(record) {
if (record.expiresAt <= Date.now()) {
this.logger?.warn?.("[SqliteTaskStore] create: record already expired", { taskId: record.taskId });
return;
}
this.prepared().insert.run(
record.taskId,
record.sessionId,
record.status,
record.expiresAt,
new Date(record.createdAt).getTime(),
new Date(record.lastUpdatedAt).getTime(),
record.executor?.pid ?? null,
this.serializeRecord(record)
);
}
async get(taskId, sessionId) {
const row = this.prepared().get.get(taskId, sessionId);
if (!row) return null;
if (row.expires_at <= Date.now()) {
this.prepared().del.run(taskId, sessionId);
return null;
}
const record = this.rowToRecord(row);
if ((record.status === "working" || record.status === "input_required") && record.executor?.host === "cli" && typeof record.executor.pid === "number" && !this.liveness(record.executor.pid)) {
const patched = await this.update(taskId, sessionId, {
status: "failed",
statusMessage: "Task runner exited before completing the task"
});
if (patched) {
await this.publishTerminal(patched);
return patched;
}
}
return record;
}
async update(taskId, sessionId, patch) {
const row = this.prepared().get.get(taskId, sessionId);
if (!row) return null;
if (row.expires_at <= Date.now()) {
this.prepared().del.run(taskId, sessionId);
return null;
}
const existing = this.rowToRecord(row);
const merged = {
...existing,
...patch,
taskId: existing.taskId,
sessionId: existing.sessionId,
createdAt: existing.createdAt,
lastUpdatedAt: (/* @__PURE__ */ new Date()).toISOString()
};
if (merged.expiresAt <= Date.now()) {
this.prepared().del.run(taskId, sessionId);
return null;
}
this.prepared().update.run(
merged.status,
merged.expiresAt,
new Date(merged.lastUpdatedAt).getTime(),
merged.executor?.pid ?? null,
this.serializeRecord(merged),
taskId,
sessionId
);
return merged;
}
async delete(taskId, sessionId) {
this.prepared().del.run(taskId, sessionId);
}
async list(sessionId, opts = {}) {
const pageSize = Math.max(1, Math.min(opts.pageSize ?? DEFAULT_PAGE_SIZE, 500));
const offset = opts.cursor ? decodeCursor(opts.cursor) : 0;
const rows = this.prepared().listBySession.all(sessionId, Date.now(), pageSize, offset);
const tasks = [];
for (const row of rows) {
let record = this.rowToRecord(row);
if ((record.status === "working" || record.status === "input_required") && record.executor?.host === "cli" && typeof record.executor.pid === "number" && !this.liveness(record.executor.pid)) {
const patched = await this.update(record.taskId, sessionId, {
status: "failed",
statusMessage: "Task runner exited before completing the task"
});
if (patched) {
await this.publishTerminal(patched);
record = patched;
}
}
tasks.push(record);
}
const total = this.prepared().countBySession.get(sessionId, Date.now()).n;
const page = { tasks };
if (offset + rows.length < total) {
page.nextCursor = encodeCursor(offset + rows.length);
}
return page;
}
// ───────────────────── Pub/Sub (in-process) ─────────────────────
async subscribeTerminal(taskId, _sessionId, cb) {
const channel = `terminal:${taskId}`;
const handler = (record) => cb(record);
this.emitter.on(channel, handler);
return async () => {
this.emitter.removeListener(channel, handler);
};
}
async publishTerminal(record) {
this.emitter.emit(`terminal:${record.taskId}`, record);
}
async subscribeCancel(taskId, _sessionId, cb) {
const channel = `cancel:${taskId}`;
const handler = () => cb();
this.emitter.on(channel, handler);
return async () => {
this.emitter.removeListener(channel, handler);
};
}
async publishCancel(taskId, _sessionId) {
this.emitter.emit(`cancel:${taskId}`);
}
// ───────────────────── Misc ─────────────────────
async destroy() {
if (this.cleanupTimer) clearInterval(this.cleanupTimer);
this.emitter.removeAllListeners();
try {
this.db.close();
} catch {
}
}
purgeExpired() {
try {
const res = this.prepared().cleanup.run(Date.now());
return res.changes;
} catch (err) {
this.logger?.warn?.("[SqliteTaskStore] purgeExpired failed", {
error: err instanceof Error ? err.message : String(err)
});
return 0;
}
}
getDatabase() {
return this.db;
}
rowToRecord(row) {
const plaintext = this.encryptionKey ? decryptValue(this.encryptionKey, row.record_json) : row.record_json;
return JSON.parse(plaintext);
}
/**
* Serialize + optionally encrypt a TaskRecord before it lands in the DB.
* Only the `record_json` column is encrypted; indexed columns stay plaintext.
*/
serializeRecord(record) {
const json = JSON.stringify(record);
return this.encryptionKey ? encryptValue(this.encryptionKey, json) : json;
}
};
function encodeCursor(offset) {
return toB64Url(new TextEncoder().encode(JSON.stringify({ offset })));
}
function decodeCursor(cursor) {
try {
const bytes = fromB64Url(cursor);
const parsed = JSON.parse(new TextDecoder().decode(bytes));
if (typeof parsed?.offset === "number" && parsed.offset >= 0 && Number.isInteger(parsed.offset)) {
return parsed.offset;
}
} catch {
}
return 0;
}
function toB64Url(data) {
const b64 = typeof Buffer !== "undefined" ? Buffer.from(data).toString("base64") : btoa(Array.from(data, (b) => String.fromCodePoint(b)).join(""));
return b64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
}
function fromB64Url(data) {
let b64 = data.replace(/-/g, "+").replace(/_/g, "/");
while (b64.length % 4) b64 += "=";
if (typeof Buffer !== "undefined") return new Uint8Array(Buffer.from(b64, "base64"));
const bin = atob(b64);
return Uint8Array.from(bin, (c) => c.codePointAt(0) ?? 0);
}
// libs/storage-sqlite/src/sqlite-event.store.ts

@@ -542,10 +815,10 @@ var SqliteEventStore = class {

// libs/storage-sqlite/src/sqlite.options.ts
var import_zod = require("zod");
var sqliteStorageOptionsSchema = import_zod.z.object({
path: import_zod.z.string().min(1),
encryption: import_zod.z.object({
secret: import_zod.z.string().min(1)
var import_lazy_zod = require("@frontmcp/lazy-zod");
var sqliteStorageOptionsSchema = import_lazy_zod.z.object({
path: import_lazy_zod.z.string().min(1),
encryption: import_lazy_zod.z.object({
secret: import_lazy_zod.z.string().min(1)
}).optional(),
ttlCleanupIntervalMs: import_zod.z.number().int().nonnegative().optional().default(6e4),
walMode: import_zod.z.boolean().optional().default(true)
ttlCleanupIntervalMs: import_lazy_zod.z.number().int().nonnegative().optional().default(6e4),
walMode: import_lazy_zod.z.boolean().optional().default(true)
});

@@ -558,2 +831,3 @@ // Annotate the CommonJS export names for ESM import in node:

SqliteSessionStore,
SqliteTaskStore,
decryptValue,

@@ -560,0 +834,0 @@ deriveEncryptionKey,

{
"name": "@frontmcp/storage-sqlite",
"version": "1.0.4",
"version": "1.1.0-beta.1",
"description": "SQLite storage backend for FrontMCP - local session, elicitation, and event persistence without Redis",

@@ -47,7 +47,7 @@ "author": "AgentFront <info@agentfront.dev>",

"dependencies": {
"@frontmcp/utils": "1.0.4",
"@frontmcp/utils": "1.1.0-beta.1",
"better-sqlite3": "^12.6.2"
},
"peerDependencies": {
"zod": "^4.0.0"
"@frontmcp/lazy-zod": "1.1.0-beta.1"
},

@@ -54,0 +54,0 @@ "devDependencies": {

@@ -6,3 +6,3 @@ /**

*/
import { z } from 'zod';
import { z } from '@frontmcp/lazy-zod';
/**

@@ -36,10 +36,10 @@ * SQLite storage configuration options.

*/
export declare const sqliteStorageOptionsSchema: z.ZodObject<{
path: z.ZodString;
encryption: z.ZodOptional<z.ZodObject<{
secret: z.ZodString;
}, z.core.$strip>>;
ttlCleanupIntervalMs: z.ZodDefault<z.ZodOptional<z.ZodNumber>>;
walMode: z.ZodDefault<z.ZodOptional<z.ZodBoolean>>;
}, z.core.$strip>;
export declare const sqliteStorageOptionsSchema: import("@frontmcp/lazy-zod").ZodObject<{
path: import("@frontmcp/lazy-zod").ZodString;
encryption: import("@frontmcp/lazy-zod").ZodOptional<import("@frontmcp/lazy-zod").ZodObject<{
secret: import("@frontmcp/lazy-zod").ZodString;
}, import("zod/v4/core").$strip>>;
ttlCleanupIntervalMs: import("@frontmcp/lazy-zod").ZodDefault<import("@frontmcp/lazy-zod").ZodOptional<import("@frontmcp/lazy-zod").ZodNumber>>;
walMode: import("@frontmcp/lazy-zod").ZodDefault<import("@frontmcp/lazy-zod").ZodOptional<import("@frontmcp/lazy-zod").ZodBoolean>>;
}, import("zod/v4/core").$strip>;
/**

@@ -46,0 +46,0 @@ * SQLite storage input type (before Zod defaults).

@@ -1,1 +0,1 @@

{"version":3,"file":"sqlite.options.d.ts","sourceRoot":"","sources":["../src/sqlite.options.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB;;GAEG;AACH,MAAM,WAAW,oBAAoB;IACnC,wCAAwC;IACxC,IAAI,EAAE,MAAM,CAAC;IAEb;;;OAGG;IACH,UAAU,CAAC,EAAE;QACX,qEAAqE;QACrE,MAAM,EAAE,MAAM,CAAC;KAChB,CAAC;IAEF;;;OAGG;IACH,oBAAoB,CAAC,EAAE,MAAM,CAAC;IAE9B;;;OAGG;IACH,OAAO,CAAC,EAAE,OAAO,CAAC;CACnB;AAED;;GAEG;AACH,eAAO,MAAM,0BAA0B;;;;;;;iBASrC,CAAC;AAEH;;GAEG;AACH,MAAM,MAAM,yBAAyB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,0BAA0B,CAAC,CAAC;AAEnF;;GAEG;AACH,MAAM,MAAM,0BAA0B,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,0BAA0B,CAAC,CAAC"}
{"version":3,"file":"sqlite.options.d.ts","sourceRoot":"","sources":["../src/sqlite.options.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,EAAE,CAAC,EAAE,MAAM,oBAAoB,CAAC;AAEvC;;GAEG;AACH,MAAM,WAAW,oBAAoB;IACnC,wCAAwC;IACxC,IAAI,EAAE,MAAM,CAAC;IAEb;;;OAGG;IACH,UAAU,CAAC,EAAE;QACX,qEAAqE;QACrE,MAAM,EAAE,MAAM,CAAC;KAChB,CAAC;IAEF;;;OAGG;IACH,oBAAoB,CAAC,EAAE,MAAM,CAAC;IAE9B;;;OAGG;IACH,OAAO,CAAC,EAAE,OAAO,CAAC;CACnB;AAED;;GAEG;AACH,eAAO,MAAM,0BAA0B;;;;;;;gCASrC,CAAC;AAEH;;GAEG;AACH,MAAM,MAAM,yBAAyB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,0BAA0B,CAAC,CAAC;AAEnF;;GAEG;AACH,MAAM,MAAM,0BAA0B,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,0BAA0B,CAAC,CAAC"}