ofw-mcp 2.6.7 → 2.7.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 +29 -1
- package/dist/bundle.js +524 -161
- package/dist/config.js +25 -0
- package/dist/index.js +1 -1
- package/dist/sync.js +117 -24
- package/dist/tools/attachments.js +61 -0
- package/dist/tools/freshness.js +147 -0
- package/dist/tools/messages.js +297 -36
- package/package.json +1 -1
- package/server.json +2 -2
- package/skills/ofw/SKILL.md +16 -1
package/dist/config.js
CHANGED
|
@@ -110,3 +110,28 @@ export function getSyncMaxRequests() {
|
|
|
110
110
|
return Number.POSITIVE_INFINITY;
|
|
111
111
|
return n;
|
|
112
112
|
}
|
|
113
|
+
/** Default for getFreshnessTtlSeconds() when OFW_FRESHNESS_TTL_SECONDS is unset. */
|
|
114
|
+
export const DEFAULT_FRESHNESS_TTL_SECONDS = 300;
|
|
115
|
+
/**
|
|
116
|
+
* How long a folder's verified-against-OFW state stays labelled `fresh`.
|
|
117
|
+
*
|
|
118
|
+
* Read tools serve from the local cache, so every read result carries a
|
|
119
|
+
* `freshness` block saying when the data was last actually compared against
|
|
120
|
+
* OFW. Past this age the block downgrades to `unverified` and grows a warning,
|
|
121
|
+
* because a co-parent can send a message — or edit a draft in the OFW web app,
|
|
122
|
+
* which bumps no timestamp at all — at any moment without us hearing about it.
|
|
123
|
+
*
|
|
124
|
+
* Set OFW_FRESHNESS_TTL_SECONDS to a positive integer to tune it. Anything
|
|
125
|
+
* else (unset / blank / zero / negative / non-integer) falls back to the
|
|
126
|
+
* default: a bad value must not silently widen the window in which stale data
|
|
127
|
+
* is presented as current.
|
|
128
|
+
*/
|
|
129
|
+
export function getFreshnessTtlSeconds() {
|
|
130
|
+
const raw = readEnvVar('OFW_FRESHNESS_TTL_SECONDS');
|
|
131
|
+
if (raw === undefined)
|
|
132
|
+
return DEFAULT_FRESHNESS_TTL_SECONDS;
|
|
133
|
+
const n = Number(raw);
|
|
134
|
+
if (!Number.isInteger(n) || n <= 0)
|
|
135
|
+
return DEFAULT_FRESHNESS_TTL_SECONDS;
|
|
136
|
+
return n;
|
|
137
|
+
}
|
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.7.0', // x-release-please-version
|
|
39
39
|
deps: client,
|
|
40
40
|
tools: [
|
|
41
41
|
registerUserTools,
|
package/dist/sync.js
CHANGED
|
@@ -126,17 +126,19 @@ async function walkPages(client, folder, folderId, opts, store) {
|
|
|
126
126
|
let page = opts.startPage;
|
|
127
127
|
let newestId = null;
|
|
128
128
|
let synced = 0;
|
|
129
|
+
let pagesFetched = 0;
|
|
129
130
|
const unread = [];
|
|
130
131
|
while (true) {
|
|
131
132
|
// One unit per list-page fetch. Out of budget → pause and resume at `page`.
|
|
132
133
|
if (!budget.take()) {
|
|
133
|
-
return { synced, unread, newestId, done: false, nextPage: page };
|
|
134
|
+
return { synced, unread, newestId, pagesFetched, done: false, nextPage: page };
|
|
134
135
|
}
|
|
135
136
|
const path = `/pub/v3/messages?folders=${encodeURIComponent(folderId)}&page=${page}&size=50&sort=date&sortDirection=desc`;
|
|
136
137
|
const list = parseLenient(ListResponseSchema, await client.request('GET', path), { label: 'ofw-mcp', context: `GET /pub/v3/messages?folders={${folder}}` });
|
|
138
|
+
pagesFetched++;
|
|
137
139
|
const items = list.data ?? [];
|
|
138
140
|
if (items.length === 0) {
|
|
139
|
-
return { synced, unread, newestId, done: true, nextPage: null };
|
|
141
|
+
return { synced, unread, newestId, pagesFetched, done: true, nextPage: null };
|
|
140
142
|
}
|
|
141
143
|
// One batch read of this page's ids (S1) instead of a per-item getMessage.
|
|
142
144
|
const existingById = new Map((await store.getMessages(items.map((it) => it.id))).map((row) => [row.id, row]));
|
|
@@ -225,14 +227,14 @@ async function walkPages(client, folder, folderId, opts, store) {
|
|
|
225
227
|
if (pageBudgetHit) {
|
|
226
228
|
// Paused mid-page. Resume at THIS page: the partial rows are cached, so
|
|
227
229
|
// getMessages skips them next time and upserts are idempotent.
|
|
228
|
-
return { synced, unread, newestId, done: false, nextPage: page };
|
|
230
|
+
return { synced, unread, newestId, pagesFetched, done: false, nextPage: page };
|
|
229
231
|
}
|
|
230
232
|
// Reached cached history (see `stopAtCachedPage`). Report THIS page as the
|
|
231
233
|
// resume point rather than the next one: it costs one redundant (cheap,
|
|
232
234
|
// all-cached) fetch if a backfill later starts here, and it cannot skip a
|
|
233
235
|
// message the way an off-by-one `page + 1` could.
|
|
234
236
|
if (opts.stopAtCachedPage && !pageHadNewItem) {
|
|
235
|
-
return { synced, unread, newestId, done: true, nextPage: page };
|
|
237
|
+
return { synced, unread, newestId, pagesFetched, done: true, nextPage: page };
|
|
236
238
|
}
|
|
237
239
|
page++;
|
|
238
240
|
}
|
|
@@ -273,12 +275,29 @@ export async function syncMessageFolder(client, folder, folderId, opts, store) {
|
|
|
273
275
|
let done;
|
|
274
276
|
let resumePage;
|
|
275
277
|
if (!fwd.done) {
|
|
276
|
-
// The forward pass itself ran out of budget, so it never reached cached
|
|
277
|
-
// history — everything from `fwd.nextPage` down is unverified. Park the
|
|
278
|
-
// backfill at whichever cursor is higher up the folder, so no page that
|
|
279
|
-
// still owes us messages ends up above the resume point.
|
|
280
278
|
done = false;
|
|
281
|
-
|
|
279
|
+
if (fwd.pagesFetched === 0) {
|
|
280
|
+
// The budget was already spent when the forward pass started, so it
|
|
281
|
+
// fetched nothing and observed NOTHING about this folder. Leave the
|
|
282
|
+
// parked cursor exactly as it was: moving it on zero information is a
|
|
283
|
+
// pure loss.
|
|
284
|
+
//
|
|
285
|
+
// This was a real starvation bug. `fwd.nextPage` is just the start page
|
|
286
|
+
// (1) when nothing was fetched, so the `Math.min` below would silently
|
|
287
|
+
// reset a deep backfill — e.g. resumePage 87 → 1 — discarding 86 pages
|
|
288
|
+
// of progress. On the hosted Worker (OFW_SYNC_MAX_REQUESTS=40) a user
|
|
289
|
+
// with enough drafts to consume the whole budget, with drafts running
|
|
290
|
+
// first, hit this on EVERY call: inbox/sent never got budget, their
|
|
291
|
+
// cursor was reset every time, and the backfill could never advance.
|
|
292
|
+
resumePage = savedResume;
|
|
293
|
+
}
|
|
294
|
+
else {
|
|
295
|
+
// The forward pass did look, and ran out before reaching cached history
|
|
296
|
+
// — everything from `fwd.nextPage` down is unverified. Park the backfill
|
|
297
|
+
// at whichever cursor is higher up the folder, so no page that still
|
|
298
|
+
// owes us messages ends up above the resume point.
|
|
299
|
+
resumePage = savedResume === null ? fwd.nextPage : Math.min(fwd.nextPage, savedResume);
|
|
300
|
+
}
|
|
282
301
|
}
|
|
283
302
|
else if (fwd.nextPage === null) {
|
|
284
303
|
// The forward pass walked clean off the end of the folder — by definition
|
|
@@ -304,12 +323,16 @@ export async function syncMessageFolder(client, folder, folderId, opts, store) {
|
|
|
304
323
|
done = bf.done;
|
|
305
324
|
resumePage = bf.done ? null : bf.nextPage;
|
|
306
325
|
}
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
326
|
+
const now = new Date().toISOString();
|
|
327
|
+
await store.setSyncState(folder, { lastSyncAt: now, newestId, resumePage });
|
|
328
|
+
// The FORWARD pass is what proves our picture of the present is current: it
|
|
329
|
+
// always starts at page 1 and stops only once it reaches cached history. Its
|
|
330
|
+
// completion — not the backfill's — is what makes reads `fresh`. Same `now`
|
|
331
|
+
// as lastSyncAt so a verified folder never looks a millisecond behind its own
|
|
332
|
+
// sync (buildFreshness downgrades when lastSyncAt runs ahead of verifiedAt).
|
|
333
|
+
if (fwd.done)
|
|
334
|
+
await markFolderVerified(store, folder, now);
|
|
335
|
+
return { synced, unread, done, verified: fwd.done };
|
|
313
336
|
}
|
|
314
337
|
const DraftListItemSchema = z.looseObject({
|
|
315
338
|
id: z.number(),
|
|
@@ -334,6 +357,38 @@ export const DRAFTS_CACHE_STATUS_KEY = 'drafts_cache_status';
|
|
|
334
357
|
export async function getDraftsCacheStatus(store) {
|
|
335
358
|
return (await store.getMeta(DRAFTS_CACHE_STATUS_KEY)) === 'fresh' ? 'fresh' : 'unverified';
|
|
336
359
|
}
|
|
360
|
+
export async function setDraftsCacheStatus(store, status) {
|
|
361
|
+
await store.setMeta(DRAFTS_CACHE_STATUS_KEY, status);
|
|
362
|
+
}
|
|
363
|
+
/**
|
|
364
|
+
* Meta key holding when a folder was last actually COMPARED against OFW.
|
|
365
|
+
*
|
|
366
|
+
* Deliberately distinct from `sync_state.last_sync_at`, which is written on
|
|
367
|
+
* every call including one that paused mid-walk — so `last_sync_at` alone
|
|
368
|
+
* cannot mean "this folder is current", only "we tried". This key advances
|
|
369
|
+
* only when the folder was verified:
|
|
370
|
+
*
|
|
371
|
+
* inbox/sent the FORWARD pass completed, i.e. it walked from page 1 down to
|
|
372
|
+
* cached history (or off the end of the folder). That is exactly
|
|
373
|
+
* the pass that proves no new message is missing. A parked
|
|
374
|
+
* BACKFILL does not hold it back: incomplete old history says
|
|
375
|
+
* nothing about whether our picture of the present is current,
|
|
376
|
+
* and letting it downgrade every read would make the whole
|
|
377
|
+
* freshness signal noise during a long backfill.
|
|
378
|
+
* drafts the full walk + reconciliation ran (the same moment
|
|
379
|
+
* DRAFTS_CACHE_STATUS_KEY goes 'fresh').
|
|
380
|
+
*
|
|
381
|
+
* Absent = never verified, which reads as `stale`, not `fresh`.
|
|
382
|
+
*/
|
|
383
|
+
export function folderVerifiedAtKey(folder) {
|
|
384
|
+
return `folder_verified_at:${folder}`;
|
|
385
|
+
}
|
|
386
|
+
export async function getFolderVerifiedAt(store, folder) {
|
|
387
|
+
return (await store.getMeta(folderVerifiedAtKey(folder))) ?? null;
|
|
388
|
+
}
|
|
389
|
+
export async function markFolderVerified(store, folder, at = new Date().toISOString()) {
|
|
390
|
+
await store.setMeta(folderVerifiedAtKey(folder), at);
|
|
391
|
+
}
|
|
337
392
|
export async function syncDrafts(client, draftsFolderId, store, budget) {
|
|
338
393
|
// No budget → unbounded (local stdio): identical to the original walk.
|
|
339
394
|
const b = budget ?? makeBudget(Number.POSITIVE_INFINITY);
|
|
@@ -342,7 +397,16 @@ export async function syncDrafts(client, draftsFolderId, store, budget) {
|
|
|
342
397
|
// know the cache is not a trustworthy base — the count we return here is
|
|
343
398
|
// "nothing applied", NOT "nothing changed on the server".
|
|
344
399
|
const defer = async () => {
|
|
345
|
-
await store
|
|
400
|
+
await setDraftsCacheStatus(store, 'unverified');
|
|
401
|
+
// Record the ATTEMPT but not a verification: buildFreshness compares the
|
|
402
|
+
// two and downgrades when a sync ran without verifying this folder, so a
|
|
403
|
+
// deferred walk actively marks reads unverified rather than leaving them
|
|
404
|
+
// coasting on an older stamp.
|
|
405
|
+
await store.setSyncState('drafts', {
|
|
406
|
+
lastSyncAt: new Date().toISOString(),
|
|
407
|
+
newestId: null,
|
|
408
|
+
resumePage: null,
|
|
409
|
+
});
|
|
346
410
|
return { synced: 0, done: false };
|
|
347
411
|
};
|
|
348
412
|
// The reconciliation step below DELETES any cached draft not seen in the
|
|
@@ -411,8 +475,12 @@ export async function syncDrafts(client, draftsFolderId, store, budget) {
|
|
|
411
475
|
}
|
|
412
476
|
// The complete walk fetched every draft's DETAIL and reconciled deletions, so
|
|
413
477
|
// the cache is now known-equal to the server. Only here is `synced: 0`
|
|
414
|
-
// truthful as "verified no changes"
|
|
415
|
-
|
|
478
|
+
// truthful as "verified no changes" — and only here may reads report the
|
|
479
|
+
// drafts as server-confirmed.
|
|
480
|
+
const now = new Date().toISOString();
|
|
481
|
+
await setDraftsCacheStatus(store, 'fresh');
|
|
482
|
+
await store.setSyncState('drafts', { lastSyncAt: now, newestId: null, resumePage: null });
|
|
483
|
+
await markFolderVerified(store, 'drafts', now);
|
|
416
484
|
return { synced, done: true };
|
|
417
485
|
}
|
|
418
486
|
export async function syncAll(client, opts, store) {
|
|
@@ -440,6 +508,21 @@ export async function syncAll(client, opts, store) {
|
|
|
440
508
|
let unreadInbox = [];
|
|
441
509
|
let done = true;
|
|
442
510
|
let draftsUnverified = false;
|
|
511
|
+
const refreshed = [];
|
|
512
|
+
const notRefreshed = [];
|
|
513
|
+
// Same rule for every folder: a count is reported, and the folder is listed
|
|
514
|
+
// as refreshed, ONLY when it was actually diffed against OFW. A folder the
|
|
515
|
+
// budget never reached reports no number at all — `inbox: 0` reads as
|
|
516
|
+
// "verified, no new messages", and that lie is the whole bug this guards.
|
|
517
|
+
const record = (folder, verified, count) => {
|
|
518
|
+
if (verified) {
|
|
519
|
+
synced[folder] = count;
|
|
520
|
+
refreshed.push(folder);
|
|
521
|
+
}
|
|
522
|
+
else {
|
|
523
|
+
notRefreshed.push(folder);
|
|
524
|
+
}
|
|
525
|
+
};
|
|
443
526
|
for (const folder of folders) {
|
|
444
527
|
if (folder === 'inbox') {
|
|
445
528
|
const r = await syncMessageFolder(client, 'inbox', ids.inbox, {
|
|
@@ -447,7 +530,7 @@ export async function syncAll(client, opts, store) {
|
|
|
447
530
|
deep: opts.deep ?? false,
|
|
448
531
|
budget,
|
|
449
532
|
}, store);
|
|
450
|
-
|
|
533
|
+
record('inbox', r.verified, r.synced);
|
|
451
534
|
unreadInbox = r.unread;
|
|
452
535
|
if (!r.done)
|
|
453
536
|
done = false;
|
|
@@ -458,7 +541,7 @@ export async function syncAll(client, opts, store) {
|
|
|
458
541
|
deep: opts.deep ?? false,
|
|
459
542
|
budget,
|
|
460
543
|
}, store);
|
|
461
|
-
|
|
544
|
+
record('sent', r.verified, r.synced);
|
|
462
545
|
if (!r.done)
|
|
463
546
|
done = false;
|
|
464
547
|
}
|
|
@@ -468,9 +551,8 @@ export async function syncAll(client, opts, store) {
|
|
|
468
551
|
// OFW. A deferred walk applied nothing, and reporting its `0` as
|
|
469
552
|
// `drafts: 0` reads as "verified, no changes" — the exact lie that let a
|
|
470
553
|
// server-side draft edit be overwritten. Omit the number instead.
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
else {
|
|
554
|
+
record('drafts', r.done, r.synced);
|
|
555
|
+
if (!r.done) {
|
|
474
556
|
draftsUnverified = true;
|
|
475
557
|
done = false;
|
|
476
558
|
}
|
|
@@ -483,9 +565,20 @@ export async function syncAll(client, opts, store) {
|
|
|
483
565
|
if (unreadInbox.length > 0) {
|
|
484
566
|
notes.push(`${unreadInbox.length} unread inbox messages cached without bodies. Call ofw_get_message(id) to read them — this will mark them as read on OFW.`);
|
|
485
567
|
}
|
|
568
|
+
if (notRefreshed.length > 0) {
|
|
569
|
+
notes.push(`NOT checked against OurFamilyWizard on this call: ${notRefreshed.join(', ')}. No count is reported for ${notRefreshed.length > 1 ? 'those folders' : 'that folder'} — absence of a count means "not looked at", not "no changes". Cached contents may be behind the server; call ofw_sync_messages again to finish, or ofw_check_freshness for a cheap live confirmation.`);
|
|
570
|
+
}
|
|
486
571
|
if (!done) {
|
|
487
572
|
notes.push('Paused after the request budget to stay within the hosting limit; more pages remain — call ofw_sync_messages again with the same arguments to resume where it left off and continue the backfill.');
|
|
488
573
|
}
|
|
489
574
|
const note = notes.length > 0 ? notes.join('\n\n') : undefined;
|
|
490
|
-
return {
|
|
575
|
+
return {
|
|
576
|
+
synced,
|
|
577
|
+
unreadInbox,
|
|
578
|
+
done,
|
|
579
|
+
syncComplete: done,
|
|
580
|
+
refreshed,
|
|
581
|
+
notRefreshed,
|
|
582
|
+
...(note ? { note } : {}),
|
|
583
|
+
};
|
|
491
584
|
}
|
|
@@ -38,8 +38,69 @@ const MIME_BY_EXT = {
|
|
|
38
38
|
export function mimeFromName(name) {
|
|
39
39
|
return MIME_BY_EXT[extname(name).toLowerCase()] ?? 'application/octet-stream';
|
|
40
40
|
}
|
|
41
|
+
const OCTET_STREAM = 'application/octet-stream';
|
|
42
|
+
// The media types a host's inline image renderer accepts. Anything else — even
|
|
43
|
+
// a valid image type like image/heic — must go back as an EmbeddedResource, and
|
|
44
|
+
// a parameter suffix (image/png;charset=UTF-8) is rejected outright, which is
|
|
45
|
+
// exactly the bug this normalization boundary exists to prevent.
|
|
46
|
+
const HOST_RENDERABLE_IMAGE_MIMES = new Set([
|
|
47
|
+
'image/png', 'image/jpeg', 'image/gif', 'image/webp',
|
|
48
|
+
]);
|
|
49
|
+
/**
|
|
50
|
+
* Strip a MIME type down to its bare `type/subtype`: drop any `;`-delimited
|
|
51
|
+
* parameters (`charset`, `name`, …), lowercase, and trim. An empty/absent value
|
|
52
|
+
* becomes `application/octet-stream`. OFW hands back `image/png;charset=UTF-8`
|
|
53
|
+
* on binary attachments, and a host's image renderer rejects the parameter
|
|
54
|
+
* suffix — so no derived MIME must ever carry one.
|
|
55
|
+
*/
|
|
56
|
+
export function normalizeMimeType(raw) {
|
|
57
|
+
if (!raw)
|
|
58
|
+
return OCTET_STREAM;
|
|
59
|
+
const bare = raw.split(';', 1)[0].trim().toLowerCase();
|
|
60
|
+
return bare || OCTET_STREAM;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Detect a host-renderable image type from the leading bytes (magic numbers).
|
|
64
|
+
* OFW's `Content-Type` is unreliable for binaries (it tacks a text `charset`
|
|
65
|
+
* onto them), so the actual bytes are the authoritative signal. Returns the
|
|
66
|
+
* bare media type, or null when the bytes aren't a PNG/JPEG/GIF/WEBP.
|
|
67
|
+
*/
|
|
68
|
+
const PNG_MAGIC = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
69
|
+
const JPEG_MAGIC = Buffer.from([0xff, 0xd8, 0xff]);
|
|
70
|
+
export function sniffImageMime(bytes) {
|
|
71
|
+
if (bytes.length >= 8 && bytes.subarray(0, 8).equals(PNG_MAGIC))
|
|
72
|
+
return 'image/png';
|
|
73
|
+
if (bytes.length >= 3 && bytes.subarray(0, 3).equals(JPEG_MAGIC))
|
|
74
|
+
return 'image/jpeg';
|
|
75
|
+
if (bytes.length >= 6 && bytes.toString('ascii', 0, 4) === 'GIF8')
|
|
76
|
+
return 'image/gif';
|
|
77
|
+
if (bytes.length >= 12 &&
|
|
78
|
+
bytes.toString('ascii', 0, 4) === 'RIFF' && bytes.toString('ascii', 8, 12) === 'WEBP') {
|
|
79
|
+
return 'image/webp';
|
|
80
|
+
}
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Resolve the MIME type to report for downloaded bytes, in priority order:
|
|
85
|
+
* magic-number sniff (bytes never lie) → parameter-stripped upstream header →
|
|
86
|
+
* filename extension. The result is always bare (never carries a `;` parameter).
|
|
87
|
+
*/
|
|
88
|
+
export function resolveDownloadMime(bytes, headerMime, fileName) {
|
|
89
|
+
const sniffed = sniffImageMime(bytes);
|
|
90
|
+
if (sniffed)
|
|
91
|
+
return sniffed;
|
|
92
|
+
const fromHeader = normalizeMimeType(headerMime);
|
|
93
|
+
if (fromHeader !== OCTET_STREAM)
|
|
94
|
+
return fromHeader;
|
|
95
|
+
return mimeFromName(fileName);
|
|
96
|
+
}
|
|
97
|
+
/** True only for the bare media types a host renders as inline ImageContent. */
|
|
98
|
+
export function isHostRenderableImage(mime) {
|
|
99
|
+
return HOST_RENDERABLE_IMAGE_MIMES.has(mime);
|
|
100
|
+
}
|
|
41
101
|
/** Disk-backed attachment I/O for the stdio/desktop server. */
|
|
42
102
|
export class NodeAttachmentIO {
|
|
103
|
+
supportsDisk = true;
|
|
43
104
|
async resolveUpload(path) {
|
|
44
105
|
const abs = expandPath(path);
|
|
45
106
|
const stat = statSync(abs); // throws if missing
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { getFolderVerifiedAt } from '../sync.js';
|
|
2
|
+
import { getFreshnessTtlSeconds } from '../config.js';
|
|
3
|
+
const RANK = { fresh: 0, unverified: 1, stale: 2 };
|
|
4
|
+
function worst(a, b) {
|
|
5
|
+
return RANK[a] >= RANK[b] ? a : b;
|
|
6
|
+
}
|
|
7
|
+
/** Human-readable age: seconds under a minute, else whole minutes. */
|
|
8
|
+
function describeAge(seconds) {
|
|
9
|
+
return seconds < 60 ? `${seconds} sec ago` : `${Math.round(seconds / 60)} min ago`;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Build the `freshness` block that every message/draft/folder read carries.
|
|
13
|
+
*
|
|
14
|
+
* The point is that the DATA announces its own age and reliability, so a
|
|
15
|
+
* caller cannot assert current state without either a fresh read or an
|
|
16
|
+
* explicit staleness caveat it has to surface. Nothing here depends on the
|
|
17
|
+
* model remembering to re-check.
|
|
18
|
+
*
|
|
19
|
+
* Across multiple folders the block reports the WORST staleness and the OLDEST
|
|
20
|
+
* asOf: a response is only as trustworthy as its least-verified input.
|
|
21
|
+
*/
|
|
22
|
+
export async function buildFreshness(store, opts) {
|
|
23
|
+
const now = opts.now ?? new Date();
|
|
24
|
+
const ttl = opts.ttlSeconds ?? getFreshnessTtlSeconds();
|
|
25
|
+
// A cache read backed by NO folder has verified nothing, so nothing in the
|
|
26
|
+
// per-folder loop below can downgrade the 'fresh' initializer — it would
|
|
27
|
+
// return `staleness: 'fresh'` alongside `asOf: null`, self-contradictory by
|
|
28
|
+
// this module's own definition and the one shape that carries no warning at
|
|
29
|
+
// all. Reachable via ofw_sync_messages({folders: []}). Fail to `stale`: an
|
|
30
|
+
// empty scope is the least evidence possible, not the most.
|
|
31
|
+
// (A LIVE read with no folders is different and legitimate — that is
|
|
32
|
+
// ofw_list_message_folders, whose data came straight off the wire.)
|
|
33
|
+
const emptyScope = opts.source === 'cache' && opts.folders.length === 0;
|
|
34
|
+
let staleness = emptyScope ? 'stale' : 'fresh';
|
|
35
|
+
let oldestVerifiedAt = null;
|
|
36
|
+
let sawNeverVerified = false;
|
|
37
|
+
let lastServerSyncAt = null;
|
|
38
|
+
let historyComplete = true;
|
|
39
|
+
// An empty scope verified nothing, so it cannot claim a complete sync.
|
|
40
|
+
let syncComplete = !emptyScope;
|
|
41
|
+
const deferred = [];
|
|
42
|
+
const backfilling = [];
|
|
43
|
+
for (const folder of opts.folders) {
|
|
44
|
+
const verifiedAt = await getFolderVerifiedAt(store, folder);
|
|
45
|
+
const state = await store.getSyncState(folder);
|
|
46
|
+
if (state !== null && (lastServerSyncAt === null || state.lastSyncAt > lastServerSyncAt)) {
|
|
47
|
+
lastServerSyncAt = state.lastSyncAt;
|
|
48
|
+
}
|
|
49
|
+
// A parked backfill means old history is incomplete. It does NOT downgrade
|
|
50
|
+
// staleness: the forward pass runs from page 1 on every call, so the
|
|
51
|
+
// present is current even while history is still being walked. Letting a
|
|
52
|
+
// months-long backfill mark every read `unverified` would train the caller
|
|
53
|
+
// to ignore the warning entirely.
|
|
54
|
+
if (state !== null && state.resumePage !== null) {
|
|
55
|
+
historyComplete = false;
|
|
56
|
+
syncComplete = false;
|
|
57
|
+
backfilling.push(folder);
|
|
58
|
+
}
|
|
59
|
+
if (verifiedAt === null) {
|
|
60
|
+
sawNeverVerified = true;
|
|
61
|
+
staleness = worst(staleness, 'stale');
|
|
62
|
+
syncComplete = false;
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
if (oldestVerifiedAt === null || verifiedAt < oldestVerifiedAt)
|
|
66
|
+
oldestVerifiedAt = verifiedAt;
|
|
67
|
+
// A sync ran AFTER the last verification of this folder — i.e. it was
|
|
68
|
+
// attempted and skipped (budget exhausted before reaching it). Recency of
|
|
69
|
+
// the older stamp must not keep it `fresh`; the skip is itself evidence
|
|
70
|
+
// that we do not currently know this folder's state.
|
|
71
|
+
if (state !== null && state.lastSyncAt > verifiedAt) {
|
|
72
|
+
staleness = worst(staleness, 'unverified');
|
|
73
|
+
syncComplete = false;
|
|
74
|
+
deferred.push(folder);
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
// Clamp at 0: a verifiedAt in the future (clock skew between the machine
|
|
78
|
+
// that wrote it and this one) must not read as a large negative age that
|
|
79
|
+
// can never exceed the threshold — that would be a silent false `fresh`.
|
|
80
|
+
const age = Math.max(0, Math.floor((now.getTime() - Date.parse(verifiedAt)) / 1000));
|
|
81
|
+
if (age > ttl)
|
|
82
|
+
staleness = worst(staleness, 'unverified');
|
|
83
|
+
}
|
|
84
|
+
// A live read is current by construction — that is the whole point of paying
|
|
85
|
+
// for it — so it reports fresh regardless of what the cache looks like.
|
|
86
|
+
if (opts.source === 'live') {
|
|
87
|
+
const asOf = now.toISOString();
|
|
88
|
+
const block = {
|
|
89
|
+
source: 'live',
|
|
90
|
+
asOf,
|
|
91
|
+
ageSeconds: 0,
|
|
92
|
+
staleness: 'fresh',
|
|
93
|
+
lastServerSyncAt,
|
|
94
|
+
syncComplete,
|
|
95
|
+
historyComplete,
|
|
96
|
+
};
|
|
97
|
+
const liveReasons = [];
|
|
98
|
+
if (sawNeverVerified) {
|
|
99
|
+
liveReasons.push('the surrounding cache has never been checked against OurFamilyWizard, so anything you did NOT fetch in this call is unverified');
|
|
100
|
+
}
|
|
101
|
+
if (backfilling.length > 0) {
|
|
102
|
+
liveReasons.push(`older history is still being backfilled for ${backfilling.join(', ')}, so older messages may be missing from the cache`);
|
|
103
|
+
}
|
|
104
|
+
if (liveReasons.length > 0) {
|
|
105
|
+
block.warning = `Fetched live from OurFamilyWizard, so this data is current. Note that ${liveReasons.join('; ')}.`;
|
|
106
|
+
}
|
|
107
|
+
return block;
|
|
108
|
+
}
|
|
109
|
+
const asOf = sawNeverVerified ? null : oldestVerifiedAt;
|
|
110
|
+
const ageSeconds = asOf === null
|
|
111
|
+
? null
|
|
112
|
+
: Math.max(0, Math.floor((now.getTime() - Date.parse(asOf)) / 1000));
|
|
113
|
+
const block = {
|
|
114
|
+
source: 'cache',
|
|
115
|
+
asOf,
|
|
116
|
+
ageSeconds,
|
|
117
|
+
staleness,
|
|
118
|
+
lastServerSyncAt,
|
|
119
|
+
syncComplete,
|
|
120
|
+
historyComplete,
|
|
121
|
+
};
|
|
122
|
+
const reasons = [];
|
|
123
|
+
if (emptyScope) {
|
|
124
|
+
reasons.push('this result is backed by no synced folder at all, so nothing about it has been verified');
|
|
125
|
+
}
|
|
126
|
+
if (sawNeverVerified) {
|
|
127
|
+
reasons.push('this data has never been checked against OurFamilyWizard');
|
|
128
|
+
}
|
|
129
|
+
if (deferred.length > 0) {
|
|
130
|
+
reasons.push(`the last sync did not finish checking ${deferred.join(', ')}`);
|
|
131
|
+
}
|
|
132
|
+
// Reported independently of the deferral: a response can be BOTH deferred
|
|
133
|
+
// and badly aged, and naming only the deferral understates how old it is.
|
|
134
|
+
if (asOf !== null && ageSeconds !== null && ageSeconds > ttl) {
|
|
135
|
+
reasons.push(`that is past the ${ttl}s freshness threshold`);
|
|
136
|
+
}
|
|
137
|
+
if (backfilling.length > 0) {
|
|
138
|
+
reasons.push(`older history is still being backfilled for ${backfilling.join(', ')}`);
|
|
139
|
+
}
|
|
140
|
+
if (reasons.length > 0) {
|
|
141
|
+
const served = asOf === null
|
|
142
|
+
? 'Served from cache that was never verified against OurFamilyWizard'
|
|
143
|
+
: `Served from cache last verified ${describeAge(ageSeconds)}`;
|
|
144
|
+
block.warning = `${served}; ${reasons.join('; ')}. Re-read before asserting current state — call ofw_check_freshness for a cheap live confirmation, or ofw_sync_messages to refresh.`;
|
|
145
|
+
}
|
|
146
|
+
return block;
|
|
147
|
+
}
|