ofw-mcp 2.8.0 → 2.9.1

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.
@@ -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
+ }