ofw-mcp 2.7.1 → 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.
@@ -0,0 +1,110 @@
1
+ // A minimal, dependency-free ZIP reader.
2
+ //
3
+ // OOXML attachments (.xlsx/.docx/.pptx) are ZIP containers of XML parts, so
4
+ // reading one is the first step of every office-document extractor. This is
5
+ // deliberately not a general ZIP library: it reads the central directory,
6
+ // slices an entry's bytes, and inflates DEFLATE members via the WHATWG
7
+ // `DecompressionStream` — which exists in BOTH Node ≥18 and workerd, so the
8
+ // same code runs on the stdio server and the hosted connector. Using
9
+ // `node:zlib` here would break the Worker build; adding a userland inflate
10
+ // dependency would bloat it. Neither is necessary.
11
+ import { inflateBounded, MAX_DECOMPRESSED_BYTES } from './inflate.js';
12
+ const EOCD_SIG = 0x06054b50;
13
+ const CENTRAL_SIG = 0x02014b50;
14
+ const LOCAL_SIG = 0x04034b50;
15
+ const ZIP64_SENTINEL = 0xffffffff;
16
+ /**
17
+ * Hard ceiling on a single decompressed member (32 MiB). An attachment is a
18
+ * co-parent-supplied file, so a zip bomb is a real (if unlikely) input, and the
19
+ * Worker's memory budget is what is being protected.
20
+ *
21
+ * The cap is enforced on the bytes as they arrive ({@link inflateBounded}), NOT
22
+ * on the size the archive declares for itself. The declared size is checked too
23
+ * — it rejects an HONEST oversized member without inflating anything — but it
24
+ * is an optimization, not the guarantee: a central directory is free to claim
25
+ * 1 KB in front of a member that expands to a gigabyte.
26
+ */
27
+ export const ZIP_MAX_UNCOMPRESSED_BYTES = MAX_DECOMPRESSED_BYTES;
28
+ /** Locate the end-of-central-directory record, scanning back past any comment. */
29
+ function findEocd(bytes) {
30
+ // The comment field is a uint16, so the record starts at most 22+65535 bytes
31
+ // from the end. Scan backwards for the signature.
32
+ const earliest = Math.max(0, bytes.length - (22 + 0xffff));
33
+ for (let i = bytes.length - 22; i >= earliest; i--) {
34
+ if (bytes.readUInt32LE(i) === EOCD_SIG)
35
+ return i;
36
+ }
37
+ throw new Error('not a ZIP archive (no end-of-central-directory record)');
38
+ }
39
+ export async function readZip(bytes, opts = {}) {
40
+ const limit = opts.maxUncompressedBytes ?? ZIP_MAX_UNCOMPRESSED_BYTES;
41
+ const eocd = findEocd(bytes);
42
+ const count = bytes.readUInt16LE(eocd + 10);
43
+ const cdOffset = bytes.readUInt32LE(eocd + 16);
44
+ if (cdOffset === ZIP64_SENTINEL || count === 0xffff) {
45
+ throw new Error('ZIP64 archives are not supported');
46
+ }
47
+ const entries = new Map();
48
+ let p = cdOffset;
49
+ for (let i = 0; i < count; i++) {
50
+ if (bytes.readUInt32LE(p) !== CENTRAL_SIG) {
51
+ throw new Error(`corrupt ZIP central directory at offset ${p}`);
52
+ }
53
+ const nameLen = bytes.readUInt16LE(p + 28);
54
+ const extraLen = bytes.readUInt16LE(p + 30);
55
+ const commentLen = bytes.readUInt16LE(p + 32);
56
+ const name = bytes.toString('utf8', p + 46, p + 46 + nameLen);
57
+ entries.set(name, {
58
+ name,
59
+ method: bytes.readUInt16LE(p + 10),
60
+ compressedSize: bytes.readUInt32LE(p + 20),
61
+ uncompressedSize: bytes.readUInt32LE(p + 24),
62
+ localOffset: bytes.readUInt32LE(p + 42),
63
+ });
64
+ p += 46 + nameLen + extraLen + commentLen;
65
+ }
66
+ const cache = new Map();
67
+ async function read(name) {
68
+ const cached = cache.get(name);
69
+ if (cached)
70
+ return cached;
71
+ const entry = entries.get(name);
72
+ if (!entry)
73
+ return null;
74
+ // Cheap pre-check for an honest oversized member. A lying header falls
75
+ // through to the streaming cap below, which is the real guarantee.
76
+ if (entry.uncompressedSize > limit) {
77
+ throw new Error(`ZIP member ${name} is too large to extract (${entry.uncompressedSize} bytes)`);
78
+ }
79
+ // The central directory records the local header's offset, but the local
80
+ // header carries its OWN name/extra lengths (they can differ from the
81
+ // central copy), so the data offset must be computed from it.
82
+ const lo = entry.localOffset;
83
+ if (bytes.readUInt32LE(lo) !== LOCAL_SIG) {
84
+ throw new Error(`corrupt ZIP local header for ${name}`);
85
+ }
86
+ const start = lo + 30 + bytes.readUInt16LE(lo + 26) + bytes.readUInt16LE(lo + 28);
87
+ const raw = bytes.subarray(start, start + entry.compressedSize);
88
+ let out;
89
+ if (entry.method === 0)
90
+ out = Buffer.from(raw);
91
+ else if (entry.method === 8)
92
+ out = await inflateBounded(raw, 'deflate-raw', limit, `ZIP member ${name}`);
93
+ else
94
+ throw new Error(`unsupported ZIP compression method ${entry.method} for ${name}`);
95
+ cache.set(name, out);
96
+ return out;
97
+ }
98
+ return {
99
+ names: () => [...entries.keys()],
100
+ has: (name) => entries.has(name),
101
+ read,
102
+ async readText(name) {
103
+ const buf = await read(name);
104
+ if (!buf)
105
+ return null;
106
+ const text = buf.toString('utf8');
107
+ return text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
108
+ },
109
+ };
110
+ }
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.7.1', // x-release-please-version
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
@@ -222,8 +226,13 @@ async function walkPages(client, folder, folderId, opts, store) {
222
226
  await fetchAttachmentMetaBudgeted(client, item.id, detailFileIds, store, budget);
223
227
  }
224
228
  }
225
- // Flush the page's rows in one transaction/RPC. Empty array is a no-op.
226
- await store.upsertMessages(toUpsert);
229
+ // Flush the page's rows in one transaction/RPC. Skipped entirely when the
230
+ // page held nothing new: on the Worker this call is a Durable-Object RPC,
231
+ // and a DO RPC counts against the same subrequest budget as an OFW fetch.
232
+ // A deep re-walk crosses page after page of already-cached messages, so an
233
+ // unconditional "no-op" write spends the caller's budget to store nothing.
234
+ if (toUpsert.length > 0)
235
+ await store.upsertMessages(toUpsert);
227
236
  if (pageBudgetHit) {
228
237
  // Paused mid-page. Resume at THIS page: the partial rows are cached, so
229
238
  // getMessages skips them next time and upserts are idempotent.
@@ -0,0 +1,99 @@
1
+ // The attachment delivery ladder.
2
+ //
3
+ // A successful fetch must always produce retrievable content. "The host cannot
4
+ // render this type" is a DISPLAY limit, and letting it become a DATA limit is
5
+ // the bug this module exists to close: `ofw_download_attachment` used to fetch
6
+ // a 10 KB custody-schedule spreadsheet, hand back an EmbeddedResource, and have
7
+ // the host reject it with "Resources of type '…spreadsheetml.sheet' are not
8
+ // currently supported" — leaving the caller holding nothing at all.
9
+ //
10
+ // Every inline delivery now walks the same rungs and returns the first that
11
+ // works:
12
+ //
13
+ // 1. host-renderable image → ImageContent (the model sees the picture)
14
+ // 2. extractable document → the FILE'S TEXT, as text (see src/extract)
15
+ // 3. raw bytes → base64 EmbeddedResource, as before
16
+ //
17
+ // Rung 3 never disappears, so nothing regresses; rung 2 is what makes a
18
+ // spreadsheet, PDF, Word or PowerPoint attachment readable at all. When a rung
19
+ // is skipped or fails, the response says so by name in `deliveryAttempts` —
20
+ // a caller must never be left guessing why it got bytes instead of content.
21
+ import { extractAttachment } from '../extract/index.js';
22
+ import { isHostRenderableImage } from './attachments.js';
23
+ /**
24
+ * Attempt extraction, converting every failure into a REASON rather than an
25
+ * exception: a format we cannot read must still be delivered as bytes, and the
26
+ * caller is owed the explanation either way.
27
+ */
28
+ export async function tryExtract(bytes, mimeType, fileName, opts) {
29
+ try {
30
+ const extracted = await extractAttachment(bytes, mimeType, fileName, {
31
+ maxChars: opts.maxChars,
32
+ parts: opts.parts,
33
+ });
34
+ if (!extracted) {
35
+ return { reason: `no text extractor for ${mimeType} (${fileName})` };
36
+ }
37
+ return { extracted, truncated: extracted.truncated ?? false };
38
+ }
39
+ catch (err) {
40
+ // A malformed .xlsx is still an .xlsx: report why it could not be read and
41
+ // fall through to the bytes, rather than failing the whole call.
42
+ return { reason: `extraction failed: ${err instanceof Error ? err.message : String(err)}` };
43
+ }
44
+ }
45
+ /**
46
+ * Build the content blocks for an inline download by walking the ladder.
47
+ * The first block is always a JSON meta block naming `deliveredVia`, so the
48
+ * caller can tell how the content arrived without inspecting block types.
49
+ */
50
+ export async function buildInlineDelivery(input) {
51
+ const { fileId, fileName, mimeType, bytes, forcedInline, options } = input;
52
+ const meta = {
53
+ fileId, fileName, mimeType, sizeBytes: bytes.length, mode: 'inline',
54
+ };
55
+ if (forcedInline)
56
+ meta.forcedInline = true;
57
+ const block = () => ({ type: 'text', text: JSON.stringify(meta, null, 2) });
58
+ // Rung 1 — the host renders these itself, and a picture beats a description.
59
+ if (isHostRenderableImage(mimeType)) {
60
+ meta.deliveredVia = 'image';
61
+ return { content: [block(), { type: 'image', data: bytes.toString('base64'), mimeType }] };
62
+ }
63
+ // Rung 2 — extraction. Skipped only when the caller explicitly opts out.
64
+ const attempts = [];
65
+ if (options.extract === false) {
66
+ attempts.push('extraction skipped (extract:false)');
67
+ }
68
+ else {
69
+ const outcome = await tryExtract(bytes, mimeType, fileName, options);
70
+ if (outcome.extracted) {
71
+ meta.deliveredVia = 'extracted';
72
+ meta.extracted = outcome.extracted;
73
+ meta.truncated = outcome.truncated;
74
+ // The bytes are deliberately NOT also attached: the extracted text is the
75
+ // readable form, and a duplicate base64 blob would be the very payload
76
+ // the host rejects — plus double the response size.
77
+ meta.note = 'Content extracted from the file. Pass extract:false to get the raw bytes instead.';
78
+ return { content: [block()] };
79
+ }
80
+ /* v8 ignore next -- tryExtract always sets `reason` when it returns no extraction */
81
+ attempts.push(outcome.reason ?? 'extraction produced no content');
82
+ }
83
+ // Rung 3 — the bytes themselves. Always available, so a fetch that succeeded
84
+ // never ends with the caller holding nothing.
85
+ meta.deliveredVia = 'blob';
86
+ meta.deliveryAttempts = attempts;
87
+ meta.note = 'Returned as raw bytes. Some hosts cannot render an embedded resource of this type; '
88
+ + 'if it came back unreadable, the file has no text extractor here (see deliveryAttempts).';
89
+ return {
90
+ content: [block(), {
91
+ type: 'resource',
92
+ resource: {
93
+ uri: `ofw://attachment/${fileId}/${encodeURIComponent(fileName)}`,
94
+ mimeType,
95
+ blob: bytes.toString('base64'),
96
+ },
97
+ }],
98
+ };
99
+ }
@@ -39,18 +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 draft's AUTHORITATIVE state straight from OFW, bypassing the cache.
65
+ * Read a message's AUTHORITATIVE state straight from OFW, bypassing the cache.
48
66
  *
49
- * Returns `null` when the draft no longer exists (404). Any other failure
50
- * throws `DraftFreshnessError`: a freshness check that could not run must
51
- * abort the write, never wave it through — see the callers in messages.ts.
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.
69
+ *
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).
52
74
  */
53
- export async function fetchServerDraft(client, id) {
75
+ export async function fetchMessageSnapshot(client, id) {
54
76
  let raw;
55
77
  try {
56
78
  raw = await client.request('GET', `/pub/v3/messages/${id}`);
@@ -71,12 +93,28 @@ export async function fetchServerDraft(client, id) {
71
93
  mode: 'strict',
72
94
  });
73
95
  return {
74
- subject: detail.subject ?? '',
75
- body: detail.body ?? '',
76
- replyToId: detail.replyToId ?? null,
77
- recipients: mapRecipients(detail.recipients),
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,
78
105
  };
79
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
+ }
80
118
  /**
81
119
  * The fields whose divergence constitutes a REAL conflict — the actual message
82
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
+ }