@khorsheed/dsh-reader 0.2.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.
Files changed (58) hide show
  1. package/README.en.md +90 -0
  2. package/README.i18n.yaml +6 -0
  3. package/README.md +90 -0
  4. package/cordis.patch.yml +13 -0
  5. package/lib/client.js +15119 -0
  6. package/lib/index.js +3660 -0
  7. package/lib/invariant.js +38 -0
  8. package/lib/typert.host.d.ts +3 -0
  9. package/lib/typert.host.js +1738 -0
  10. package/lib/typert.remote-client.d.ts +82 -0
  11. package/lib/typert.remote-client.js +1115 -0
  12. package/lib/types/arxiv.d.ts +47 -0
  13. package/lib/types/arxiv.js +76 -0
  14. package/lib/types/client/ReaderPane.d.ts +71 -0
  15. package/lib/types/client/ReaderPane.js +3135 -0
  16. package/lib/types/client/contract.d.ts +254 -0
  17. package/lib/types/client/contract.js +1 -0
  18. package/lib/types/client/definition.d.ts +27 -0
  19. package/lib/types/client/definition.js +32 -0
  20. package/lib/types/client/extract-article.d.ts +102 -0
  21. package/lib/types/client/extract-article.js +1024 -0
  22. package/lib/types/client/icons.d.ts +27 -0
  23. package/lib/types/client/icons.js +11 -0
  24. package/lib/types/client/index.d.ts +34 -0
  25. package/lib/types/client/index.js +222 -0
  26. package/lib/types/client/locales.d.ts +391 -0
  27. package/lib/types/client/locales.js +382 -0
  28. package/lib/types/client/parse-rss.d.ts +69 -0
  29. package/lib/types/client/parse-rss.js +656 -0
  30. package/lib/types/client/quote.d.ts +89 -0
  31. package/lib/types/client/quote.js +119 -0
  32. package/lib/types/client/selectors.d.ts +167 -0
  33. package/lib/types/client/selectors.js +413 -0
  34. package/lib/types/client/session.d.ts +171 -0
  35. package/lib/types/client/session.js +223 -0
  36. package/lib/types/client/store.d.ts +181 -0
  37. package/lib/types/client/store.js +226 -0
  38. package/lib/types/client/translate.d.ts +379 -0
  39. package/lib/types/client/translate.js +873 -0
  40. package/lib/types/index.d.ts +48 -0
  41. package/lib/types/index.js +25 -0
  42. package/lib/types/invariant.d.ts +29 -0
  43. package/lib/types/invariant.js +38 -0
  44. package/lib/types/link-resolvers.d.ts +107 -0
  45. package/lib/types/link-resolvers.js +299 -0
  46. package/lib/types/remote.d.ts +224 -0
  47. package/lib/types/remote.js +275 -0
  48. package/lib/types/schedule.d.ts +78 -0
  49. package/lib/types/schedule.js +114 -0
  50. package/lib/types/service.d.ts +633 -0
  51. package/lib/types/service.js +1697 -0
  52. package/lib/types/store.d.ts +224 -0
  53. package/lib/types/store.js +916 -0
  54. package/lib/types/types.d.ts +710 -0
  55. package/lib/types/types.js +182 -0
  56. package/locale/en.json +6 -0
  57. package/locale/zh.json +6 -0
  58. package/package.json +128 -0
@@ -0,0 +1,916 @@
1
+ /**
2
+ * The host-side state store: one JSON document under the deployment's state
3
+ * root, holding the source list and, per source, the raw payload of its last
4
+ * fetch plus that fetch's metadata.
5
+ *
6
+ * **Why plain `node:fs` and not `ctx.fs`.** `ctx.fs` is the SANDBOXED
7
+ * filesystem: every mutation is fenced by the calling session's policy, so a
8
+ * workspace-write session refuses a write to `$DSH_HOME/state` with
9
+ * `FS_SANDBOX_DENIED` — which is exactly what happened on the acceptance
10
+ * instance, where a subscription failed with
11
+ * `cannot write "…/state/dsh-reader/state.json": file access denied under
12
+ * workspace-write mode`. That fence is correct and must stay; the mistake is
13
+ * aiming a session-fenced capability at host-owned durable state. This
14
+ * package's state belongs to the DEPLOYMENT, not to whichever session happened
15
+ * to click "fetch", so it is written the way this repo's other host-state
16
+ * packages write theirs (`packages/lab/src/state.ts`): `node:fs`, atomic
17
+ * via a temporary file plus rename.
18
+ *
19
+ * The rest of the contract is unchanged and is why this file exists:
20
+ *
21
+ * - a missing document reads as empty, and a **corrupt** one refuses rather
22
+ * than being overwritten, so a hand-edited file stays visible until its
23
+ * owner fixes it instead of being silently destroyed by the next refresh;
24
+ * - an unwritable root degrades to memory-only instead of taking the plugin
25
+ * down, because a composition must never fail for lack of a writable disk;
26
+ * - nothing here parses a feed. The document stores raw payloads, and the
27
+ * browser half — the only side with a DOM parser — turns them into entries.
28
+ *
29
+ * @module @khorsheed/dsh-reader/store
30
+ */
31
+ import { createHash } from 'node:crypto';
32
+ import { mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
33
+ import { join } from 'node:path';
34
+ import { DEFAULT_CACHE_POLICY, MAX_BODY_CHARS_PER_SOURCE, MAX_TOTAL_BODY_CHARS, MAX_TRANSLATION_MEMORY_ENTRIES, STATE_ROOT_SEGMENT, TRANSLATION_STORAGE_VERSION, } from "./types.js";
35
+ /** The failure codes the document may carry, for normalization. */
36
+ const PREVIEW_FAILURE_CODES = new Set([
37
+ 'blocked', 'login', 'unsupported-type', 'redirected', 'empty', 'unreachable', 'http', 'unreadable',
38
+ ]);
39
+ /** How many tags one document may define (a vocabulary, not a folksonomy dump). */
40
+ export const MAX_TAGS = 200;
41
+ /** How many entries may carry fetched bodies, before the newest-first eviction. */
42
+ export const MAX_CACHED_BODIES = 500;
43
+ /** The state file name inside the plugin's state root. */
44
+ export const READER_STATE_FILE_NAME = 'state.json';
45
+ /** A store failure that the service maps onto a wire outcome. */
46
+ export class ReaderStoreError extends Error {
47
+ kind;
48
+ constructor(message, kind) {
49
+ super(message);
50
+ this.kind = kind;
51
+ this.name = 'ReaderStoreError';
52
+ }
53
+ }
54
+ /**
55
+ * Resolve the state root: an explicit config value wins, then
56
+ * `$DSH_HOME/state/dsh-reader`, then `<cwd>/.dsh-reader`.
57
+ *
58
+ * @param configured - the plugin-provided override, if any.
59
+ * @returns the absolute state root directory.
60
+ */
61
+ export function resolveReaderStateRoot(configured) {
62
+ if (configured !== undefined && configured !== '')
63
+ return configured;
64
+ const home = process.env.DSH_HOME;
65
+ if (home !== undefined && home !== '')
66
+ return join(home, 'state', STATE_ROOT_SEGMENT);
67
+ return join(process.cwd(), `.${STATE_ROOT_SEGMENT}`);
68
+ }
69
+ /** The empty document. */
70
+ export function emptyStateDoc() {
71
+ return { version: 1, sources: [], refresh: { enabled: true, timeOfDay: '10:00' } };
72
+ }
73
+ /** Serialize the document (trailing newline, the repo's state-file shape). */
74
+ export function serializeStateDoc(doc) {
75
+ return `${JSON.stringify(doc, null, 2)}\n`;
76
+ }
77
+ /**
78
+ * Coerce a parsed value into a document, dropping anything unrecognizable
79
+ * rather than trusting the file: this is user-editable state.
80
+ *
81
+ * @param value - the parsed JSON.
82
+ * @returns a valid document.
83
+ */
84
+ export function normalizeStateDoc(value) {
85
+ const base = emptyStateDoc();
86
+ if (typeof value !== 'object' || value === null)
87
+ return base;
88
+ const record = value;
89
+ const sources = Array.isArray(record.sources) ? record.sources : [];
90
+ const refresh = typeof record.refresh === 'object' && record.refresh !== null
91
+ ? record.refresh
92
+ : {};
93
+ return {
94
+ version: 1,
95
+ sources: sources.flatMap(item => {
96
+ const source = normalizeSource(item);
97
+ return source === undefined ? [] : [source];
98
+ }),
99
+ refresh: {
100
+ enabled: refresh.enabled !== false,
101
+ timeOfDay: typeof refresh.timeOfDay === 'string' && /^\d{1,2}:\d{2}$/.test(refresh.timeOfDay)
102
+ ? refresh.timeOfDay
103
+ : base.refresh.timeOfDay,
104
+ },
105
+ ...(typeof record.lastRefreshAt === 'string' ? { lastRefreshAt: record.lastRefreshAt } : {}),
106
+ ...(() => {
107
+ const cache = normalizeCachePolicy(record.cache);
108
+ return cache === undefined ? {} : { cache };
109
+ })(),
110
+ ...(() => {
111
+ const tags = normalizeTags(record.tags);
112
+ return Object.keys(tags).length > 0 ? { tags } : {};
113
+ })(),
114
+ ...(() => {
115
+ const annotations = normalizeAnnotations(record.annotations);
116
+ return Object.keys(annotations).length > 0 ? { annotations } : {};
117
+ })(),
118
+ ...(() => {
119
+ const translationMemory = normalizeTranslationMemory(record.translationMemory);
120
+ return translationMemory === undefined ? {} : { translationMemory };
121
+ })(),
122
+ ...(() => {
123
+ const recent = normalizeRecent(record.recent);
124
+ return recent.length > 0 ? { recent } : {};
125
+ })(),
126
+ };
127
+ }
128
+ /**
129
+ * How many opened entries the recent list keeps.
130
+ *
131
+ * A bounded list is what makes the page useful: the question it answers is
132
+ * "what was I just reading", and a year of history answers it worse than the
133
+ * last hundred items. The cap is also what keeps `state.json` small, since this
134
+ * is the one table that grows with READING rather than with sources.
135
+ */
136
+ export const MAX_RECENT_ENTRIES = 100;
137
+ /**
138
+ * The recent list, dropping anything without the two ids and a timestamp.
139
+ *
140
+ * The order is the file's, which is newest-first by construction (the service
141
+ * unshifts); a hand-edited file that disagrees still renders in its own order,
142
+ * which is the honest reading of what is on disk.
143
+ */
144
+ function normalizeRecent(value) {
145
+ if (!Array.isArray(value))
146
+ return [];
147
+ const out = [];
148
+ const seen = new Set();
149
+ for (const item of value) {
150
+ if (typeof item !== 'object' || item === null)
151
+ continue;
152
+ const record = item;
153
+ const entryId = typeof record.entryId === 'string' ? record.entryId.trim() : '';
154
+ const sourceId = typeof record.sourceId === 'string' ? record.sourceId.trim() : '';
155
+ const title = typeof record.title === 'string' ? record.title.trim() : '';
156
+ const readAt = typeof record.readAt === 'string' ? record.readAt : '';
157
+ // An entry with no title is still worth keeping — the reader may have
158
+ // opened something the feed never titled — but one with no IDs cannot be
159
+ // reopened, and one with no timestamp cannot be ordered.
160
+ if (entryId.length === 0 || sourceId.length === 0 || readAt.length === 0)
161
+ continue;
162
+ if (seen.has(entryId))
163
+ continue;
164
+ seen.add(entryId);
165
+ out.push({
166
+ entryId,
167
+ sourceId,
168
+ title: title.slice(0, 300),
169
+ ...(typeof record.url === 'string' && record.url.length > 0 ? { url: record.url } : {}),
170
+ readAt,
171
+ });
172
+ if (out.length >= MAX_RECENT_ENTRIES)
173
+ break;
174
+ }
175
+ return out;
176
+ }
177
+ /** The cache policy, coerced to something usable (absent = the default). */
178
+ function normalizeCachePolicy(value) {
179
+ if (typeof value !== 'object' || value === null)
180
+ return undefined;
181
+ const record = value;
182
+ const ttlHours = typeof record.ttlHours === 'number' && Number.isFinite(record.ttlHours) && record.ttlHours >= 0
183
+ ? Math.min(record.ttlHours, 24 * 90)
184
+ : DEFAULT_CACHE_POLICY.ttlHours;
185
+ const maxEntries = typeof record.maxEntries === 'number' && Number.isFinite(record.maxEntries) && record.maxEntries > 0
186
+ ? Math.min(Math.floor(record.maxEntries), 5_000)
187
+ : DEFAULT_CACHE_POLICY.maxEntries;
188
+ const translationBudgetChars = typeof record.translationBudgetChars === 'number'
189
+ && Number.isFinite(record.translationBudgetChars) && record.translationBudgetChars > 0
190
+ ? Math.min(Math.floor(record.translationBudgetChars), 1024 * 1024 * 1024)
191
+ : undefined;
192
+ return {
193
+ ttlHours,
194
+ maxEntries,
195
+ ...(translationBudgetChars === undefined ? {} : { translationBudgetChars }),
196
+ };
197
+ }
198
+ /** A `bodies/` file name is one path segment, or it is not a name we wrote. */
199
+ function normalizeFileName(value) {
200
+ return typeof value === 'string' && /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(value) ? value : undefined;
201
+ }
202
+ /** The global memory's manifest; kept as recorded (a version bump reads as a miss, lazily). */
203
+ function normalizeTranslationMemory(value) {
204
+ if (typeof value !== 'object' || value === null)
205
+ return undefined;
206
+ const record = value;
207
+ const file = normalizeFileName(record.file);
208
+ if (typeof record.version !== 'number' || file === undefined)
209
+ return undefined;
210
+ return {
211
+ version: record.version,
212
+ file,
213
+ entries: typeof record.entries === 'number' && Number.isFinite(record.entries) ? Math.max(0, Math.floor(record.entries)) : 0,
214
+ chars: typeof record.chars === 'number' && Number.isFinite(record.chars) ? Math.max(0, Math.floor(record.chars)) : 0,
215
+ updatedAt: typeof record.updatedAt === 'string' ? record.updatedAt : new Date(0).toISOString(),
216
+ };
217
+ }
218
+ /**
219
+ * One entry's translation segment map, when it carries the fields that make it
220
+ * one. The schema version is preserved as recorded: a mismatch is a MISS when
221
+ * the record is served, and the record is evicted by the next write or budget
222
+ * pass — never by the read path.
223
+ */
224
+ function normalizeTranslation(value) {
225
+ if (typeof value !== 'object' || value === null)
226
+ return undefined;
227
+ const record = value;
228
+ if (typeof record.version !== 'number')
229
+ return undefined;
230
+ const pair = typeof record.pair === 'string' ? record.pair : '';
231
+ const bodyHash = typeof record.bodyHash === 'string' ? record.bodyHash : '';
232
+ if (pair.length === 0 || bodyHash.length === 0)
233
+ return undefined;
234
+ const segments = typeof record.segments === 'object' && record.segments !== null
235
+ ? Object.fromEntries(Object.entries(record.segments)
236
+ .filter(([, target]) => typeof target === 'string'))
237
+ : undefined;
238
+ const file = normalizeFileName(record.file);
239
+ if (segments === undefined && file === undefined)
240
+ return undefined;
241
+ return {
242
+ version: record.version,
243
+ pair,
244
+ bodyHash,
245
+ ...(segments !== undefined && Object.keys(segments).length > 0 ? { segments } : {}),
246
+ ...(file === undefined ? {} : { file }),
247
+ ...(typeof record.chars === 'number' && Number.isFinite(record.chars) ? { chars: record.chars } : {}),
248
+ translatedAt: typeof record.translatedAt === 'string' ? record.translatedAt : new Date(0).toISOString(),
249
+ lastUsedAt: typeof record.lastUsedAt === 'string' ? record.lastUsedAt : new Date(0).toISOString(),
250
+ };
251
+ }
252
+ /** The tag vocabulary, dropping anything without an id and a name. */
253
+ function normalizeTags(value) {
254
+ if (typeof value !== 'object' || value === null)
255
+ return {};
256
+ const out = {};
257
+ for (const [id, entry] of Object.entries(value)) {
258
+ if (typeof entry !== 'object' || entry === null)
259
+ continue;
260
+ const record = entry;
261
+ const name = typeof record.name === 'string' ? record.name.trim() : '';
262
+ if (id.length === 0 || name.length === 0)
263
+ continue;
264
+ out[id] = {
265
+ id,
266
+ name: name.slice(0, 40),
267
+ createdAt: typeof record.createdAt === 'string' ? record.createdAt : new Date(0).toISOString(),
268
+ };
269
+ if (Object.keys(out).length >= MAX_TAGS)
270
+ break;
271
+ }
272
+ return out;
273
+ }
274
+ /** The per-entry annotations, dropping entries that carry nothing. */
275
+ function normalizeAnnotations(value) {
276
+ if (typeof value !== 'object' || value === null)
277
+ return {};
278
+ const out = {};
279
+ for (const [entryId, entry] of Object.entries(value)) {
280
+ if (entryId.length === 0 || typeof entry !== 'object' || entry === null)
281
+ continue;
282
+ const record = entry;
283
+ const body = normalizeBody(record.body);
284
+ const tagIds = Array.isArray(record.tagIds)
285
+ ? [...new Set(record.tagIds.filter((id) => typeof id === 'string' && id.length > 0))]
286
+ : [];
287
+ const fetch = normalizeFetch(record.fetch);
288
+ const translation = normalizeTranslation(record.translation);
289
+ const failureCode = PREVIEW_FAILURE_CODES.has(String(record.failureCode))
290
+ ? record.failureCode
291
+ : undefined;
292
+ const annotation = {
293
+ ...(body === undefined ? {} : { body }),
294
+ ...(fetch === undefined ? {} : { fetch }),
295
+ ...(translation === undefined ? {} : { translation }),
296
+ ...(tagIds.length > 0 ? { tagIds } : {}),
297
+ ...(typeof record.error === 'string' ? { error: record.error.slice(0, 500) } : {}),
298
+ ...(failureCode === undefined ? {} : { failureCode }),
299
+ ...(typeof record.failedAt === 'string' ? { failedAt: record.failedAt } : {}),
300
+ // The captured card meta: trimmed and capped on read, empty reads absent.
301
+ ...(typeof record.title === 'string' && record.title.trim().length > 0
302
+ ? { title: record.title.trim().slice(0, 300) }
303
+ : {}),
304
+ ...(typeof record.excerpt === 'string' && record.excerpt.trim().length > 0
305
+ ? { excerpt: record.excerpt.trim().slice(0, 1000) }
306
+ : {}),
307
+ };
308
+ // A `fetch` record alone is a real annotation: it is what says "the payload
309
+ // is on disk waiting to be extracted". Dropping it here lost every stored
310
+ // payload on the next read. A `translation` is the same kind of load-bearing:
311
+ // it is the whole point of the translation store. And a captured
312
+ // title/excerpt is the card's memory of what the paper was — it must outlive
313
+ // the body it was captured from.
314
+ if (annotation.body === undefined && annotation.fetch === undefined && annotation.tagIds === undefined
315
+ && annotation.error === undefined && annotation.translation === undefined
316
+ && annotation.title === undefined && annotation.excerpt === undefined)
317
+ continue;
318
+ out[entryId] = annotation;
319
+ }
320
+ return out;
321
+ }
322
+ /**
323
+ * One in-flight or stored-raw record, when it carries the fields that make it one.
324
+ *
325
+ * @param value - the persisted field.
326
+ * @returns the normalized record, or `undefined`.
327
+ */
328
+ function normalizeFetch(value) {
329
+ if (typeof value !== 'object' || value === null)
330
+ return undefined;
331
+ const record = value;
332
+ const state = record.state === 'fetching' || record.state === 'raw' ? record.state : undefined;
333
+ if (state === undefined)
334
+ return undefined;
335
+ const rawFile = typeof record.rawFile === 'string' && record.rawFile.length > 0 ? record.rawFile : undefined;
336
+ return {
337
+ state,
338
+ at: typeof record.at === 'string' ? record.at : new Date(0).toISOString(),
339
+ ...(rawFile === undefined ? {} : { rawFile }),
340
+ ...(typeof record.chars === 'number' && Number.isFinite(record.chars) ? { chars: record.chars } : {}),
341
+ ...(typeof record.url === 'string' ? { url: record.url } : {}),
342
+ ...(record.truncated === true ? { truncated: true } : {}),
343
+ };
344
+ }
345
+ /** One cached body, when it carries the fields that make it one. */
346
+ function normalizeBody(value) {
347
+ if (typeof value !== 'object' || value === null)
348
+ return undefined;
349
+ const record = value;
350
+ const inline = typeof record.html === 'string' && record.html.length > 0;
351
+ // A large body lives in `bodies/` and the document keeps its file name: a body
352
+ // with neither the markup nor a file is not a body.
353
+ const file = typeof record.file === 'string' && record.file.length > 0 ? record.file : undefined;
354
+ if (!inline && file === undefined)
355
+ return undefined;
356
+ return {
357
+ ...(inline ? { html: record.html } : {}),
358
+ ...(file === undefined ? {} : { file }),
359
+ ...(typeof record.chars === 'number' && Number.isFinite(record.chars) ? { chars: record.chars } : {}),
360
+ fetchedAt: typeof record.fetchedAt === 'string' ? record.fetchedAt : new Date(0).toISOString(),
361
+ expiresAt: typeof record.expiresAt === 'string' ? record.expiresAt : new Date(0).toISOString(),
362
+ url: typeof record.url === 'string' ? record.url : '',
363
+ ...(record.truncated === true ? { truncated: true } : {}),
364
+ // Without this the script-figure note survived exactly one read: the body is
365
+ // normalized on EVERY load, so a field the normalizer forgets is a field the
366
+ // document silently loses.
367
+ ...(typeof record.scriptFigures === 'number' && Number.isFinite(record.scriptFigures)
368
+ ? { scriptFigures: record.scriptFigures }
369
+ : {}),
370
+ ...(record.rendered === true ? { rendered: true } : {}),
371
+ };
372
+ }
373
+ /** One source, or `undefined` when it lacks the fields that make it one. */
374
+ function normalizeSource(value) {
375
+ if (typeof value !== 'object' || value === null)
376
+ return undefined;
377
+ const record = value;
378
+ const id = typeof record.id === 'string' ? record.id : undefined;
379
+ const url = typeof record.url === 'string' ? record.url : undefined;
380
+ if (id === undefined || url === undefined)
381
+ return undefined;
382
+ const kind = record.kind === 'link' ? 'link' : 'rss';
383
+ const status = record.status === 'ok' || record.status === 'fetching' || record.status === 'error'
384
+ ? record.status
385
+ : undefined;
386
+ const failure = normalizePreviewFailure(record.failure);
387
+ return {
388
+ id,
389
+ kind,
390
+ url,
391
+ label: typeof record.label === 'string' && record.label.length > 0 ? record.label : url,
392
+ enabled: record.enabled !== false,
393
+ addedAt: typeof record.addedAt === 'string' ? record.addedAt : new Date(0).toISOString(),
394
+ ...(typeof record.fetchedAt === 'string' ? { fetchedAt: record.fetchedAt } : {}),
395
+ ...(status !== undefined ? { status } : {}),
396
+ ...(typeof record.error === 'string' ? { error: record.error } : {}),
397
+ ...(record.truncated === true ? { truncated: true } : {}),
398
+ ...(failure === undefined ? {} : { failure }),
399
+ ...(typeof record.resolvedFrom === 'string' ? { resolvedFrom: record.resolvedFrom } : {}),
400
+ ...(typeof record.raw === 'string' ? { raw: record.raw } : {}),
401
+ ...(typeof record.timeOfDay === 'string' ? { timeOfDay: record.timeOfDay } : {}),
402
+ };
403
+ }
404
+ /**
405
+ * One recorded preview failure, or `undefined` when the record is not one.
406
+ *
407
+ * An unknown code is dropped rather than trusted: the union is what the
408
+ * browser switches on, and a code this build does not know would render as a
409
+ * missing sentence instead of a diagnosis.
410
+ *
411
+ * @param value - the persisted field.
412
+ * @returns the normalized failure, or `undefined`.
413
+ */
414
+ function normalizePreviewFailure(value) {
415
+ if (typeof value !== 'object' || value === null)
416
+ return undefined;
417
+ const record = value;
418
+ const code = record.code;
419
+ if (typeof code !== 'string' || !PREVIEW_FAILURE_CODES.has(code))
420
+ return undefined;
421
+ return {
422
+ code: code,
423
+ message: typeof record.message === 'string' ? record.message.slice(0, 500) : '',
424
+ at: typeof record.at === 'string' ? record.at : new Date(0).toISOString(),
425
+ };
426
+ }
427
+ /**
428
+ * Bound the document's payload bytes without dropping a subscription.
429
+ *
430
+ * Newest-first: the freshest payload is the one the reader is most likely to
431
+ * open, so it survives and the oldest bodies are the ones released. Keeping
432
+ * every source row regardless is deliberate — a source whose body was evicted
433
+ * still refreshes, and dropping subscriptions to save space would be a silent
434
+ * data loss the reader never asked for.
435
+ *
436
+ * @param doc - the document to bound.
437
+ * @returns the bounded document, and whether anything was released.
438
+ */
439
+ export function boundPayloads(doc) {
440
+ let changed = false;
441
+ // Walk NEWEST first and keep what fits: the reader's most recent material is
442
+ // what it is about to read, so the budget must evict the other end. Walking
443
+ // oldest-first and stopping at the budget would do the opposite (it keeps the
444
+ // stale payloads and drops what just arrived) — the order here is the point.
445
+ const byNewest = [...doc.sources].sort((a, b) => (b.fetchedAt ?? '').localeCompare(a.fetchedAt ?? ''));
446
+ const trimmed = new Map();
447
+ let total = 0;
448
+ for (const source of byNewest) {
449
+ const raw = source.raw;
450
+ if (raw === undefined)
451
+ continue;
452
+ if (raw.length > MAX_BODY_CHARS_PER_SOURCE) {
453
+ // Keep as much as the per-source budget allows and say it is partial:
454
+ // dropping the whole body would lose an article the reader just added,
455
+ // and the detail view already knows how to render a truncated body.
456
+ trimmed.set(source.id, { ...source, raw: raw.slice(0, MAX_BODY_CHARS_PER_SOURCE), truncated: true });
457
+ total += MAX_BODY_CHARS_PER_SOURCE;
458
+ changed = true;
459
+ continue;
460
+ }
461
+ if (total + raw.length > MAX_TOTAL_BODY_CHARS) {
462
+ // Over budget: this payload (and every older one) loses its body. The
463
+ // source itself stays — dropping subscriptions to save space would be a
464
+ // silent data loss the reader never asked for.
465
+ const { raw: _dropped, ...withoutRaw } = source;
466
+ trimmed.set(source.id, withoutRaw);
467
+ changed = true;
468
+ continue;
469
+ }
470
+ total += raw.length;
471
+ trimmed.set(source.id, source);
472
+ }
473
+ if (!changed)
474
+ return { doc, changed };
475
+ return {
476
+ doc: { ...doc, sources: doc.sources.map(s => trimmed.get(s.id) ?? s) },
477
+ changed,
478
+ };
479
+ }
480
+ /** Sub-directory holding bodies too large to inline in the document. */
481
+ export const READER_BODIES_DIR = 'bodies';
482
+ /**
483
+ * The global translation memory's file name inside `bodies/`.
484
+ *
485
+ * One fixed name rather than a per-key hash: the table is a single artifact,
486
+ * rewritten whole by the (already debounced) write path, and referenced from
487
+ * the document's `translationMemory` manifest so `pruneBodies` keeps it.
488
+ */
489
+ export const TRANSLATION_MEMORY_FILE = 'translation-memory.json';
490
+ /** Native-fs state store: the deployment's document, not the session's. */
491
+ export class ReaderStore {
492
+ stateRoot;
493
+ file;
494
+ bodiesDir;
495
+ /** Set once the root has proven unwritable; the service then goes memory-only. */
496
+ unwritable;
497
+ /**
498
+ * @param config - optional state-root override.
499
+ */
500
+ constructor(config = {}) {
501
+ this.stateRoot = resolveReaderStateRoot(config.stateRoot);
502
+ this.file = join(this.stateRoot, READER_STATE_FILE_NAME);
503
+ this.bodiesDir = join(this.stateRoot, READER_BODIES_DIR);
504
+ }
505
+ /**
506
+ * Write one body to its own file, atomically, and return the file's name.
507
+ *
508
+ * The name is a digest of the entry id: entry ids are URLs, and a document
509
+ * carrying an absolute path (or a path derived from remote text) is a
510
+ * directory-traversal waiting to happen.
511
+ *
512
+ * @param entryId - the entry the body belongs to.
513
+ * @param html - the normalized body markup.
514
+ * @returns the file name to store in the document.
515
+ */
516
+ writeBody(entryId, html) {
517
+ const name = `${bodyFileName(entryId)}.html`;
518
+ mkdirSync(this.bodiesDir, { recursive: true });
519
+ const target = join(this.bodiesDir, name);
520
+ const temporary = `${target}.${process.pid}.tmp`;
521
+ try {
522
+ writeFileSync(temporary, html);
523
+ renameSync(temporary, target);
524
+ }
525
+ catch (error) {
526
+ rmSync(temporary, { force: true });
527
+ throw new ReaderStoreError(`reader: ${target} could not be written — ${errorMessage(error)}`, 'io');
528
+ }
529
+ return name;
530
+ }
531
+ /**
532
+ * Write one NAMED artifact into `bodies/`, atomically.
533
+ *
534
+ * Unlike {@link writeBody} the name is the caller's, not a hash of an entry
535
+ * id: the global translation memory is one fixed file, not one per entry. The
536
+ * name is validated to a bare file name — this directory must never grow a
537
+ * path.
538
+ *
539
+ * @param name - the bare file name to write.
540
+ * @param content - the file's content.
541
+ * @returns the name written.
542
+ */
543
+ writeNamedBody(name, content) {
544
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(name)) {
545
+ throw new ReaderStoreError(`reader: refusing to write ${name} — not a bare file name`, 'io');
546
+ }
547
+ mkdirSync(this.bodiesDir, { recursive: true });
548
+ const target = join(this.bodiesDir, name);
549
+ const temporary = `${target}.${process.pid}.tmp`;
550
+ try {
551
+ writeFileSync(temporary, content);
552
+ renameSync(temporary, target);
553
+ }
554
+ catch (error) {
555
+ rmSync(temporary, { force: true });
556
+ throw new ReaderStoreError(`reader: ${target} could not be written — ${errorMessage(error)}`, 'io');
557
+ }
558
+ return name;
559
+ }
560
+ /**
561
+ * Read one body file.
562
+ *
563
+ * @param name - the file name recorded in the document.
564
+ * @returns the markup, or `undefined` when the file is gone or unreadable.
565
+ */
566
+ readBody(name) {
567
+ try {
568
+ return readFileSync(join(this.bodiesDir, name), 'utf8');
569
+ }
570
+ catch {
571
+ // A body file the reader deleted by hand is not an error: the entry simply
572
+ // has no cache, and the next open fetches it again.
573
+ return undefined;
574
+ }
575
+ }
576
+ /**
577
+ * Delete body files the document no longer references.
578
+ *
579
+ * Eviction (`maxEntries`), tag edits that drop an annotation, and a hand-edited
580
+ * document all release bodies by REMOVING them from the JSON — which would
581
+ * otherwise leave their files on disk forever. Best-effort by design: a failed
582
+ * sweep must never fail the commit that triggered it.
583
+ *
584
+ * @param doc - the document that was just written.
585
+ * @returns how many files were removed.
586
+ */
587
+ pruneBodies(doc) {
588
+ const referenced = new Set();
589
+ for (const annotation of Object.values(doc.annotations ?? {})) {
590
+ if (annotation.body?.file !== undefined)
591
+ referenced.add(annotation.body.file);
592
+ // A fetched-but-not-yet-extracted payload is referenced too: deleting it
593
+ // would throw away a download the reader waited for.
594
+ if (annotation.fetch?.rawFile !== undefined)
595
+ referenced.add(annotation.fetch.rawFile);
596
+ // A large translation segment map is a sidecar file like any body.
597
+ if (annotation.translation?.file !== undefined)
598
+ referenced.add(annotation.translation.file);
599
+ }
600
+ // The global translation memory's table is one named file in the same dir.
601
+ if (doc.translationMemory !== undefined)
602
+ referenced.add(doc.translationMemory.file);
603
+ let names;
604
+ try {
605
+ names = readdirSync(this.bodiesDir);
606
+ }
607
+ catch {
608
+ return 0;
609
+ }
610
+ let removed = 0;
611
+ for (const name of names) {
612
+ if (referenced.has(name))
613
+ continue;
614
+ try {
615
+ // Anything unreferenced goes, including a half-written `.tmp` from a
616
+ // crashed process.
617
+ rmSync(join(this.bodiesDir, name), { force: true });
618
+ removed += 1;
619
+ }
620
+ catch {
621
+ // Leave it; the next commit sweeps again.
622
+ }
623
+ }
624
+ return removed;
625
+ }
626
+ /** The absolute path of the state file (diagnostics and tests). */
627
+ get path() {
628
+ return this.file;
629
+ }
630
+ /** Why persistence is unavailable, when it is. */
631
+ get unavailableReason() {
632
+ return this.unwritable;
633
+ }
634
+ /**
635
+ * Whether persistence is available.
636
+ *
637
+ * Probed by creating the state root, not by asking a capability: a
638
+ * read-only deployment (a container with no writable home) must degrade to
639
+ * memory-only, and that is only knowable by trying.
640
+ */
641
+ get available() {
642
+ if (this.unwritable !== undefined)
643
+ return false;
644
+ try {
645
+ mkdirSync(this.stateRoot, { recursive: true });
646
+ return true;
647
+ }
648
+ catch (error) {
649
+ this.unwritable = `${this.stateRoot}: ${errorMessage(error)}`;
650
+ return false;
651
+ }
652
+ }
653
+ /**
654
+ * Read the document tolerantly.
655
+ *
656
+ * @returns the document and its freshness token; a missing file reads as empty.
657
+ * @throws ReaderStoreError when the file exists but does not parse.
658
+ */
659
+ async read() {
660
+ if (!this.available)
661
+ return { doc: emptyStateDoc(), version: null };
662
+ let text;
663
+ try {
664
+ text = readFileSync(this.file, 'utf8');
665
+ }
666
+ catch (error) {
667
+ if (isMissing(error))
668
+ return { doc: emptyStateDoc(), version: null };
669
+ throw new ReaderStoreError(`reader: ${this.file} could not be read — ${errorMessage(error)}`, 'io');
670
+ }
671
+ try {
672
+ return { doc: normalizeStateDoc(JSON.parse(text)), version: String(text.length) + ':' + String(statSync(this.file).mtimeMs) };
673
+ }
674
+ catch (error) {
675
+ if (error instanceof ReaderStoreError)
676
+ throw error;
677
+ // Refuse rather than clobber: the file is the user's subscriptions.
678
+ throw new ReaderStoreError(`reader: ${this.file} does not parse — fix or remove it by hand`, 'corrupt');
679
+ }
680
+ }
681
+ /**
682
+ * Write the document atomically under the token from the last read.
683
+ *
684
+ * @param doc - the document to publish.
685
+ * @param version - the token from the last read (`null` for the first write).
686
+ * @returns the new freshness token.
687
+ */
688
+ async write(doc, version) {
689
+ if (!this.available)
690
+ throw new ReaderStoreError('reader: no writable state root', 'io');
691
+ // A concurrent external edit between our read and write: refuse instead of
692
+ // publishing over a document we never saw. Only this process writes, so a
693
+ // mismatch means a hand edit, and the next read will surface it loudly.
694
+ if (version !== null) {
695
+ const current = await this.read();
696
+ if (current.version !== version) {
697
+ throw new ReaderStoreError(`reader: ${this.file} changed underneath this write`, 'io');
698
+ }
699
+ }
700
+ const temporary = `${this.file}.${process.pid}.tmp`;
701
+ try {
702
+ writeFileSync(temporary, serializeStateDoc(doc));
703
+ renameSync(temporary, this.file);
704
+ }
705
+ catch (error) {
706
+ rmSync(temporary, { force: true });
707
+ throw new ReaderStoreError(`reader: ${this.file} could not be written — ${errorMessage(error)}`, 'io');
708
+ }
709
+ const text = serializeStateDoc(doc);
710
+ return String(text.length) + ':' + String(statSync(this.file).mtimeMs);
711
+ }
712
+ /**
713
+ * Read-modify-write with the one concurrent-edit retry.
714
+ *
715
+ * @param mutate - pure function from the current document to the next one.
716
+ * @returns the document that ended up on disk.
717
+ */
718
+ async update(mutate) {
719
+ for (let attempt = 0; attempt < 2; attempt++) {
720
+ const { doc, version } = await this.read();
721
+ const next = boundAnnotations(boundPayloads(mutate(doc)).doc).doc;
722
+ try {
723
+ await this.write(next, version);
724
+ // Bodies the document no longer names are files nobody can reach: the
725
+ // sweep runs here because this is the only place the new document is
726
+ // known to be on disk.
727
+ this.pruneBodies(next);
728
+ return next;
729
+ }
730
+ catch (error) {
731
+ // An external edit moved the document between our read and write.
732
+ // One retry is the contract; a second failure is a real io problem.
733
+ if (attempt === 1 || !(error instanceof ReaderStoreError) || error.kind !== 'io')
734
+ throw error;
735
+ }
736
+ }
737
+ /* v8 ignore next -- the loop either returns or throws */
738
+ throw new ReaderStoreError('reader: could not commit after a concurrent-edit retry', 'io');
739
+ }
740
+ }
741
+ /** Whether a native fs failure is "there is no file yet". */
742
+ /** The bare file name one entry's body lives under. */
743
+ function bodyFileName(entryId) {
744
+ return createHash('sha1').update(entryId).digest('hex').slice(0, 32);
745
+ }
746
+ function isMissing(error) {
747
+ return error?.code === 'ENOENT';
748
+ }
749
+ /** A native fs failure's message, with its code, for the wire. */
750
+ function errorMessage(error) {
751
+ const code = error?.code;
752
+ const message = error instanceof Error ? error.message : String(error);
753
+ return code === undefined ? message : `${code}: ${message}`;
754
+ }
755
+ /**
756
+ * Bound the cached article bodies without touching what the reader authored.
757
+ *
758
+ * Newest-first: the article just fetched is the one being read, so the budget
759
+ * releases the older copies. Tags are NOT part of this budget — a tag is a
760
+ * sentence the reader wrote, and losing it to make room for a cached page would
761
+ * be the worst trade in this document.
762
+ *
763
+ * @param doc - the document to bound.
764
+ * @returns the bounded document, and whether anything was released.
765
+ */
766
+ export function boundAnnotations(doc) {
767
+ const annotations = doc.annotations;
768
+ if (annotations === undefined)
769
+ return { doc, changed: false };
770
+ const withBody = Object.entries(annotations).filter(([, entry]) => entry.body !== undefined);
771
+ const limit = Math.max(1, doc.cache?.maxEntries ?? MAX_CACHED_BODIES);
772
+ if (withBody.length <= limit)
773
+ return { doc, changed: false };
774
+ const keep = new Set([...withBody]
775
+ .sort((a, b) => (b[1].body?.fetchedAt ?? '').localeCompare(a[1].body?.fetchedAt ?? ''))
776
+ .slice(0, limit)
777
+ .map(([entryId]) => entryId));
778
+ const next = {};
779
+ for (const [entryId, entry] of Object.entries(annotations)) {
780
+ if (entry.body === undefined || keep.has(entryId)) {
781
+ next[entryId] = entry;
782
+ continue;
783
+ }
784
+ // The body is gone, so its exact-fit translation map goes in the same pass:
785
+ // the map is keyed to that body's hash and answers nothing without it
786
+ // (unchanged sentences still hit the GLOBAL memory — the linkage is about
787
+ // the entry tier only). Tags, the failure record and the captured card meta
788
+ // survive, as before.
789
+ const { body: _released, translation: _linked, ...rest } = entry;
790
+ if (rest.tagIds === undefined && rest.error === undefined && rest.fetch === undefined
791
+ && rest.title === undefined && rest.excerpt === undefined)
792
+ continue;
793
+ next[entryId] = rest;
794
+ }
795
+ return { doc: { ...doc, annotations: next }, changed: true };
796
+ }
797
+ /**
798
+ * Drop tags nothing references any more.
799
+ *
800
+ * @param doc - the document to prune.
801
+ * @returns the pruned document, and how many tags went.
802
+ */
803
+ export function pruneOrphanTags(doc) {
804
+ const annotations = doc.annotations ?? {};
805
+ const used = new Set(Object.values(annotations).flatMap(entry => entry.tagIds ?? []));
806
+ const tags = doc.tags ?? {};
807
+ const kept = {};
808
+ let removed = 0;
809
+ for (const [id, tag] of Object.entries(tags)) {
810
+ if (used.has(id))
811
+ kept[id] = tag;
812
+ else
813
+ removed += 1;
814
+ }
815
+ if (removed === 0)
816
+ return { doc, removed };
817
+ return { doc: { ...doc, tags: kept }, removed };
818
+ }
819
+ /** One memory entry's approximate size in characters (key + both texts + stamp). */
820
+ function memoryEntryChars(key, entry) {
821
+ return key.length + entry.source.length + entry.target.length + 24;
822
+ }
823
+ /** One entry translation's size in characters, as recorded or estimated. */
824
+ function entryTranslationChars(translation) {
825
+ if (translation.chars !== undefined)
826
+ return translation.chars;
827
+ return Object.entries(translation.segments ?? {}).reduce((sum, [hash, target]) => sum + hash.length + target.length + 2, 0);
828
+ }
829
+ /**
830
+ * Bound the two translation tiers to their shared budget, LRU by last use.
831
+ *
832
+ * The eviction list is ONE ordering across the global memory and every entry's
833
+ * segment map: a sentence remembered and a per-entry map are the same kind of
834
+ * thing (a translation that was paid for), so the oldest lastUsedAt goes first
835
+ * regardless of which tier holds it. Two extra rules ride the same walk,
836
+ * because it is the only place both tiers are walked: schema-version mismatches
837
+ * are evicted on sight (the lazy half of "version bump = miss"), and the memory
838
+ * table answers to its own count cap even when the budget has room.
839
+ *
840
+ * Pure over plain records: the service wires the files, this decides.
841
+ *
842
+ * @param memory - the global sentence memory (table content).
843
+ * @param annotations - the document's annotations (entry translations included).
844
+ * @param budgetChars - the shared budget.
845
+ * @returns the bounded records, plus WHICH keys each tier lost (the caller
846
+ * applies the entry evictions to the freshest document inside its commit).
847
+ */
848
+ export function boundTranslations(memory, annotations, budgetChars) {
849
+ let nextMemory = memory;
850
+ const evictedMemoryKeys = [];
851
+ // The count cap is the memory's own: 50k sentences is already several MB.
852
+ const keysByAge = Object.keys(memory).sort((a, b) => memory[a].lastUsedAt.localeCompare(memory[b].lastUsedAt));
853
+ if (keysByAge.length > MAX_TRANSLATION_MEMORY_ENTRIES) {
854
+ nextMemory = { ...memory };
855
+ for (const key of keysByAge.slice(0, keysByAge.length - MAX_TRANSLATION_MEMORY_ENTRIES)) {
856
+ delete nextMemory[key];
857
+ evictedMemoryKeys.push(key);
858
+ }
859
+ }
860
+ const ladder = [];
861
+ let total = 0;
862
+ for (const [key, entry] of Object.entries(nextMemory)) {
863
+ const chars = memoryEntryChars(key, entry);
864
+ total += chars;
865
+ ladder.push({ at: entry.lastUsedAt, chars, kind: 'memory', key, stale: false });
866
+ }
867
+ for (const [entryId, annotation] of Object.entries(annotations)) {
868
+ const translation = annotation.translation;
869
+ if (translation === undefined)
870
+ continue;
871
+ const stale = translation.version !== TRANSLATION_STORAGE_VERSION;
872
+ const chars = entryTranslationChars(translation);
873
+ if (!stale)
874
+ total += chars;
875
+ ladder.push({ at: stale ? new Date(0).toISOString() : translation.lastUsedAt, chars, kind: 'entry', key: entryId, stale });
876
+ }
877
+ ladder.sort((a, b) => a.at.localeCompare(b.at));
878
+ const evictedEntryIds = [];
879
+ let nextAnnotations;
880
+ for (const item of ladder) {
881
+ // Stale records are evicted on sight; fresh ones only while over budget.
882
+ if (!item.stale && total <= budgetChars)
883
+ break;
884
+ if (item.kind === 'memory') {
885
+ if (nextMemory === memory)
886
+ nextMemory = { ...memory };
887
+ if (nextMemory[item.key] !== undefined) {
888
+ delete nextMemory[item.key];
889
+ total -= item.chars;
890
+ evictedMemoryKeys.push(item.key);
891
+ }
892
+ }
893
+ else {
894
+ const annotation = (nextAnnotations ?? annotations)[item.key];
895
+ if (annotation?.translation !== undefined) {
896
+ if (nextAnnotations === undefined)
897
+ nextAnnotations = { ...annotations };
898
+ const { translation: _evicted, ...rest } = annotation;
899
+ nextAnnotations[item.key] = rest;
900
+ if (!item.stale)
901
+ total -= item.chars;
902
+ evictedEntryIds.push(item.key);
903
+ }
904
+ }
905
+ }
906
+ return {
907
+ memory: nextMemory,
908
+ annotations: nextAnnotations ?? annotations,
909
+ evictedMemoryKeys,
910
+ evictedEntryIds,
911
+ };
912
+ }
913
+ /** The table's approximate size in characters, for the document's manifest. */
914
+ export function memoryTableChars(entries) {
915
+ return Object.entries(entries).reduce((sum, [key, entry]) => sum + memoryEntryChars(key, entry), 0);
916
+ }