ofw-mcp 2.10.2 → 2.12.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.
@@ -261,8 +261,13 @@ export class OFWCacheCore {
261
261
  listMessages(opts) {
262
262
  const { where, params } = buildMessageFilter(opts);
263
263
  const offset = (opts.page - 1) * opts.size;
264
+ // Direction comes from a closed set of literals, never from caller input.
265
+ // `id` tiebreaks in the SAME direction as `sent_at` so paging stays a total
266
+ // order: rows sharing a timestamp keep a stable relative position, and none
267
+ // is skipped or repeated at a page boundary.
268
+ const dir = opts.sort === 'oldest' ? 'ASC' : 'DESC';
264
269
  const rows = this.db.all(`SELECT * FROM messages ${where}
265
- ORDER BY sent_at DESC, id DESC
270
+ ORDER BY sent_at ${dir}, id ${dir}
266
271
  LIMIT ? OFFSET ?`, [...params, opts.size, offset]);
267
272
  return rows.map(rowFromDb);
268
273
  }
package/dist/client.js CHANGED
@@ -3,6 +3,7 @@ import { TokenManager } from '@chrischall/mcp-utils/session';
3
3
  import { dirname, join } from 'path';
4
4
  import { fileURLToPath } from 'url';
5
5
  import { resolveAuth } from './auth.js';
6
+ import { createSessionCache, reportCacheWriteFailure } from './session-cache.js';
6
7
  import { BASE_URL, OFW_PROTOCOL_HEADERS, OFW_TOKEN_TTL_MS, OFW_TOKEN_EXPIRY_SKEW_MS } from './protocol.js';
7
8
  // Load .env for local dev; silently skip if dotenv is unavailable (e.g. mcpb
8
9
  // bundle). loadDotenvSafely applies override:false + quiet:true and swallows a
@@ -62,9 +63,9 @@ export class OFWClient {
62
63
  // Bearer-token lifecycle is delegated to the shared, race-safe TokenManager
63
64
  // (proactive refresh inside the skew window, single-flight refresh so a burst
64
65
  // of concurrent callers coalesces onto ONE `resolveAuth()`, and a 401-replay
65
- // guarded against double-refresh). It is created lazily, seeded with an
66
- // already-expired placeholder token so the first request drives the refresh
67
- // callback — i.e. the original "log in on first request" behavior.
66
+ // guarded against double-refresh). It is created lazily, and mints on first
67
+ // use through the function form of `initial` — see `mint` below for why that
68
+ // form rather than a seeded placeholder.
68
69
  tokenManager;
69
70
  // Optional injected auth resolver. When set, the refresh callback uses it
70
71
  // instead of the module-level global `resolveAuth` (env-var → fetchproxy
@@ -78,22 +79,33 @@ export class OFWClient {
78
79
  }
79
80
  getTokenManager() {
80
81
  if (!this.tokenManager) {
82
+ // Minting and renewing are the SAME operation here — OFW has no refresh
83
+ // grant — so one function serves as both `initial` and `refresh`. It has
84
+ // to be the function form: the eager object form skips persistence, so
85
+ // the expired placeholder that used to sit here would have meant the
86
+ // cache was written but never read.
87
+ const mint = async () => {
88
+ const { token, expiresAt } = await (this.authResolver ?? resolveAuth)();
89
+ return {
90
+ accessToken: token,
91
+ refreshToken: OFW_REFRESH_SENTINEL,
92
+ expiresAt: (expiresAt ?? new Date(Date.now() + OFW_TOKEN_TTL_MS)).getTime(),
93
+ };
94
+ };
81
95
  this.tokenManager = new TokenManager({
82
- initial: { accessToken: '', refreshToken: OFW_REFRESH_SENTINEL, expiresAt: 0 },
96
+ initial: mint,
97
+ persistence: createSessionCache({ injectedResolver: this.authResolver !== undefined }) ?? undefined,
98
+ onPersistError: reportCacheWriteFailure,
99
+ // A failed renewal IS a failed login here, so the library's
100
+ // re-mint-on-revoked recovery would just repeat the call that failed.
101
+ isRefreshRevoked: () => false,
83
102
  skewMs: OFW_TOKEN_EXPIRY_SKEW_MS,
84
103
  // Map OFW's mint/refresh onto the refresh callback. `resolveAuth()`
85
104
  // returns a token and a best-effort expiry; when the fetchproxy path
86
105
  // can't supply one we fall back to the same 6h estimate the password
87
106
  // path uses (the 401-replay covers a wrong guess). We re-arm the
88
107
  // sentinel so the manager can refresh again later.
89
- refresh: async () => {
90
- const { token, expiresAt } = await (this.authResolver ?? resolveAuth)();
91
- return {
92
- accessToken: token,
93
- refreshToken: OFW_REFRESH_SENTINEL,
94
- expiresAt: (expiresAt ?? new Date(Date.now() + OFW_TOKEN_TTL_MS)).getTime(),
95
- };
96
- },
108
+ refresh: mint,
97
109
  });
98
110
  }
99
111
  return this.tokenManager;
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.10.2', // x-release-please-version
38
+ version: '2.12.0', // x-release-please-version
39
39
  deps: client,
40
40
  tools: [
41
41
  registerUserTools,
@@ -0,0 +1,78 @@
1
+ import { createFileStatePersistence, resolveStateFile, } from '@chrischall/mcp-utils/session';
2
+ import { readEnvVar, parseBoolEnv } from '@chrischall/mcp-utils';
3
+ /**
4
+ * Where the OFW session token is cached between runs.
5
+ *
6
+ * OFW has no OAuth refresh token — every renewal re-runs the full
7
+ * `resolveAuth()` (a password POST, or a fetchproxy snapshot). The token itself
8
+ * is good for six hours (`OFW_TOKEN_TTL_MS`), so on a scale-to-zero host, where
9
+ * children idle out after ten minutes and every start is a cold one, the same
10
+ * six-hour token was being re-minted many times over. Caching it turns those
11
+ * restarts into zero-cost ones; an expired token still costs exactly what it did
12
+ * before.
13
+ */
14
+ export function sessionCachePath(env = process.env) {
15
+ return resolveStateFile({
16
+ env,
17
+ envVar: 'OFW_SESSION_FILE',
18
+ subdir: '.ofw-mcp',
19
+ fileName: 'session.json',
20
+ });
21
+ }
22
+ /** Only a token pair is ever stored — never the username or password. */
23
+ function isTokens(raw) {
24
+ if (raw === null || typeof raw !== 'object')
25
+ return false;
26
+ const t = raw;
27
+ return (typeof t.accessToken === 'string' &&
28
+ t.accessToken !== '' &&
29
+ typeof t.expiresAt === 'number' &&
30
+ (t.refreshToken === undefined || typeof t.refreshToken === 'string'));
31
+ }
32
+ /**
33
+ * The session cache, or `null` when it must not be used.
34
+ *
35
+ * Three ways it comes back `null`, each deliberate:
36
+ *
37
+ * - `OFW_SESSION_CACHE=false` — the operator opted out.
38
+ * - **A caller-injected auth resolver.** That is the per-user hosted path, and
39
+ * this registration declares no `identity.perUserChild`, so one process can
40
+ * serve several people. A single cache file would hand one user's session to
41
+ * the next; until the child is per-user, that path simply does not cache.
42
+ * - **No env credentials.** The fetchproxy path authenticates from a signed-in
43
+ * browser tab rather than a stored secret, so there is nothing stable to bind
44
+ * a cached token to — and it is local-only, where cold starts are rare.
45
+ *
46
+ * When it is used, the record is bound to the credentials that minted it, so
47
+ * rotating either discards it. Only a salted digest is written.
48
+ */
49
+ export function createSessionCache(opts = {}) {
50
+ const env = opts.env ?? process.env;
51
+ if (opts.injectedResolver === true)
52
+ return null;
53
+ if (!parseBoolEnv('OFW_SESSION_CACHE', { env, default: true }))
54
+ return null;
55
+ const username = readEnvVar('OFW_USERNAME', { env });
56
+ const password = readEnvVar('OFW_PASSWORD', { env });
57
+ if (username === undefined || password === undefined)
58
+ return null;
59
+ return createFileStatePersistence({
60
+ filePath: sessionCachePath(env),
61
+ // Joined on a NUL, written as an escape rather than a literal byte: a
62
+ // password may contain spaces, so a space-joined pair could collide with
63
+ // a different pair by shifting the boundary between the two halves.
64
+ boundTo: [username.trim().toLowerCase(), password].join('\u0000'),
65
+ validate: (raw) => (isTokens(raw) ? raw : null),
66
+ });
67
+ }
68
+ /**
69
+ * Report a cache write that failed. Not fatal: OFW tokens are re-mintable from
70
+ * the credentials in the environment, so a lost write costs the next start a
71
+ * login rather than access. Still worth saying — a read-only data dir otherwise
72
+ * looks exactly like a server that never caches. stderr only; stdout is JSON-RPC.
73
+ */
74
+ export function reportCacheWriteFailure(err) {
75
+ const detail = err instanceof Error ? err.message : String(err);
76
+ console.error(`[ofw-mcp] could not cache the session token (${detail}); continuing without the ` +
77
+ 'cache — every restart will re-authenticate until this is fixed.');
78
+ }
@@ -1,5 +1,6 @@
1
1
  import { z } from 'zod';
2
2
  import { jsonResponse } from './_shared.js';
3
+ import { offsetState, readUpstreamPaging, withPaginationFirst } from './pagination.js';
3
4
  import { getWriteMode } from '../config.js';
4
5
  export function registerExpenseTools(server, client) {
5
6
  // Expense writes land on the court-visible record — OFW_WRITE_MODE 'all' only.
@@ -12,17 +13,34 @@ export function registerExpenseTools(server, client) {
12
13
  return jsonResponse(data);
13
14
  });
14
15
  server.registerTool('ofw_list_expenses', {
15
- description: 'List OurFamilyWizard expenses with pagination',
16
+ description: 'List OurFamilyWizard expenses. Offset-paged via start/max. The response leads with its paging state — `hasMore` and `nextStart` (null when the list is exhausted) — BEFORE the records, so a truncated or partially-read response still says whether more remain. Never state an expense total or an absence from one page.',
16
17
  annotations: { readOnlyHint: true },
17
18
  inputSchema: {
18
- start: z.number().int().min(0).describe('Start offset (default 0)').optional(),
19
+ start: z.number().int().min(0).describe('Start offset, 0-based (default 0). To continue a listing, pass the `nextStart` from the previous response.').optional(),
19
20
  max: z.number().int().min(1).describe('Max results (default 20)').optional(),
20
21
  },
21
22
  }, async (args) => {
22
23
  const start = args.start ?? 0;
23
24
  const max = args.max ?? 20;
24
25
  const data = await client.request('GET', `/pub/v2/expense/expenses?start=${start}&max=${max}`);
25
- return jsonResponse(data);
26
+ // Paging state FIRST, records after — a partial read of a spilled response
27
+ // must reach "there are more" before it reaches the records. See
28
+ // src/tools/pagination.ts for why the order is load-bearing.
29
+ //
30
+ // OFW wraps these listings as {data, metadata} and its metadata carries a
31
+ // `last` boolean, so "is there another page" is answered by the server
32
+ // rather than inferred from a full page (verified live).
33
+ const { returned, total, last } = readUpstreamPaging(data);
34
+ const wrapped = withPaginationFirst({
35
+ state: offsetState({ start, max, returned, total, last, base: 0 }),
36
+ start, max, returned, total,
37
+ hint: `Re-call ofw_list_expenses with start:${start + max}.`,
38
+ payload: data,
39
+ });
40
+ // A payload that is not a plain object cannot carry the paging keys at all.
41
+ // Pass it through untouched rather than relocating it — an added field is
42
+ // never worth changing a response's top-level shape.
43
+ return jsonResponse(wrapped ?? data);
26
44
  });
27
45
  if (allowWrites)
28
46
  server.registerTool('ofw_create_expense', {
@@ -1,14 +1,15 @@
1
1
  import { z } from 'zod';
2
2
  import { jsonResponse } from './_shared.js';
3
+ import { offsetState, readUpstreamPaging, withPaginationFirst } from './pagination.js';
3
4
  import { getWriteMode } from '../config.js';
4
5
  export function registerJournalTools(server, client) {
5
6
  // Journal writes land on the court-visible record — OFW_WRITE_MODE 'all' only.
6
7
  const allowWrites = getWriteMode() === 'all';
7
8
  server.registerTool('ofw_list_journal_entries', {
8
- description: 'List OurFamilyWizard journal entries',
9
+ description: 'List OurFamilyWizard journal entries. Offset-paged via start/max (1-based). The response leads with its paging state — `hasMore` and `nextStart` (null when the list is exhausted) — BEFORE the records, so a truncated or partially-read response still says whether more remain. Never state an entry count or an absence from one page.',
9
10
  annotations: { readOnlyHint: true },
10
11
  inputSchema: {
11
- start: z.number().int().min(1).describe('Start offset (default 1)').optional(),
12
+ start: z.number().int().min(1).describe('Start offset, 1-based (default 1). To continue a listing, pass the `nextStart` from the previous response.').optional(),
12
13
  max: z.number().int().min(1).describe('Max results (default 10)').optional(),
13
14
  },
14
15
  }, async (args) => {
@@ -16,7 +17,24 @@ export function registerJournalTools(server, client) {
16
17
  const start = args.start ?? 1;
17
18
  const max = args.max ?? 10;
18
19
  const data = await client.request('GET', `/pub/v1/journals?start=${start}&max=${max}`);
19
- return jsonResponse(data);
20
+ // Paging state FIRST, records after — a partial read of a spilled response
21
+ // must reach "there are more" before it reaches the records. See
22
+ // src/tools/pagination.ts for why the order is load-bearing.
23
+ //
24
+ // OFW wraps these listings as {data, metadata} and its metadata carries a
25
+ // `last` boolean, so "is there another page" is answered by the server
26
+ // rather than inferred from a full page (verified live).
27
+ const { returned, total, last } = readUpstreamPaging(data);
28
+ const wrapped = withPaginationFirst({
29
+ state: offsetState({ start, max, returned, total, last, base: 1 }),
30
+ start, max, returned, total,
31
+ hint: `Re-call ofw_list_journal_entries with start:${start + max}.`,
32
+ payload: data,
33
+ });
34
+ // A payload that is not a plain object cannot carry the paging keys at all.
35
+ // Pass it through untouched rather than relocating it — an added field is
36
+ // never worth changing a response's top-level shape.
37
+ return jsonResponse(wrapped ?? data);
20
38
  });
21
39
  if (allowWrites)
22
40
  server.registerTool('ofw_create_journal_entry', {
@@ -10,6 +10,7 @@ import { getAllowMarkRead, getAttachmentsDir, getAutoRefreshStaleReads, getDefau
10
10
  import { basename, join } from 'node:path';
11
11
  import { ApiRecipientSchema, deriveRead, expandPath, hasRealView, jsonErrorResponse, jsonResponse, mapRecipients, postMessageAndRefetch, reportsThreaded, reportsUnthreaded, textResponse, threadedReplyTo, verifyWriteLanded, withReadState } from './_shared.js';
12
12
  import { parseLenient } from '@chrischall/mcp-utils';
13
+ import { pageState } from './pagination.js';
13
14
  // Schemas for the load-bearing fields of each /pub/v3 response this file
14
15
  // reads (issue #83). Loose: unknown keys pass through into cached listData.
15
16
  const DateSchema = z.looseObject({ dateTime: z.string() });
@@ -254,7 +255,7 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
254
255
  return jsonResponse({ folders: data, freshness });
255
256
  });
256
257
  server.registerTool('ofw_list_messages', {
257
- description: 'List messages from the local OurFamilyWizard cache. Supports filtering by folder, date range, and a substring query on subject+body. Pagination is offset-based but if you know what you want (a date range, a topic), prefer the filters over walking pages — the cache may have 1000+ messages. Returns an explicit `complete` boolean describing the RESULT SET: true means "this is every message on OurFamilyWizard matching these filters as of freshness.asOf" — check it before asserting a count. An empty result from a cache that is not verified-fresh is REFUSED (result:"UNVERIFIED_EMPTY") rather than reported as an absence; pass autoRefresh:true to sync and answer instead.',
258
+ description: 'List messages from the local OurFamilyWizard cache. Supports filtering by folder, date range, and a substring query on subject+body. Pagination is offset-based (1-based `page`) but if you know what you want (a date range, a topic), prefer the filters over walking pages — the cache may have 1000+ messages. Results are newest-first by default; `sort:"oldest"` starts at the old end of a range instead of paging to it. Returns an explicit `complete` boolean describing the RESULT SET: true means "this is every message on OurFamilyWizard matching these filters as of freshness.asOf" — check it before asserting a count. An empty result from a cache that is not verified-fresh is REFUSED (result:"UNVERIFIED_EMPTY") rather than reported as an absence; pass autoRefresh:true to sync and answer instead.',
258
259
  annotations: { readOnlyHint: false },
259
260
  inputSchema: {
260
261
  folderId: z.string().describe('Folder name: "inbox", "sent", or "both" (default "both")').optional(),
@@ -263,11 +264,13 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
263
264
  since: z.string().describe('ISO date or datetime — only messages with sent_at >= since (inclusive)').optional(),
264
265
  until: z.string().describe('ISO date or datetime — only messages with sent_at < until (exclusive)').optional(),
265
266
  q: z.string().describe('Substring match on subject AND body (case-insensitive). Use to find messages on a specific topic.').optional(),
267
+ sort: z.enum(['newest', 'oldest']).describe('Result order: "newest" (default, newest first) or "oldest" (oldest first). This decides which end a truncated page keeps — with "newest" page 1 of a wide date range holds its most RECENT slice, with "oldest" its earliest. Use "oldest" to start at the old end of a range instead of paging to it.').optional(),
266
268
  autoRefresh: z.boolean().describe(AUTO_REFRESH_DESC).optional(),
267
269
  },
268
270
  }, async (args) => {
269
271
  const page = args.page ?? 1;
270
272
  const size = args.size ?? 50;
273
+ const sort = args.sort ?? 'newest';
271
274
  const folderArg = args.folderId ?? 'both';
272
275
  let folder;
273
276
  if (folderArg === 'inbox')
@@ -303,7 +306,7 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
303
306
  // can be stale (a message read after it was first scraped), so `read`
304
307
  // is derived from the record's own `viewedAt`/`fetchedBodyAt` and
305
308
  // `listData` is forced to agree — see withReadState.
306
- const messages = (await cache.listMessages({ ...filter, page, size })).map((m) => withReadState(m));
309
+ const messages = (await cache.listMessages({ ...filter, page, size, sort })).map((m) => withReadState(m));
307
310
  // Served from the local cache, so the result must say how old it is and
308
311
  // whether anything vouches for it — a caller cannot state current state
309
312
  // from this payload without either re-reading or surfacing the caveat.
@@ -328,7 +331,28 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
328
331
  // needs one boolean to check before saying "you have N messages".
329
332
  const fullSlice = page === 1 && messages.length === total;
330
333
  const complete = fullSlice && freshness.staleness === 'fresh' && freshness.historyComplete;
331
- const payload = { messages, total, page, size, complete, freshness };
334
+ const { hasMore, nextPage } = pageState({ page, size, total });
335
+ // Key ORDER is load-bearing, not cosmetic. These responses are large enough
336
+ // that a client may spill them to a file and a script may pull out only the
337
+ // fields it thought to name — which is how a correct `complete:false` next
338
+ // to a correct `completeNote` was dropped, and a 60-of-391 slice was
339
+ // reported as "October 1-28 is unreachable". Paging state and freshness are
340
+ // emitted BEFORE `messages` so a head, a preview, or a truncated read hits
341
+ // "this is a slice, fetch page 2" before it hits the first message body.
342
+ // The bulk goes last. See tests asserting this order — a refactor that
343
+ // rebuilds this literal silently undoes it.
344
+ const payload = {
345
+ complete,
346
+ hasMore,
347
+ nextPage,
348
+ // The honest record count, as a scalar and ahead of the array — a
349
+ // consumer never has to reach `messages` to learn how many came back.
350
+ returned: messages.length,
351
+ total,
352
+ page,
353
+ size,
354
+ sort,
355
+ };
332
356
  if (!complete) {
333
357
  payload.completeNote = [
334
358
  !fullSlice ? `this page holds ${messages.length} of ${total} matching cached messages` : null,
@@ -342,11 +366,13 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
342
366
  payload.note = 'No messages match these filters, and the cache IS verified-fresh for these folders — so this is a real "nothing matched", not a stale-cache artefact. If you expected results, relax the filters.';
343
367
  }
344
368
  else if (page * size < total) {
345
- payload.note = `Showing ${(page - 1) * size + 1}–${(page - 1) * size + messages.length} of ${total}. Increase 'page' to see more, or narrow with since/until/q.`;
369
+ payload.note = `Showing ${(page - 1) * size + 1}–${(page - 1) * size + messages.length} of ${total}, ${sort} first. Increase 'page' to see more, narrow with since/until/q, or set sort:"${sort === 'newest' ? 'oldest' : 'newest'}" to start from the other end.`;
346
370
  }
347
371
  if (refreshed) {
348
372
  payload.autoRefreshed = true;
349
373
  }
374
+ payload.freshness = freshness;
375
+ payload.messages = messages;
350
376
  return jsonResponse(payload);
351
377
  });
352
378
  server.registerTool('ofw_get_message', {
@@ -916,7 +942,18 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
916
942
  // against OFW inside the freshness window AND not a slice of a larger list.
917
943
  const fullSlice = page === 1 && drafts.length === total;
918
944
  const complete = serverConfirmed && fullSlice;
919
- const payload = { drafts, total, page, size, complete, freshness };
945
+ const { hasMore, nextPage } = pageState({ page, size, total });
946
+ // Paging state and freshness ahead of the bulk — see the note in
947
+ // ofw_list_messages.
948
+ const payload = {
949
+ complete,
950
+ hasMore,
951
+ nextPage,
952
+ returned: drafts.length,
953
+ total,
954
+ page,
955
+ size,
956
+ };
920
957
  if (!complete) {
921
958
  payload.completeNote = [
922
959
  !fullSlice ? `this page holds ${drafts.length} of ${total} cached drafts` : null,
@@ -937,6 +974,8 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
937
974
  if (verifyNote !== null) {
938
975
  payload.verifyNote = verifyNote;
939
976
  }
977
+ payload.freshness = freshness;
978
+ payload.drafts = drafts;
940
979
  return jsonResponse(payload);
941
980
  });
942
981
  if (allowDrafts)
@@ -1226,7 +1265,20 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
1226
1265
  unread.push({ id: msg.id, subject: msg.subject, sentAt: msg.sentAt, unreadBy });
1227
1266
  }
1228
1267
  }
1229
- const payload = { unread, scanned: sent.length, total, complete, freshness };
1268
+ const { hasMore, nextPage } = pageState({ page, size, total });
1269
+ // Paging state ahead of the bulk — see the note in ofw_list_messages. Note
1270
+ // `unread` is a VERDICT list, not the scanned set: it is routinely empty
1271
+ // while the scan itself is truncated, which is what `scanned`/`total`/
1272
+ // `complete` are for.
1273
+ const payload = {
1274
+ complete,
1275
+ hasMore,
1276
+ nextPage,
1277
+ total,
1278
+ scanned: sent.length,
1279
+ page,
1280
+ size,
1281
+ };
1230
1282
  if (!complete) {
1231
1283
  payload.completeNote = `This verdict covers the ${sent.length} of ${total} cached sent messages on this page${freshness.staleness === 'fresh' ? '' : `, from a cache that is "${freshness.staleness}"`}. It is not a statement about every message you have sent.`;
1232
1284
  }
@@ -1236,6 +1288,8 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
1236
1288
  if (refreshed) {
1237
1289
  payload.autoRefreshed = true;
1238
1290
  }
1291
+ payload.freshness = freshness;
1292
+ payload.unread = unread;
1239
1293
  return jsonResponse(payload);
1240
1294
  });
1241
1295
  if (allowDrafts)
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Pagination state that survives lossy consumption.
3
+ *
4
+ * The failure this module exists to prevent: a correct response was ignored.
5
+ * Three wide date-range reads each returned `complete:false` alongside a `note`
6
+ * and a `completeNote` spelling out that the page held 60 of 391 and how to get
7
+ * the rest. The responses were large, so the client spilled them to a JSON file
8
+ * and a script pulled out `total` and `messages` — dropping every field that
9
+ * said "this is a slice". The caller then reported October 1-28 as unreachable.
10
+ *
11
+ * The tool cannot fix a cherry-picking script. It can stop being easy to
12
+ * cherry-pick wrongly, which is what these helpers encode:
13
+ *
14
+ * - **State before bulk.** Callers place the paging/freshness keys ahead of the
15
+ * data array, so a `head`, a truncated preview, or a partial read reaches
16
+ * "this is 60 of 391" before it reaches the first message body. Key order in
17
+ * JSON is free — an object literal's insertion order is what JSON.stringify
18
+ * emits — so this costs nothing and is pure upside.
19
+ * - **The remedy is a value, not an inference.** `complete:false` tells a
20
+ * reader something is wrong; `nextPage: 2` tells it what to do. A reader that
21
+ * understands neither prose nor booleans can still act on an integer.
22
+ * - **The honest count is a scalar, not the array's length.** `returned` sits
23
+ * in the header beside `total`, so a consumer never has to reach the array
24
+ * to learn how many records came back.
25
+ */
26
+ /**
27
+ * Compute paging state for a 1-based `page`/`size` read.
28
+ *
29
+ * `hasMore` is decided from the OFFSET, not from `returned < total`: on a page
30
+ * past the end of the result set, `returned` is 0 while `total` is large, and
31
+ * comparing those two would advertise a `nextPage` that returns nothing forever.
32
+ */
33
+ export function pageState(input) {
34
+ const hasMore = input.page * input.size < input.total;
35
+ return { hasMore, nextPage: hasMore ? input.page + 1 : null };
36
+ }
37
+ function asRecord(value) {
38
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
39
+ ? value
40
+ : null;
41
+ }
42
+ /** Read the paging facts out of an OFW `{data, metadata}` list envelope. */
43
+ function recordArray(root) {
44
+ // `data` is the envelope both endpoints actually use, so it wins outright.
45
+ // Falling back to the first array-valued key matters because the alternative
46
+ // is silently publishing `returned: 0` and a "reaches the end of the list"
47
+ // note ALONGSIDE real records — the precise shape of lie this file exists to
48
+ // prevent, reintroduced by a backend rename.
49
+ if (Array.isArray(root.data))
50
+ return root.data;
51
+ for (const value of Object.values(root)) {
52
+ if (Array.isArray(value))
53
+ return value;
54
+ }
55
+ return null;
56
+ }
57
+ export function readUpstreamPaging(payload) {
58
+ const root = asRecord(payload);
59
+ const rows = root === null ? null : recordArray(root);
60
+ const meta = root === null ? null : asRecord(root.metadata);
61
+ const total = meta !== null && typeof meta.totalElements === 'number' && Number.isFinite(meta.totalElements)
62
+ ? meta.totalElements
63
+ : null;
64
+ const last = meta !== null && typeof meta.last === 'boolean' ? meta.last : null;
65
+ return { returned: rows?.length ?? 0, total, last };
66
+ }
67
+ /**
68
+ * Compute paging state for a `start`/`max` read, best evidence first.
69
+ *
70
+ * OFW's own `metadata.last` is authoritative and used whenever present. Failing
71
+ * that, a reported total settles it. Failing both, a FULL page is treated as
72
+ * probably-more: the bias is one-directional on purpose, because a `nextStart`
73
+ * that returns an empty page costs one wasted call, while a null that hides a
74
+ * further page reproduces the bug this file exists for — a slice narrated as
75
+ * the whole.
76
+ */
77
+ export function offsetState(input) {
78
+ const last = input.last ?? null;
79
+ const consumed = input.start - input.base + input.returned;
80
+ const hasMore = last !== null
81
+ ? !last
82
+ : input.total !== null
83
+ ? consumed < input.total
84
+ : input.returned >= input.max;
85
+ return { hasMore, nextStart: hasMore ? input.start + input.max : null };
86
+ }
87
+ /**
88
+ * Prepend paging state to an OFW list envelope, renaming and dropping nothing.
89
+ *
90
+ * The upstream object is spread in BEHIND the paging keys, so every key it had
91
+ * stays exactly where a caller expects to find it — only the order changes.
92
+ * The head is then spread a SECOND time: a plain `{...head, ...body}` lets an
93
+ * upstream `hasMore`/`nextStart`/`paginationNote` overwrite the computed value
94
+ * while keeping the head's key position, which is worse than either — a paging
95
+ * field sitting in the paging slot, reading as ours, sourced from elsewhere.
96
+ * Re-spreading restores our values; keys keep their first-insertion position,
97
+ * so the order is unchanged.
98
+ * Returns null when the payload is not a plain object and therefore cannot
99
+ * carry sibling keys at all; the caller then passes it through untouched rather
100
+ * than relocating it, so this can never change a response's top-level shape.
101
+ */
102
+ export function withPaginationFirst(input) {
103
+ const body = asRecord(input.payload);
104
+ if (body === null)
105
+ return null;
106
+ const scope = input.total !== null ? ` of ${input.total}` : '';
107
+ const head = {
108
+ hasMore: input.state.hasMore,
109
+ nextStart: input.state.nextStart,
110
+ start: input.start,
111
+ max: input.max,
112
+ returned: input.returned,
113
+ ...(input.total !== null ? { total: input.total } : {}),
114
+ paginationNote: input.state.hasMore
115
+ ? `PARTIAL: this response holds ${input.returned} record(s) starting at ${input.start}${scope}. `
116
+ + `${input.hint} Do not state a total or an absence from this response alone.`
117
+ : `This response reaches the end of the list${scope === '' ? '' : ` (${input.total} record(s) in total)`}.`,
118
+ };
119
+ return { ...head, ...body, ...head };
120
+ }