ofw-mcp 2.4.4 → 2.6.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.
package/dist/cache.js DELETED
@@ -1,345 +0,0 @@
1
- import { DatabaseSync } from 'node:sqlite';
2
- import { mkdirSync, chmodSync, existsSync } from 'node:fs';
3
- import { dirname } from 'node:path';
4
- import { getCacheDbPath } from './config.js';
5
- let instance = null;
6
- const SCHEMA_V1 = `
7
- CREATE TABLE IF NOT EXISTS messages (
8
- id INTEGER PRIMARY KEY,
9
- folder TEXT NOT NULL,
10
- subject TEXT NOT NULL,
11
- from_user TEXT NOT NULL,
12
- sent_at TEXT NOT NULL,
13
- recipients_json TEXT NOT NULL,
14
- body TEXT,
15
- fetched_body_at TEXT,
16
- reply_to_id INTEGER,
17
- chain_root_id INTEGER,
18
- list_data_json TEXT NOT NULL,
19
- last_seen_at TEXT NOT NULL
20
- );
21
- CREATE INDEX IF NOT EXISTS idx_messages_folder_sent_at ON messages(folder, sent_at DESC);
22
- CREATE INDEX IF NOT EXISTS idx_messages_chain_root ON messages(chain_root_id);
23
-
24
- CREATE TABLE IF NOT EXISTS drafts (
25
- id INTEGER PRIMARY KEY,
26
- subject TEXT NOT NULL,
27
- body TEXT NOT NULL,
28
- recipients_json TEXT NOT NULL,
29
- reply_to_id INTEGER,
30
- modified_at TEXT NOT NULL,
31
- list_data_json TEXT NOT NULL
32
- );
33
-
34
- CREATE TABLE IF NOT EXISTS sync_state (
35
- folder TEXT PRIMARY KEY,
36
- last_sync_at TEXT NOT NULL,
37
- newest_id INTEGER
38
- );
39
-
40
- CREATE TABLE IF NOT EXISTS meta (
41
- key TEXT PRIMARY KEY,
42
- value TEXT NOT NULL
43
- );
44
- `;
45
- // v2: add attachments table. Idempotent — IF NOT EXISTS.
46
- const SCHEMA_V2 = `
47
- CREATE TABLE IF NOT EXISTS attachments (
48
- file_id INTEGER PRIMARY KEY,
49
- file_name TEXT NOT NULL,
50
- label TEXT NOT NULL,
51
- mime_type TEXT NOT NULL,
52
- size_bytes INTEGER,
53
- metadata_json TEXT NOT NULL,
54
- message_ids_json TEXT NOT NULL, -- JSON array of message ids that reference this file
55
- downloaded_path TEXT, -- absolute path on disk if/when downloaded
56
- downloaded_at TEXT,
57
- fetched_metadata_at TEXT NOT NULL
58
- );
59
- `;
60
- function migrate(db) {
61
- db.exec(SCHEMA_V1);
62
- db.exec(SCHEMA_V2);
63
- db.prepare('INSERT INTO meta(key, value) VALUES(?, ?) ON CONFLICT(key) DO UPDATE SET value=excluded.value').run('schema_version', '2');
64
- }
65
- // The cache holds full co-parenting message history — keep it private to the
66
- // owning user. Modes are asserted on every open (not just creation): mkdirSync
67
- // `mode` and SQLite's default file mode only apply when the path is first
68
- // created, so a pre-existing dir/db keeps whatever (world-readable) mode it
69
- // had. The -wal/-shm siblings appear and disappear with WAL checkpoints, hence
70
- // the existence check.
71
- function enforceCachePermissions(dbPath) {
72
- chmodSync(dirname(dbPath), 0o700);
73
- chmodSync(dbPath, 0o600);
74
- for (const sibling of [`${dbPath}-wal`, `${dbPath}-shm`]) {
75
- if (existsSync(sibling))
76
- chmodSync(sibling, 0o600);
77
- }
78
- }
79
- export function openCache() {
80
- if (instance)
81
- return instance;
82
- const path = getCacheDbPath();
83
- mkdirSync(dirname(path), { recursive: true });
84
- const db = new DatabaseSync(path);
85
- // First pass: lock down dir + db before WAL siblings exist.
86
- enforceCachePermissions(path);
87
- db.exec('PRAGMA journal_mode = WAL');
88
- db.exec('PRAGMA foreign_keys = ON');
89
- migrate(db);
90
- // Second pass: the migration writes created -wal/-shm — lock those down too.
91
- enforceCachePermissions(path);
92
- instance = { db };
93
- return instance;
94
- }
95
- export function closeCache() {
96
- if (instance) {
97
- instance.db.close();
98
- instance = null;
99
- }
100
- }
101
- function rowFromDb(r) {
102
- return {
103
- id: r.id,
104
- folder: r.folder,
105
- subject: r.subject,
106
- fromUser: r.from_user,
107
- sentAt: r.sent_at,
108
- recipients: JSON.parse(r.recipients_json),
109
- body: r.body,
110
- fetchedBodyAt: r.fetched_body_at,
111
- replyToId: r.reply_to_id,
112
- chainRootId: r.chain_root_id,
113
- listData: JSON.parse(r.list_data_json),
114
- };
115
- }
116
- // node:sqlite rejects `undefined` as a bound parameter ("Provided value cannot
117
- // be bound"). Normalize undefined to null for nullable columns so callers
118
- // don't have to remember; throw with a useful error for NOT NULL fields that
119
- // somehow arrived as undefined.
120
- function nullish(v) {
121
- return v === undefined ? null : v;
122
- }
123
- function requireString(field, v) {
124
- if (typeof v === 'string')
125
- return v;
126
- throw new Error(`cache: ${field} is required (got ${v === undefined ? 'undefined' : 'null'})`);
127
- }
128
- export function upsertMessage(row) {
129
- const { db } = openCache();
130
- db.prepare(`INSERT INTO messages (
131
- id, folder, subject, from_user, sent_at, recipients_json,
132
- body, fetched_body_at, reply_to_id, chain_root_id, list_data_json, last_seen_at
133
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
134
- ON CONFLICT(id) DO UPDATE SET
135
- folder=excluded.folder,
136
- subject=excluded.subject,
137
- from_user=excluded.from_user,
138
- sent_at=excluded.sent_at,
139
- recipients_json=excluded.recipients_json,
140
- body=excluded.body,
141
- fetched_body_at=excluded.fetched_body_at,
142
- reply_to_id=excluded.reply_to_id,
143
- chain_root_id=excluded.chain_root_id,
144
- list_data_json=excluded.list_data_json,
145
- last_seen_at=excluded.last_seen_at`).run(row.id, requireString('messages.folder', row.folder), requireString('messages.subject', row.subject), requireString('messages.fromUser', row.fromUser), requireString('messages.sentAt', row.sentAt), JSON.stringify(row.recipients ?? []), nullish(row.body), nullish(row.fetchedBodyAt), nullish(row.replyToId), nullish(row.chainRootId), JSON.stringify(row.listData ?? null), new Date().toISOString());
146
- }
147
- export function getMessage(id) {
148
- const { db } = openCache();
149
- const r = db.prepare('SELECT * FROM messages WHERE id = ?').get(id);
150
- return r ? rowFromDb(r) : null;
151
- }
152
- /**
153
- * Remove a row from the `messages` table. Used by syncDrafts to evict
154
- * stale rows that were cached when a draft was previously read through
155
- * `ofw_get_message` (which would have wrongly classified it as `inbox`)
156
- * — the drafts table is the authoritative source for that id now.
157
- */
158
- export function deleteMessage(id) {
159
- const { db } = openCache();
160
- db.prepare('DELETE FROM messages WHERE id = ?').run(id);
161
- }
162
- // Build the WHERE clause + bound params for message queries. listMessages and
163
- // countMessages share this so the filter semantics can't drift.
164
- function buildMessageFilter(opts) {
165
- const wheres = [];
166
- const params = [];
167
- if (opts.folder !== undefined) {
168
- wheres.push('folder = ?');
169
- params.push(opts.folder);
170
- }
171
- if (opts.since !== undefined) {
172
- wheres.push('sent_at >= ?');
173
- params.push(opts.since);
174
- }
175
- if (opts.until !== undefined) {
176
- wheres.push('sent_at < ?');
177
- params.push(opts.until);
178
- }
179
- if (opts.q !== undefined && opts.q.length > 0) {
180
- const pattern = `%${opts.q}%`;
181
- wheres.push('(subject LIKE ? OR body LIKE ?)');
182
- params.push(pattern, pattern);
183
- }
184
- return {
185
- where: wheres.length > 0 ? `WHERE ${wheres.join(' AND ')}` : '',
186
- params,
187
- };
188
- }
189
- export function listMessages(opts) {
190
- const { db } = openCache();
191
- const { where, params } = buildMessageFilter(opts);
192
- const offset = (opts.page - 1) * opts.size;
193
- const rows = db.prepare(`SELECT * FROM messages ${where}
194
- ORDER BY sent_at DESC, id DESC
195
- LIMIT ? OFFSET ?`).all(...params, opts.size, offset);
196
- return rows.map(rowFromDb);
197
- }
198
- export function countMessages(opts) {
199
- const { db } = openCache();
200
- const { where, params } = buildMessageFilter(opts);
201
- const r = db.prepare(`SELECT COUNT(*) as n FROM messages ${where}`)
202
- .get(...params);
203
- /* v8 ignore next -- SELECT COUNT(*) always returns exactly one row; the ?./?? are defensive */
204
- return r?.n ?? 0;
205
- }
206
- function draftFromDb(r) {
207
- return {
208
- id: r.id,
209
- subject: r.subject,
210
- body: r.body,
211
- recipients: JSON.parse(r.recipients_json),
212
- replyToId: r.reply_to_id,
213
- modifiedAt: r.modified_at,
214
- listData: JSON.parse(r.list_data_json),
215
- };
216
- }
217
- export function upsertDraft(row) {
218
- const { db } = openCache();
219
- db.prepare(`INSERT INTO drafts (id, subject, body, recipients_json, reply_to_id, modified_at, list_data_json)
220
- VALUES (?, ?, ?, ?, ?, ?, ?)
221
- ON CONFLICT(id) DO UPDATE SET
222
- subject=excluded.subject,
223
- body=excluded.body,
224
- recipients_json=excluded.recipients_json,
225
- reply_to_id=excluded.reply_to_id,
226
- modified_at=excluded.modified_at,
227
- list_data_json=excluded.list_data_json`).run(row.id, requireString('drafts.subject', row.subject), requireString('drafts.body', row.body), JSON.stringify(row.recipients ?? []), nullish(row.replyToId), requireString('drafts.modifiedAt', row.modifiedAt), JSON.stringify(row.listData ?? null));
228
- }
229
- export function getDraft(id) {
230
- const { db } = openCache();
231
- const r = db.prepare('SELECT * FROM drafts WHERE id = ?').get(id);
232
- return r ? draftFromDb(r) : null;
233
- }
234
- export function listDrafts(opts) {
235
- const { db } = openCache();
236
- const offset = (opts.page - 1) * opts.size;
237
- const rows = db.prepare('SELECT * FROM drafts ORDER BY modified_at DESC, id DESC LIMIT ? OFFSET ?').all(opts.size, offset);
238
- return rows.map(draftFromDb);
239
- }
240
- export function deleteDraft(id) {
241
- const { db } = openCache();
242
- db.prepare('DELETE FROM drafts WHERE id = ?').run(id);
243
- }
244
- export function listDraftIds() {
245
- const { db } = openCache();
246
- const rows = db.prepare('SELECT id FROM drafts').all();
247
- return rows.map((r) => r.id);
248
- }
249
- export function getSyncState(folder) {
250
- const { db } = openCache();
251
- const r = db.prepare('SELECT last_sync_at, newest_id FROM sync_state WHERE folder = ?')
252
- .get(folder);
253
- if (!r)
254
- return null;
255
- return { lastSyncAt: r.last_sync_at, newestId: r.newest_id };
256
- }
257
- export function setSyncState(folder, state) {
258
- const { db } = openCache();
259
- db.prepare(`INSERT INTO sync_state (folder, last_sync_at, newest_id) VALUES (?, ?, ?)
260
- ON CONFLICT(folder) DO UPDATE SET
261
- last_sync_at = excluded.last_sync_at,
262
- newest_id = excluded.newest_id`).run(folder, state.lastSyncAt, state.newestId);
263
- }
264
- export function getMeta(key) {
265
- const { db } = openCache();
266
- const r = db.prepare('SELECT value FROM meta WHERE key = ?')
267
- .get(key);
268
- return r ? r.value : null;
269
- }
270
- export function setMeta(key, value) {
271
- const { db } = openCache();
272
- db.prepare('INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value=excluded.value').run(key, value);
273
- }
274
- export function findLatestReplyTip(replyToId) {
275
- const { db } = openCache();
276
- const parent = db.prepare('SELECT id, folder, chain_root_id FROM messages WHERE id = ?').get(replyToId);
277
- if (!parent)
278
- return replyToId;
279
- const chainRoot = parent.chain_root_id ?? parent.id;
280
- const tip = db.prepare(`SELECT id FROM messages
281
- WHERE folder = 'sent' AND chain_root_id = ?
282
- ORDER BY id DESC LIMIT 1`).get(chainRoot);
283
- return tip ? tip.id : replyToId;
284
- }
285
- function attachmentFromDb(r) {
286
- return {
287
- fileId: r.file_id,
288
- fileName: r.file_name,
289
- label: r.label,
290
- mimeType: r.mime_type,
291
- sizeBytes: r.size_bytes,
292
- metadata: JSON.parse(r.metadata_json),
293
- messageIds: JSON.parse(r.message_ids_json),
294
- downloadedPath: r.downloaded_path,
295
- downloadedAt: r.downloaded_at,
296
- };
297
- }
298
- export function getAttachment(fileId) {
299
- const { db } = openCache();
300
- const r = db.prepare('SELECT * FROM attachments WHERE file_id = ?').get(fileId);
301
- return r ? attachmentFromDb(r) : null;
302
- }
303
- export function listAttachmentsForMessage(messageId) {
304
- const { db } = openCache();
305
- // SQLite JSON1 contains check
306
- const rows = db.prepare(`SELECT * FROM attachments
307
- WHERE EXISTS (SELECT 1 FROM json_each(message_ids_json) WHERE value = ?)
308
- ORDER BY file_id`).all(messageId);
309
- return rows.map(attachmentFromDb);
310
- }
311
- export function upsertAttachmentForMessage(input) {
312
- const { db } = openCache();
313
- const existing = db.prepare('SELECT message_ids_json FROM attachments WHERE file_id = ?')
314
- .get(input.fileId);
315
- // messageId === 0 is the "metadata-only, not yet linked to a message"
316
- // sentinel used by upload-without-send and download-by-id. Don't
317
- // pollute the array with it — leave the list empty / unchanged.
318
- const prior = existing ? JSON.parse(existing.message_ids_json) : [];
319
- let messageIds;
320
- if (input.messageId === 0) {
321
- messageIds = prior;
322
- }
323
- else if (prior.includes(input.messageId)) {
324
- messageIds = prior;
325
- }
326
- else {
327
- messageIds = [...prior, input.messageId];
328
- }
329
- db.prepare(`INSERT INTO attachments (
330
- file_id, file_name, label, mime_type, size_bytes,
331
- metadata_json, message_ids_json, fetched_metadata_at
332
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
333
- ON CONFLICT(file_id) DO UPDATE SET
334
- file_name=excluded.file_name,
335
- label=excluded.label,
336
- mime_type=excluded.mime_type,
337
- size_bytes=excluded.size_bytes,
338
- metadata_json=excluded.metadata_json,
339
- message_ids_json=excluded.message_ids_json,
340
- fetched_metadata_at=excluded.fetched_metadata_at`).run(input.fileId, requireString('attachments.fileName', input.fileName), requireString('attachments.label', input.label), requireString('attachments.mimeType', input.mimeType), nullish(input.sizeBytes), JSON.stringify(input.metadata ?? null), JSON.stringify(messageIds), new Date().toISOString());
341
- }
342
- export function markAttachmentDownloaded(fileId, path) {
343
- const { db } = openCache();
344
- db.prepare('UPDATE attachments SET downloaded_path = ?, downloaded_at = ? WHERE file_id = ?').run(path, new Date().toISOString(), fileId);
345
- }