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.
@@ -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 = '2';
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.8.0', // 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
@@ -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 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, and the callers in messages.ts abort on ALL of them: a freshness
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` from this function, but not all —
54
- * a strict `parseLenient` mismatch on the response throws `McpToolError`
55
- * instead. Callers must not assume the narrower type (the catch blocks read
56
- * only `.message`, which every Error carries).
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 fetchServerDraft(client, id) {
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
- subject: detail.subject ?? '',
80
- body: detail.body ?? '',
81
- replyToId: detail.replyToId ?? null,
82
- 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,
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
+ }