ofw-mcp 2.8.0 → 2.9.0
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/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +44 -3
- package/dist/bundle.js +644 -118
- package/dist/cache/store.js +79 -1
- package/dist/config.js +18 -0
- package/dist/index.js +1 -1
- package/dist/sync.js +4 -0
- package/dist/tools/draft-freshness.js +46 -13
- package/dist/tools/lifecycle.js +277 -0
- package/dist/tools/messages.js +447 -135
- package/package.json +1 -1
- package/server.json +2 -2
- package/skills/ofw/SKILL.md +32 -13
package/dist/cache/store.js
CHANGED
|
@@ -32,6 +32,14 @@ function draftFromDb(r) {
|
|
|
32
32
|
listData: JSON.parse(r.list_data_json),
|
|
33
33
|
};
|
|
34
34
|
}
|
|
35
|
+
function lineageFromDb(r) {
|
|
36
|
+
return {
|
|
37
|
+
id: r.id,
|
|
38
|
+
draftKey: r.draft_key,
|
|
39
|
+
previousId: r.previous_id,
|
|
40
|
+
recordedAt: r.recorded_at,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
35
43
|
function attachmentFromDb(r) {
|
|
36
44
|
return {
|
|
37
45
|
fileId: r.file_id,
|
|
@@ -93,6 +101,17 @@ export const SCHEMA_STATEMENTS = [
|
|
|
93
101
|
key TEXT PRIMARY KEY,
|
|
94
102
|
value TEXT NOT NULL
|
|
95
103
|
)`,
|
|
104
|
+
// v3: draft identity chain. One row per OFW id, all the ids of one logical
|
|
105
|
+
// document sharing a `draft_key`. Survives ofw_save_draft's create-then-delete
|
|
106
|
+
// replacement AND the transition to a sent message, so "what happened to the
|
|
107
|
+
// draft I was editing?" is answerable without guessing which id is current.
|
|
108
|
+
`CREATE TABLE IF NOT EXISTS draft_lineage (
|
|
109
|
+
id INTEGER PRIMARY KEY,
|
|
110
|
+
draft_key TEXT NOT NULL,
|
|
111
|
+
previous_id INTEGER,
|
|
112
|
+
recorded_at TEXT NOT NULL
|
|
113
|
+
)`,
|
|
114
|
+
`CREATE INDEX IF NOT EXISTS idx_draft_lineage_key ON draft_lineage(draft_key, recorded_at, id)`,
|
|
96
115
|
// v2: attachments table. Idempotent — IF NOT EXISTS.
|
|
97
116
|
`CREATE TABLE IF NOT EXISTS attachments (
|
|
98
117
|
file_id INTEGER PRIMARY KEY,
|
|
@@ -119,7 +138,7 @@ export const MIGRATIONS = [
|
|
|
119
138
|
'ALTER TABLE sync_state ADD COLUMN resume_page INTEGER',
|
|
120
139
|
];
|
|
121
140
|
/** The schema version stamped into the `meta` table on open. */
|
|
122
|
-
export const SCHEMA_VERSION = '
|
|
141
|
+
export const SCHEMA_VERSION = '3';
|
|
123
142
|
// Build the WHERE clause + bound params for message queries. listMessages and
|
|
124
143
|
// countMessages share this so the filter semantics can't drift.
|
|
125
144
|
function buildMessageFilter(opts) {
|
|
@@ -302,6 +321,11 @@ export class OFWCacheCore {
|
|
|
302
321
|
const rows = this.db.all('SELECT * FROM drafts ORDER BY modified_at DESC, id DESC LIMIT ? OFFSET ?', [opts.size, offset]);
|
|
303
322
|
return rows.map(draftFromDb);
|
|
304
323
|
}
|
|
324
|
+
countDrafts() {
|
|
325
|
+
const r = this.db.get('SELECT COUNT(*) as n FROM drafts', []);
|
|
326
|
+
/* v8 ignore next -- SELECT COUNT(*) always returns exactly one row; the ?./?? are defensive */
|
|
327
|
+
return r?.n ?? 0;
|
|
328
|
+
}
|
|
305
329
|
deleteDraft(id) {
|
|
306
330
|
this.db.run('DELETE FROM drafts WHERE id = ?', [id]);
|
|
307
331
|
}
|
|
@@ -309,6 +333,45 @@ export class OFWCacheCore {
|
|
|
309
333
|
const rows = this.db.all('SELECT id FROM drafts', []);
|
|
310
334
|
return rows.map((r) => r.id);
|
|
311
335
|
}
|
|
336
|
+
/**
|
|
337
|
+
* Link an id into a draft's identity chain. Upserts on id: re-recording the
|
|
338
|
+
* same id (e.g. a retried save) rewrites its link rather than duplicating it,
|
|
339
|
+
* so `getDraftLineage` can never report one id twice.
|
|
340
|
+
*/
|
|
341
|
+
recordDraftLineage(row) {
|
|
342
|
+
this.db.run(`INSERT INTO draft_lineage (id, draft_key, previous_id, recorded_at) VALUES (?, ?, ?, ?)
|
|
343
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
344
|
+
draft_key=excluded.draft_key,
|
|
345
|
+
previous_id=excluded.previous_id,
|
|
346
|
+
recorded_at=excluded.recorded_at`, [row.id, requireString('draft_lineage.draftKey', row.draftKey), nullish(row.previousId),
|
|
347
|
+
requireString('draft_lineage.recordedAt', row.recordedAt)]);
|
|
348
|
+
}
|
|
349
|
+
getDraftLineageById(id) {
|
|
350
|
+
const r = this.db.get('SELECT * FROM draft_lineage WHERE id = ?', [id]);
|
|
351
|
+
return r ? lineageFromDb(r) : null;
|
|
352
|
+
}
|
|
353
|
+
/**
|
|
354
|
+
* Batch read — one query for a whole page of drafts. On the Durable Object
|
|
355
|
+
* backend each cache call is a subrequest, so a per-draft lookup would spend
|
|
356
|
+
* the caller's sync budget on bookkeeping.
|
|
357
|
+
*/
|
|
358
|
+
getDraftLineageByIds(ids) {
|
|
359
|
+
if (ids.length === 0)
|
|
360
|
+
return [];
|
|
361
|
+
const placeholders = ids.map(() => '?').join(', ');
|
|
362
|
+
const rows = this.db.all(`SELECT * FROM draft_lineage WHERE id IN (${placeholders})`, ids);
|
|
363
|
+
return rows.map(lineageFromDb);
|
|
364
|
+
}
|
|
365
|
+
/**
|
|
366
|
+
* Every link in one chain, OLDEST FIRST — so the last element is the chain's
|
|
367
|
+
* current id. Ordered by recorded_at then id: two links written inside the
|
|
368
|
+
* same millisecond tie-break on id, and OFW mints ids monotonically, so the
|
|
369
|
+
* newer replacement always sorts last.
|
|
370
|
+
*/
|
|
371
|
+
getDraftLineage(draftKey) {
|
|
372
|
+
const rows = this.db.all('SELECT * FROM draft_lineage WHERE draft_key = ? ORDER BY recorded_at ASC, id ASC', [draftKey]);
|
|
373
|
+
return rows.map(lineageFromDb);
|
|
374
|
+
}
|
|
312
375
|
getSyncState(folder) {
|
|
313
376
|
const r = this.db.get('SELECT last_sync_at, newest_id, resume_page FROM sync_state WHERE folder = ?', [folder]);
|
|
314
377
|
if (!r)
|
|
@@ -444,12 +507,27 @@ export class LocalCacheStore {
|
|
|
444
507
|
async listDrafts(opts) {
|
|
445
508
|
return this.core.listDrafts(opts);
|
|
446
509
|
}
|
|
510
|
+
async countDrafts() {
|
|
511
|
+
return this.core.countDrafts();
|
|
512
|
+
}
|
|
447
513
|
async deleteDraft(id) {
|
|
448
514
|
this.core.deleteDraft(id);
|
|
449
515
|
}
|
|
450
516
|
async listDraftIds() {
|
|
451
517
|
return this.core.listDraftIds();
|
|
452
518
|
}
|
|
519
|
+
async recordDraftLineage(row) {
|
|
520
|
+
this.core.recordDraftLineage(row);
|
|
521
|
+
}
|
|
522
|
+
async getDraftLineageById(id) {
|
|
523
|
+
return this.core.getDraftLineageById(id);
|
|
524
|
+
}
|
|
525
|
+
async getDraftLineageByIds(ids) {
|
|
526
|
+
return this.core.getDraftLineageByIds(ids);
|
|
527
|
+
}
|
|
528
|
+
async getDraftLineage(draftKey) {
|
|
529
|
+
return this.core.getDraftLineage(draftKey);
|
|
530
|
+
}
|
|
453
531
|
async getSyncState(folder) {
|
|
454
532
|
return this.core.getSyncState(folder);
|
|
455
533
|
}
|
package/dist/config.js
CHANGED
|
@@ -124,6 +124,24 @@ export function getAllowMarkRead() {
|
|
|
124
124
|
export function getFetchUnreadBodies() {
|
|
125
125
|
return parseBoolEnv('OFW_FETCH_UNREAD_BODIES');
|
|
126
126
|
}
|
|
127
|
+
/**
|
|
128
|
+
* Default for the read tools' `autoRefresh` arg.
|
|
129
|
+
*
|
|
130
|
+
* When a cached read comes back EMPTY and the cache is not `fresh`, the tools
|
|
131
|
+
* refuse to report that emptiness (see UNVERIFIED_EMPTY in tools/messages.ts):
|
|
132
|
+
* an empty result from a 207-minute-old cache is shaped identically to a
|
|
133
|
+
* verified "nothing there", and answering "no, it wasn't sent" from one is how
|
|
134
|
+
* a false negative becomes a confident statement about a legal record.
|
|
135
|
+
*
|
|
136
|
+
* The refusal names its remedy, so the default (false) costs one extra call.
|
|
137
|
+
* Set OFW_AUTO_REFRESH=true and the tools instead sync the backing folders
|
|
138
|
+
* themselves and answer from the refreshed cache — same guarantee, no round
|
|
139
|
+
* trip. Never silently degrades: if the refresh does not make the read
|
|
140
|
+
* verifiable, the refusal still fires.
|
|
141
|
+
*/
|
|
142
|
+
export function getAutoRefreshStaleReads() {
|
|
143
|
+
return parseBoolEnv('OFW_AUTO_REFRESH');
|
|
144
|
+
}
|
|
127
145
|
// Default for ofw_download_attachment's `inline` arg when the caller doesn't
|
|
128
146
|
// pass one. Set OFW_INLINE_ATTACHMENTS=true to have attachments returned as
|
|
129
147
|
// MCP content blocks by default (skipping disk) — useful on sandboxed MCP
|
package/dist/index.js
CHANGED
|
@@ -35,7 +35,7 @@ const nodeAttachmentIO = new NodeAttachmentIO();
|
|
|
35
35
|
// always succeeds before any credential check runs.
|
|
36
36
|
await runMcp({
|
|
37
37
|
name: 'ofw',
|
|
38
|
-
version: '2.
|
|
38
|
+
version: '2.9.0', // x-release-please-version
|
|
39
39
|
deps: client,
|
|
40
40
|
tools: [
|
|
41
41
|
registerUserTools,
|
package/dist/sync.js
CHANGED
|
@@ -94,6 +94,10 @@ export async function resolveFolderIds(client, store) {
|
|
|
94
94
|
// label an uncached message sent-vs-inbox from the detail payload's own folder
|
|
95
95
|
// id, instead of hard-defaulting to inbox.
|
|
96
96
|
await store.setMeta('sent_folder_id', ids.sent);
|
|
97
|
+
// And inbox, which completes the map a lifecycle probe needs to turn a detail
|
|
98
|
+
// payload's own folder id into "draft" / "sent" / "received" (see
|
|
99
|
+
// tools/lifecycle.ts). Without all three, a probe can only say "unknown".
|
|
100
|
+
await store.setMeta('inbox_folder_id', ids.inbox);
|
|
97
101
|
return ids;
|
|
98
102
|
}
|
|
99
103
|
// Required fields are the ones the sync loop reads unguarded (id keys the
|
|
@@ -39,23 +39,40 @@ const ServerDraftSchema = z.looseObject({
|
|
|
39
39
|
body: z.string().optional(),
|
|
40
40
|
replyToId: z.number().nullable().optional(),
|
|
41
41
|
recipients: z.array(ApiRecipientSchema).optional(),
|
|
42
|
+
// Read for the LIFECYCLE answer (see tools/lifecycle.ts): which folder OFW
|
|
43
|
+
// itself says this id lives in right now. `existsOnServer` alone cannot
|
|
44
|
+
// distinguish "still a draft" from "was sent" — a sent draft still exists.
|
|
45
|
+
// `id` accepts BOTH spellings deliberately. This schema is parsed in
|
|
46
|
+
// `mode: 'strict'` because it backs the destructive-draft guard, so a
|
|
47
|
+
// present-but-mistyped field THROWS — and OFW is already inconsistent about
|
|
48
|
+
// this exact field: the folders listing (`FoldersSchema` in sync.ts) types it
|
|
49
|
+
// `z.string()`, while message detail has been observed returning a number.
|
|
50
|
+
// Pinning one spelling here would turn a harmless representation change into
|
|
51
|
+
// a hard failure of ofw_save_draft / ofw_delete_draft, which is the opposite
|
|
52
|
+
// of what a strict boundary is for: it exists to stop us acting on a response
|
|
53
|
+
// we cannot interpret, not to reject one we can. `folderId` is normalized to
|
|
54
|
+
// a string below, so both spellings compare correctly downstream.
|
|
55
|
+
folder: z.looseObject({
|
|
56
|
+
id: z.union([z.string(), z.number()]).optional(),
|
|
57
|
+
name: z.string().optional(),
|
|
58
|
+
}).nullable().optional(),
|
|
59
|
+
date: z.looseObject({ dateTime: z.string().optional() }).nullable().optional(),
|
|
42
60
|
});
|
|
43
61
|
function isNotFound(e) {
|
|
44
62
|
return e instanceof Error && /OFW API error: 404\b/.test(e.message);
|
|
45
63
|
}
|
|
46
64
|
/**
|
|
47
|
-
* Read a
|
|
65
|
+
* Read a message's AUTHORITATIVE state straight from OFW, bypassing the cache.
|
|
48
66
|
*
|
|
49
|
-
* Returns `null` when
|
|
50
|
-
*
|
|
51
|
-
* check that could not run must never wave the write through.
|
|
67
|
+
* Returns `null` when it no longer exists (404, or an empty body — OFW's other
|
|
68
|
+
* way of saying "no such message"). Any other failure throws.
|
|
52
69
|
*
|
|
53
|
-
* Most failures throw `DraftFreshnessError
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
70
|
+
* Most failures throw `DraftFreshnessError`, but not all — a strict
|
|
71
|
+
* `parseLenient` mismatch throws `McpToolError` instead. Callers must not assume
|
|
72
|
+
* the narrower type (the catch blocks read only `.message`, which every Error
|
|
73
|
+
* carries).
|
|
57
74
|
*/
|
|
58
|
-
export async function
|
|
75
|
+
export async function fetchMessageSnapshot(client, id) {
|
|
59
76
|
let raw;
|
|
60
77
|
try {
|
|
61
78
|
raw = await client.request('GET', `/pub/v3/messages/${id}`);
|
|
@@ -76,12 +93,28 @@ export async function fetchServerDraft(client, id) {
|
|
|
76
93
|
mode: 'strict',
|
|
77
94
|
});
|
|
78
95
|
return {
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
96
|
+
content: {
|
|
97
|
+
subject: detail.subject ?? '',
|
|
98
|
+
body: detail.body ?? '',
|
|
99
|
+
replyToId: detail.replyToId ?? null,
|
|
100
|
+
recipients: mapRecipients(detail.recipients),
|
|
101
|
+
},
|
|
102
|
+
folderId: detail.folder?.id === undefined ? null : String(detail.folder.id),
|
|
103
|
+
folderName: detail.folder?.name ?? null,
|
|
104
|
+
dateTime: detail.date?.dateTime ?? null,
|
|
83
105
|
};
|
|
84
106
|
}
|
|
107
|
+
/**
|
|
108
|
+
* The content-only view of {@link fetchMessageSnapshot}, used by the
|
|
109
|
+
* destructive-draft guard, which cares what the draft SAYS, not where it lives.
|
|
110
|
+
*
|
|
111
|
+
* Returns `null` when the draft no longer exists. Any other failure throws, and
|
|
112
|
+
* the callers in messages.ts abort on ALL of them: a freshness check that could
|
|
113
|
+
* not run must never wave the write through.
|
|
114
|
+
*/
|
|
115
|
+
export async function fetchServerDraft(client, id) {
|
|
116
|
+
return (await fetchMessageSnapshot(client, id))?.content ?? null;
|
|
117
|
+
}
|
|
85
118
|
/**
|
|
86
119
|
* The fields whose divergence constitutes a REAL conflict — the actual message
|
|
87
120
|
* content a caller would lose if we overwrote a copy edited elsewhere. Anything
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
import { resolveFolderIds } from '../sync.js';
|
|
2
|
+
import { draftRevision, fetchMessageSnapshot } from './draft-freshness.js';
|
|
3
|
+
import { deriveRead } from './_shared.js';
|
|
4
|
+
/** OFW's `folderType` discriminator for each folder we track. */
|
|
5
|
+
export const FOLDER_TYPE = {
|
|
6
|
+
inbox: 'INBOX',
|
|
7
|
+
sent: 'SENT_MESSAGES',
|
|
8
|
+
drafts: 'DRAFTS',
|
|
9
|
+
};
|
|
10
|
+
/** Where each folder's id is persisted in the `meta` table. */
|
|
11
|
+
const FOLDER_ID_META_KEY = {
|
|
12
|
+
inbox: 'inbox_folder_id',
|
|
13
|
+
sent: 'sent_folder_id',
|
|
14
|
+
drafts: 'drafts_folder_id',
|
|
15
|
+
};
|
|
16
|
+
const FOLDERS = ['inbox', 'sent', 'drafts'];
|
|
17
|
+
/** Read whatever folder ids past syncs persisted. No requests. */
|
|
18
|
+
export async function readFolderIdMap(store) {
|
|
19
|
+
return {
|
|
20
|
+
inbox: await store.getMeta(FOLDER_ID_META_KEY.inbox),
|
|
21
|
+
sent: await store.getMeta(FOLDER_ID_META_KEY.sent),
|
|
22
|
+
drafts: await store.getMeta(FOLDER_ID_META_KEY.drafts),
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Persist folder ids harvested from a folders listing the caller ALREADY
|
|
27
|
+
* fetched.
|
|
28
|
+
*
|
|
29
|
+
* `ofw_check_freshness` asked about folders and ids in one call hits
|
|
30
|
+
* `/pub/v1/messageFolders?includeFolderCounts=true` for the counts, and then
|
|
31
|
+
* `ensureFolderIdMap` hit the very same endpoint again to learn the ids. The
|
|
32
|
+
* response in hand already carries them; taking them makes the second call a
|
|
33
|
+
* no-op instead of a duplicate round trip.
|
|
34
|
+
*/
|
|
35
|
+
export async function persistFolderIds(store, systemFolders) {
|
|
36
|
+
for (const folder of FOLDERS) {
|
|
37
|
+
const entry = systemFolders.find((f) => f.folderType === FOLDER_TYPE[folder]);
|
|
38
|
+
if (entry !== undefined)
|
|
39
|
+
await store.setMeta(FOLDER_ID_META_KEY[folder], entry.id);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* The folder map, resolving it live when the cache has never held all three.
|
|
44
|
+
*
|
|
45
|
+
* Costs at most ONE request, and only when the map is genuinely incomplete —
|
|
46
|
+
* without it every probe degrades to `unknown`, which is a refusal to answer,
|
|
47
|
+
* not an answer. A failed resolve is swallowed: we fall back to the partial
|
|
48
|
+
* cached map and let classification say `unknown` honestly, rather than failing
|
|
49
|
+
* a whole status call over a folder listing.
|
|
50
|
+
*/
|
|
51
|
+
export async function ensureFolderIdMap(client, store) {
|
|
52
|
+
const cached = await readFolderIdMap(store);
|
|
53
|
+
if (cached.inbox !== null && cached.sent !== null && cached.drafts !== null) {
|
|
54
|
+
return { map: cached, requests: 0 };
|
|
55
|
+
}
|
|
56
|
+
try {
|
|
57
|
+
const ids = await resolveFolderIds(client, store);
|
|
58
|
+
return { map: { inbox: ids.inbox, sent: ids.sent, drafts: ids.drafts }, requests: 1 };
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return { map: cached, requests: 1 };
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Map a live snapshot to a lifecycle state. Pure — the request already happened.
|
|
66
|
+
*
|
|
67
|
+
* A null snapshot means OFW returned 404 / an empty body, i.e. `deleted`. An
|
|
68
|
+
* unmappable folder is `unknown` rather than being guessed at: guessing here is
|
|
69
|
+
* exactly how "still a draft" gets asserted about something that was sent.
|
|
70
|
+
*/
|
|
71
|
+
export function classifyState(snapshot, map) {
|
|
72
|
+
if (snapshot === null)
|
|
73
|
+
return 'deleted';
|
|
74
|
+
const { folderId } = snapshot;
|
|
75
|
+
if (folderId === null)
|
|
76
|
+
return 'unknown';
|
|
77
|
+
if (map.drafts !== null && folderId === map.drafts)
|
|
78
|
+
return 'draft';
|
|
79
|
+
if (map.sent !== null && folderId === map.sent)
|
|
80
|
+
return 'sent';
|
|
81
|
+
if (map.inbox !== null && folderId === map.inbox)
|
|
82
|
+
return 'received';
|
|
83
|
+
return 'unknown';
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Would probing this id stamp the record?
|
|
87
|
+
*
|
|
88
|
+
* A probe is `GET /pub/v3/messages/{id}`, and for an UNREAD INBOX message that
|
|
89
|
+
* marks it read on OFW and writes a co-parent-visible "First Viewed" time —
|
|
90
|
+
* court-visible and irreversible. Everything else is free of side effects:
|
|
91
|
+
*
|
|
92
|
+
* - a cached DRAFT: drafts have no read state at all;
|
|
93
|
+
* - a cached SENT message: its view times belong to the recipient, and our
|
|
94
|
+
* own fetch never writes one;
|
|
95
|
+
* - a cached inbox message already derived as read: the stamp exists, and
|
|
96
|
+
* `deriveRead` is monotonic so it cannot be added twice.
|
|
97
|
+
*
|
|
98
|
+
* An id with NO cached row returns true — whether it would stamp is exactly
|
|
99
|
+
* what cannot be known without making the request that stamps it.
|
|
100
|
+
*/
|
|
101
|
+
export function probeWouldStamp(cachedDraft, cachedMessage) {
|
|
102
|
+
if (cachedDraft !== null)
|
|
103
|
+
return false;
|
|
104
|
+
if (cachedMessage === null)
|
|
105
|
+
return true;
|
|
106
|
+
if (cachedMessage.folder === 'sent')
|
|
107
|
+
return false;
|
|
108
|
+
return !deriveRead(cachedMessage);
|
|
109
|
+
}
|
|
110
|
+
const SKIP_NOTE = 'Verifying this id requires fetching its detail from OurFamilyWizard, which would mark an unread inbox message as READ and stamp a co-parent-visible "First Viewed" time on the record. Ids already cached as drafts, as sent, or as already-read inbox messages are probed freely because none of those can stamp anything. Run ofw_sync_messages (it walks list pages, not bodies) or pass allowMarkRead:true.';
|
|
111
|
+
function stateNote(state, cachedAsDraft, folderName) {
|
|
112
|
+
if (state === 'deleted') {
|
|
113
|
+
return cachedAsDraft
|
|
114
|
+
? 'This draft is in the local cache but NO LONGER EXISTS on OurFamilyWizard — it was sent or deleted elsewhere. Do not describe it as still unsent.'
|
|
115
|
+
: 'Not found on OurFamilyWizard.';
|
|
116
|
+
}
|
|
117
|
+
if (state === 'sent') {
|
|
118
|
+
return cachedAsDraft
|
|
119
|
+
? 'This id is cached as a DRAFT but OurFamilyWizard now has it in Sent — it was SENT (see sentAt). It is no longer a draft; saying it is still unsent would be false.'
|
|
120
|
+
: 'This id is a sent message on OurFamilyWizard.';
|
|
121
|
+
}
|
|
122
|
+
if (state === 'received') {
|
|
123
|
+
return cachedAsDraft
|
|
124
|
+
? 'This id is cached as a draft but OurFamilyWizard has it in the Inbox. Run ofw_sync_messages to reconcile.'
|
|
125
|
+
: 'This id is an inbox message on OurFamilyWizard.';
|
|
126
|
+
}
|
|
127
|
+
if (state === 'unknown') {
|
|
128
|
+
return `OurFamilyWizard did not report a folder this tool can map${folderName === null ? '' : ` (it reported "${folderName}")`}, so what this id has become is NOT established. Treat it as unverified rather than assuming it is unchanged.`;
|
|
129
|
+
}
|
|
130
|
+
return undefined;
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Probe a batch of ids live and return one lifecycle verdict each.
|
|
134
|
+
*
|
|
135
|
+
* Skips are decided BEFORE anything is fetched, so a call whose every id is
|
|
136
|
+
* refused spends zero OFW requests — including the folder-map resolve, which
|
|
137
|
+
* only happens when at least one id will actually be probed. Costs at most one
|
|
138
|
+
* request for the map plus one per probed id.
|
|
139
|
+
*/
|
|
140
|
+
export async function probeIds(client, store, ids, opts) {
|
|
141
|
+
// THREE cache reads for the whole batch, not three per id. On the Durable
|
|
142
|
+
// Object backend every cache call is a subrequest counting against the same
|
|
143
|
+
// hosting cap as the OFW fetches, so a per-id lookup would spend the caller's
|
|
144
|
+
// budget on bookkeeping before a single probe ran.
|
|
145
|
+
const draftsById = new Map((await store.getDrafts(ids)).map((d) => [d.id, d]));
|
|
146
|
+
const messagesById = new Map((await store.getMessages(ids)).map((m) => [m.id, m]));
|
|
147
|
+
const prepared = ids.map((id) => {
|
|
148
|
+
const cachedDraft = draftsById.get(id) ?? null;
|
|
149
|
+
const cachedMessage = cachedDraft === null ? messagesById.get(id) ?? null : null;
|
|
150
|
+
return {
|
|
151
|
+
id,
|
|
152
|
+
cachedDraft,
|
|
153
|
+
cachedMessage,
|
|
154
|
+
skip: !opts.allowMarkRead && probeWouldStamp(cachedDraft, cachedMessage),
|
|
155
|
+
};
|
|
156
|
+
});
|
|
157
|
+
let requests = 0;
|
|
158
|
+
let map = { inbox: null, sent: null, drafts: null };
|
|
159
|
+
if (prepared.some((p) => !p.skip)) {
|
|
160
|
+
const resolved = await ensureFolderIdMap(client, store);
|
|
161
|
+
map = resolved.map;
|
|
162
|
+
requests += resolved.requests;
|
|
163
|
+
}
|
|
164
|
+
const keyById = new Map((await store.getDraftLineageByIds(ids)).map((l) => [l.id, l.draftKey]));
|
|
165
|
+
const items = [];
|
|
166
|
+
for (const p of prepared) {
|
|
167
|
+
if (p.skip) {
|
|
168
|
+
items.push({ id: p.id, skipped: true, reason: 'WOULD_MARK_READ', note: SKIP_NOTE });
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
const probe = await probeOne(client, p, map, keyById.get(p.id) ?? null);
|
|
172
|
+
requests += probe.requests;
|
|
173
|
+
items.push(probe.item);
|
|
174
|
+
}
|
|
175
|
+
return { items, requests };
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* The live half of one probe. Never throws: a probe that could not run comes
|
|
179
|
+
* back as an error item, because a check that failed must never read as
|
|
180
|
+
* "in sync".
|
|
181
|
+
*/
|
|
182
|
+
async function probeOne(client, prepared, map, draftKey) {
|
|
183
|
+
const { id, cachedDraft } = prepared;
|
|
184
|
+
let snapshot;
|
|
185
|
+
try {
|
|
186
|
+
snapshot = await fetchMessageSnapshot(client, id);
|
|
187
|
+
}
|
|
188
|
+
catch (e) {
|
|
189
|
+
return {
|
|
190
|
+
requests: 1,
|
|
191
|
+
item: {
|
|
192
|
+
id,
|
|
193
|
+
error: 'FRESHNESS_CHECK_FAILED',
|
|
194
|
+
message: e.message,
|
|
195
|
+
inSync: null,
|
|
196
|
+
note: 'The freshness check itself failed, so nothing is confirmed either way.',
|
|
197
|
+
},
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
const state = classifyState(snapshot, map);
|
|
201
|
+
const cacheRevision = cachedDraft === null ? null : draftRevision(cachedDraft);
|
|
202
|
+
const serverRevision = snapshot === null ? null : draftRevision(snapshot.content);
|
|
203
|
+
const viewedAt = snapshot?.content.recipients.find((r) => r.viewedAt !== null)?.viewedAt ?? null;
|
|
204
|
+
// `inSync` compares the CACHED DRAFT against the server, and it is FALSE the
|
|
205
|
+
// moment the entity stops being what the cache says it is — even when every
|
|
206
|
+
// byte of content still matches. A sent draft keeps its subject and body
|
|
207
|
+
// verbatim, so a revision-only comparison reported inSync:true for exactly
|
|
208
|
+
// the case this whole mechanism exists to catch.
|
|
209
|
+
//
|
|
210
|
+
// `null` means "not compared", the same thing it means on the folder
|
|
211
|
+
// verdicts: there is no cached draft to compare, or the content matches but
|
|
212
|
+
// OFW did not tell us where the message now lives. Never `false` for those —
|
|
213
|
+
// false claims a drift was detected.
|
|
214
|
+
let inSync;
|
|
215
|
+
if (cachedDraft === null)
|
|
216
|
+
inSync = null;
|
|
217
|
+
else if (snapshot === null)
|
|
218
|
+
inSync = false;
|
|
219
|
+
else if (cacheRevision !== serverRevision)
|
|
220
|
+
inSync = false;
|
|
221
|
+
else if (state === 'draft')
|
|
222
|
+
inSync = true;
|
|
223
|
+
else if (state === 'unknown')
|
|
224
|
+
inSync = null;
|
|
225
|
+
else
|
|
226
|
+
inSync = false;
|
|
227
|
+
// State and content are INDEPENDENT signals, so both are reported. An
|
|
228
|
+
// unmappable folder must not swallow "this was edited on OFW", and a content
|
|
229
|
+
// match must not swallow "this is no longer a draft".
|
|
230
|
+
const notes = [];
|
|
231
|
+
const stateN = stateNote(state, cachedDraft !== null, snapshot?.folderName ?? null);
|
|
232
|
+
if (stateN !== undefined)
|
|
233
|
+
notes.push(stateN);
|
|
234
|
+
if (snapshot !== null && cachedDraft === null) {
|
|
235
|
+
notes.push('Not in the drafts cache, so there is no cached copy to compare its content against (inSync is null, not false).');
|
|
236
|
+
}
|
|
237
|
+
else if (snapshot !== null && cacheRevision !== serverRevision) {
|
|
238
|
+
notes.push('Content differs from the cache — it was edited on OurFamilyWizard since the last sync. Run ofw_sync_messages before reading or writing it.');
|
|
239
|
+
}
|
|
240
|
+
return {
|
|
241
|
+
requests: 1,
|
|
242
|
+
item: {
|
|
243
|
+
id,
|
|
244
|
+
state,
|
|
245
|
+
folder: snapshot?.folderName ?? null,
|
|
246
|
+
sentAt: state === 'sent' ? snapshot?.dateTime ?? null : null,
|
|
247
|
+
viewedAt,
|
|
248
|
+
existsOnServer: snapshot !== null,
|
|
249
|
+
cacheRevision,
|
|
250
|
+
serverRevision,
|
|
251
|
+
inSync,
|
|
252
|
+
...(draftKey !== null ? { draftKey } : {}),
|
|
253
|
+
...(notes.length > 0 ? { note: notes.join(' ') } : {}),
|
|
254
|
+
},
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
/**
|
|
258
|
+
* Resolve a stable {@link import('../cache/store.js').DraftLineageRow} key to
|
|
259
|
+
* the chain's CURRENT id.
|
|
260
|
+
*
|
|
261
|
+
* Returns null when the key was never recorded — an unknown key must not
|
|
262
|
+
* silently resolve to nothing-in-particular.
|
|
263
|
+
*/
|
|
264
|
+
export async function resolveDraftKey(store, draftKey) {
|
|
265
|
+
const chain = await store.getDraftLineage(draftKey);
|
|
266
|
+
if (chain.length === 0)
|
|
267
|
+
return null;
|
|
268
|
+
return { currentId: chain[chain.length - 1].id, ids: chain.map((r) => r.id) };
|
|
269
|
+
}
|
|
270
|
+
/**
|
|
271
|
+
* Mint a new stable draft identity. Uses the Web Crypto global, which both
|
|
272
|
+
* Node ≥19 and the Workers runtime provide — a `node:crypto` import would not
|
|
273
|
+
* bundle for the hosted connector.
|
|
274
|
+
*/
|
|
275
|
+
export function newDraftKey() {
|
|
276
|
+
return `dk_${crypto.randomUUID()}`;
|
|
277
|
+
}
|