Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 29 additions & 1 deletion apps/desktop/src/renderer/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -438,12 +438,40 @@ function NotesApp() {
// Plugin runtime: init once, React observes
const discoveredPlugins = useStore(pluginRuntimeStore, s => s.plugins);
const pluginErrors = useStore(pluginRuntimeStore, s => s.errors);
const [builtInEnabledMap, setBuiltInEnabledMap] = useState<Record<string, boolean>>({});

useEffect(() => {
void pluginRuntimeStore.getState().init();
// Load built-in plugin enabled states
void (async () => {
const stateList = await window.readied.plugins.listState();
const map: Record<string, boolean> = {};
for (const s of stateList) {
map[s.pluginId] = s.enabled;
}
setBuiltInEnabledMap(map);
})();
}, []);

// Re-check built-in enabled state when plugins reload
useEffect(() => {
const handler = () => {
void (async () => {
const stateList = await window.readied.plugins.listState();
const map: Record<string, boolean> = {};
for (const s of stateList) {
map[s.pluginId] = s.enabled;
}
setBuiltInEnabledMap(map);
})();
};
return window.readied.ipc.on('plugins:reload', handler);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid clearing shared reload listeners on App cleanup

This subscription returns a cleanup from readied.ipc.on, and that API currently removes all listeners for the channel (removeAllListeners) rather than just this handler. During an App unmount/remount cycle (notably React StrictMode in development), cleanup here removes pluginRuntimeStore's own plugins:reload listener as well; because that store marks its listener as already attached, it does not re-register, so later reload events stop reloading runtime plugins.

Useful? React with 👍 / 👎.

}, []);

const allPlugins = useMemo(() => [...builtInPlugins, ...discoveredPlugins], [discoveredPlugins]);
const allPlugins = useMemo(() => {
const enabledBuiltIn = builtInPlugins.filter(p => builtInEnabledMap[p.id] !== false);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Gate built-in plugins until enabled state is loaded

This filter defaults every built-in plugin to enabled while builtInEnabledMap is still empty, so PluginHost mounts disabled built-ins during initial render and only unloads them after the async listState() call finishes in the mount effect. In the startup path where a user has previously disabled a built-in plugin, its activate() logic still runs once per launch, which can cause unwanted side effects and contradicts the expected disabled behavior.

Useful? React with 👍 / 👎.

return [...enabledBuiltIn, ...discoveredPlugins];
}, [discoveredPlugins, builtInEnabledMap]);

const configBridge = useMemo(
() => ({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,6 @@ export function PluginCard({
id={`plugin-${name.toLowerCase().replace(/\s+/g, '-')}`}
checked={enabled}
onChange={checked => onToggle?.(checked)}
disabled={isBuiltIn}
/>
{!isBuiltIn && onUninstall && (
<button
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ export function PluginsSection() {
const [pluginsPath, setPluginsPath] = useState('');
const [isReloading, setIsReloading] = useState(false);
const [configValues, setConfigValues] = useState<Record<string, Record<string, unknown>>>({});
const [builtInEnabled, setBuiltInEnabled] = useState<Record<string, boolean>>({});
const [refreshKey, setRefreshKey] = useState(0);

// Listen for plugin install events from BrowseTab
Expand Down Expand Up @@ -77,6 +78,13 @@ export function PluginsSection() {
}));
setPlugins(pluginList);

// Load built-in plugin enabled states from DB
const builtInStates: Record<string, boolean> = {};
for (const bp of builtInPlugins) {
builtInStates[bp.id] = stateMap.get(bp.id) ?? true;
}
setBuiltInEnabled(builtInStates);

// Load config values for all plugins with schemas (built-in + community)
const configs: Record<string, Record<string, unknown>> = {};

Expand Down Expand Up @@ -111,11 +119,14 @@ export function PluginsSection() {
void loadPlugins();
}, [refreshKey]);

// Toggle plugin enabled/disabled
// Toggle plugin enabled/disabled (works for both built-in and community)
const handleToggle = useCallback(async (pluginId: string, enabled: boolean) => {
try {
await window.readied.plugins.setEnabled(pluginId, enabled);
// Update community plugins state
setPlugins(prev => prev.map(p => (p.id === pluginId ? { ...p, enabled } : p)));
// Update built-in plugins state
setBuiltInEnabled(prev => ({ ...prev, [pluginId]: enabled }));
// Trigger reload in main window so preview updates immediately
window.readied.plugins.requestReload();
toast.success(`Plugin ${enabled ? 'enabled' : 'disabled'}`);
Expand Down Expand Up @@ -293,7 +304,8 @@ export function PluginsSection() {
version={plugin.version}
description={plugin.description}
isBuiltIn={true}
enabled={true}
enabled={builtInEnabled[plugin.id] ?? true}
onToggle={enabled => handleToggle(plugin.id, enabled)}
configSchema={BUILT_IN_CONFIG_SCHEMAS[plugin.id]}
configValues={configValues[plugin.id]}
onConfigChange={(key, value) => handleConfigChange(plugin.id, key, value)}
Expand Down
9 changes: 6 additions & 3 deletions packages/mcp-server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,16 +14,19 @@
},
"scripts": {
"build": "tsc",
"dev": "tsx src/index.ts"
"dev": "tsx src/index.ts",
"test": "vitest run"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.29.0",
"sql.js": "^1.14.1",
"better-sqlite3": "^11.7.0",
"zod": "^4.3.6"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.0",
"@types/node": "^22.0.0",
"tsx": "^4.19.0",
"typescript": "^5.7.0"
"typescript": "^5.7.0",
"vitest": "^3.2.1"
}
}
157 changes: 157 additions & 0 deletions packages/mcp-server/src/__tests__/fts5-triggers.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import Database from 'better-sqlite3';
import { openDb } from '../db.js';

/**
* Minimal schema that mirrors the real Readied database enough to
* exercise the FTS5 triggers that caused "no such module: fts5"
* when the MCP server used sql.js (which lacks FTS5).
*/
const SCHEMA = `
CREATE TABLE notes (
id TEXT PRIMARY KEY,
content TEXT NOT NULL,
title TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
word_count INTEGER NOT NULL DEFAULT 0,
notebook_id TEXT DEFAULT 'inbox',
is_pinned INTEGER DEFAULT 0,
is_deleted INTEGER DEFAULT 0,
status TEXT DEFAULT 'active',
needs_sync INTEGER DEFAULT 0,
local_version INTEGER DEFAULT 1,
sync_version INTEGER DEFAULT 0
);

CREATE TABLE notebooks (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
parent_id TEXT,
depth INTEGER NOT NULL DEFAULT 0,
"order" INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);

INSERT INTO notebooks (id, name, created_at, updated_at)
VALUES ('inbox', 'Inbox', datetime('now'), datetime('now'));

-- FTS5 virtual table (migration 008)
CREATE VIRTUAL TABLE notes_fts USING fts5(
id UNINDEXED,
title,
content,
tokenize='porter unicode61'
);

-- Trigger: sync FTS on INSERT
CREATE TRIGGER notes_fts_insert AFTER INSERT ON notes
WHEN NEW.is_deleted = 0 OR NEW.is_deleted IS NULL
BEGIN
INSERT INTO notes_fts(id, title, content)
VALUES (NEW.id, NEW.title, NEW.content);
END;

-- Trigger: sync FTS on UPDATE (delete + re-insert)
CREATE TRIGGER notes_fts_update AFTER UPDATE ON notes
BEGIN
DELETE FROM notes_fts WHERE id = OLD.id;
INSERT INTO notes_fts(id, title, content)
SELECT NEW.id, NEW.title, NEW.content
WHERE NEW.is_deleted = 0 OR NEW.is_deleted IS NULL;
END;

-- Trigger: sync FTS on DELETE
CREATE TRIGGER notes_fts_delete AFTER DELETE ON notes
BEGIN
DELETE FROM notes_fts WHERE id = OLD.id;
END;
`;

describe('FTS5 trigger execution', () => {
let db: Database.Database;

beforeEach(() => {
db = new Database(':memory:');
db.exec(SCHEMA);
});

afterEach(() => {
db.close();
});

it('INSERT fires notes_fts_insert trigger without error', () => {
const now = new Date().toISOString();

expect(() => {
db.prepare(
`INSERT INTO notes (id, content, title, created_at, updated_at, word_count, notebook_id, status, needs_sync, local_version, sync_version)
VALUES (?, ?, ?, ?, ?, ?, ?, 'active', 1, 1, 0)`
).run('note-1', '# Test Note\n\nHello world', 'Test Note', now, now, 2, 'inbox');
}).not.toThrow();

const ftsRow = db.prepare('SELECT * FROM notes_fts WHERE notes_fts MATCH ?').get('hello');
expect(ftsRow).toBeTruthy();
});

it('UPDATE fires notes_fts_update trigger without error', () => {
const now = new Date().toISOString();
db.prepare(
`INSERT INTO notes (id, content, title, created_at, updated_at, word_count)
VALUES (?, ?, ?, ?, ?, ?)`
).run('note-1', '# Original\n\nOriginal content', 'Original', now, now, 2);

expect(() => {
db.prepare(
'UPDATE notes SET content = ?, title = ?, updated_at = ?, word_count = ?, needs_sync = 1, local_version = local_version + 1 WHERE id = ?'
).run('# Updated\n\nBrand new content', 'Updated', now, 3, 'note-1');
}).not.toThrow();

const oldMatch = db.prepare('SELECT * FROM notes_fts WHERE notes_fts MATCH ?').get('original');
expect(oldMatch).toBeUndefined();

const newMatch = db.prepare('SELECT * FROM notes_fts WHERE notes_fts MATCH ?').get('brand');
expect(newMatch).toBeTruthy();
});

it('soft-delete UPDATE removes entry from FTS index', () => {
const now = new Date().toISOString();
db.prepare(
`INSERT INTO notes (id, content, title, created_at, updated_at, word_count)
VALUES (?, ?, ?, ?, ?, ?)`
).run('note-1', '# Trashable\n\nGoing away', 'Trashable', now, now, 2);

expect(() => {
db.prepare(
'UPDATE notes SET is_deleted = 1, updated_at = ?, needs_sync = 1 WHERE id = ?'
).run(now, 'note-1');
}).not.toThrow();

const match = db.prepare('SELECT * FROM notes_fts WHERE notes_fts MATCH ?').get('trashable');
expect(match).toBeUndefined();
});

it('DELETE fires notes_fts_delete trigger without error', () => {
const now = new Date().toISOString();
db.prepare(
`INSERT INTO notes (id, content, title, created_at, updated_at, word_count)
VALUES (?, ?, ?, ?, ?, ?)`
).run('note-1', '# Deletable\n\nWill be removed', 'Deletable', now, now, 3);

expect(() => {
db.prepare('DELETE FROM notes WHERE id = ?').run('note-1');
}).not.toThrow();

const match = db.prepare('SELECT * FROM notes_fts WHERE notes_fts MATCH ?').get('deletable');
expect(match).toBeUndefined();
});
});

describe('FTS5 runtime check', () => {
it('openDb succeeds with an in-memory database (FTS5 available)', () => {
const db = openDb(':memory:');
expect(db).toBeTruthy();
db.close();
});
});
50 changes: 32 additions & 18 deletions packages/mcp-server/src/db.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,19 @@
/**
* Database connection for the MCP server.
*
* Opens the Readied SQLite database using sql.js (WASM-based).
* This avoids native module conflicts with Electron's better-sqlite3.
* Opens the Readied SQLite database using better-sqlite3 (native).
* The MCP server runs as a standalone Node.js process, so native
* modules work without Electron conflicts. This gives full feature
* parity with the desktop app, including FTS5 support and WAL
* concurrency for safe concurrent access to the same DB file.
*/

import initSqlJs, { type Database } from 'sql.js';
import { existsSync, readFileSync, writeFileSync } from 'fs';
import Database from 'better-sqlite3';
import { existsSync } from 'fs';
import { join } from 'path';
import { homedir } from 'os';

export type { Database } from 'sql.js';

let dbPath: string;
export type { Database as BetterSqlite3Database } from 'better-sqlite3';

function getDbPath(): string {
if (process.env.READIED_DB_PATH) {
Expand Down Expand Up @@ -49,17 +50,30 @@ function getDbPath(): string {
);
}

export async function openDb(): Promise<Database> {
dbPath = getDbPath();
const SQL = await initSqlJs();
const buffer = readFileSync(dbPath);
return new SQL.Database(buffer);
}

/**
* Save the database back to disk after writes.
* Verify that the SQLite build includes FTS5.
* Fails loudly at startup so the error is obvious, rather than
* surfacing later as a cryptic trigger failure on write operations.
*/
export function saveDb(db: Database): void {
const data = db.export();
writeFileSync(dbPath, Buffer.from(data));
function assertFts5Available(db: Database.Database): void {
try {
db.prepare('CREATE VIRTUAL TABLE _fts5_check USING fts5(x)').run();
db.prepare('DROP TABLE _fts5_check').run();
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
throw new Error(
`FTS5 module is not available in this SQLite build.\n` +
`The Readied database uses FTS5 for full-text search triggers.\n` +
`Without FTS5, write operations (create/update/delete notes) will fail.\n` +
`Original error: ${message}`
);
}
}

export function openDb(dbPath?: string): Database.Database {
const resolvedPath = dbPath ?? getDbPath();
const db = new Database(resolvedPath);
db.pragma('journal_mode = WAL');
assertFts5Available(db);
return db;
}
Loading
Loading