mattrguerra/meridian-ops-boardmainPrivate on GitHub, published here in full

snapshot-file.ts

Language TypeScript5.9 KB141 lines

apps/dashboard/src/snapshot-file.ts

  1. /**
  2. * Caching the last sync to `data/last-snapshot.json`. Node only.
  3. *
  4. * `pnpm morning` renders the page, writes the briefings from that render's
  5. * numbers, then renders again so the prose appears on the board. A second
  6. * render that re-synced would move `snap.syncedAt`, so the briefings would no
  7. * longer match the numbers beside them and every clean run would end by
  8. * declaring its own output stale. `--render-only` reads this file and
  9. * re-renders from the same sync, so the check passes because the
  10. * prose and the numbers genuinely do describe one moment, not because the check
  11. * was loosened. Loosening it would remove the only thing telling a reader whether
  12. * the two halves of the page agree.
  13. *
  14. * Not re-rendered from briefing-payload.json because That file is deliberately
  15. * distilled (roughly 4k tokens where the snapshot is 300k), and `render()` needs
  16. * the whole snapshot. Widening the payload to serve the renderer would throw away
  17. * the entire cost strategy.
  18. *
  19. * Node-only IO lives in a `*-file.ts` module because a static `node:fs` import
  20. * resolves even behind `import type` and breaks the Worker build (rule 7). See
  21. * also history-file.ts and briefings-file.ts.
  22. */
  23. import { readFile, writeFile, mkdir } from 'node:fs/promises'
  24. import { dirname } from 'node:path'
  25. import { fileURLToPath } from 'node:url'
  26. import type { Snapshot } from './collect.js'
  27. import { errorMessage } from '@meridian/shared'
  28. export const SNAPSHOT_FILE = new URL('../../../data/last-snapshot.json', import.meta.url)
  29. /**
  30. * Plain JSON throughout: no Date, Map or Set anywhere in Snapshot, and
  31. * `ninetySpread` is four numbers. If a future field breaks that,
  32. * `--render-only` is where it will show up.
  33. */
  34. export async function writeSnapshot(snap: Snapshot, file: URL = SNAPSHOT_FILE): Promise<void> {
  35. try {
  36. await mkdir(dirname(fileURLToPath(file)), { recursive: true })
  37. await writeFile(file, `${JSON.stringify(snap)}\n`, 'utf8')
  38. } catch {
  39. // Read-only checkout, CI, a full disk. `--render-only` stops working and
  40. // says so; a live build must not fail because a cache could not be written.
  41. }
  42. }
  43. /**
  44. * Missing and unusable are different answers, so this does not collapse them
  45. * into null. "No cache yet" means run a sync; "there is a cache and it is not a
  46. * snapshot" means something wrote a bad file, which is a problem of its own and
  47. * the caller should be able to say so instead of guessing.
  48. */
  49. export type SnapshotRead =
  50. | { ok: true; snap: Snapshot }
  51. | { ok: false; missing: boolean; reason: string }
  52. /**
  53. * The fields checked are the ones decisions are made from. `syncedAt` dates every
  54. * number on the page and is what the staleness check compares; the four sources
  55. * carry both the data and whether it can be trusted. Everything else is left
  56. * alone deliberately: being strict about fields nothing reads discards good
  57. * records (rule 4).
  58. *
  59. * Rendering half a snapshot would put up a page that looks complete and is not,
  60. * which is the one outcome this whole board exists to prevent. So a file failing
  61. * these checks is refused rather than rendered around.
  62. */
  63. export async function readSnapshot(file: URL = SNAPSHOT_FILE): Promise<SnapshotRead> {
  64. let raw: string
  65. try {
  66. raw = await readFile(file, 'utf8')
  67. } catch {
  68. return { ok: false, missing: true, reason: 'no file there yet' }
  69. }
  70. let parsed: unknown
  71. try {
  72. parsed = JSON.parse(raw)
  73. } catch (err) {
  74. return {
  75. ok: false,
  76. missing: false,
  77. reason: `it is not valid JSON (${errorMessage(err)})`,
  78. }
  79. }
  80. if (!parsed || typeof parsed !== 'object') {
  81. return { ok: false, missing: false, reason: `it holds ${typeof parsed}, not an object` }
  82. }
  83. const s = parsed as Snapshot
  84. if (typeof s.syncedAt !== 'string' || !s.syncedAt) {
  85. return { ok: false, missing: false, reason: 'it has no syncedAt, so nothing on it can be dated' }
  86. }
  87. if (s.mode !== 'live' && s.mode !== 'fixtures') {
  88. return { ok: false, missing: false, reason: `mode is ${JSON.stringify(s.mode)}, not 'live' or 'fixtures'` }
  89. }
  90. for (const key of ['leads', 'orderLines', 'tasks', 'ninety'] as const) {
  91. const src = s[key]
  92. if (!src || typeof src !== 'object' || typeof src.ok !== 'boolean' || !Array.isArray(src.data)) {
  93. return { ok: false, missing: false, reason: `the '${key}' source is missing or malformed` }
  94. }
  95. }
  96. // A cache without orderWindow is refused, not defaulted. Defaulting
  97. // `truncated` to false would have this file assert that a sweep it knows
  98. // nothing about was complete. The cache is disposable; re-syncing
  99. // costs one run.
  100. if (!s.orderWindow || typeof s.orderWindow.truncated !== 'boolean') {
  101. return {
  102. ok: false,
  103. missing: false,
  104. reason: 'it predates the orderWindow field, so whether its order counts are ' +
  105. 'complete or truncated cannot be known',
  106. }
  107. }
  108. // The same reasoning one field deeper. A cache without assignment coverage
  109. // cannot say
  110. // whether its per-person counts were complete or blind, and --render-only would
  111. // put them on the page as though it could.
  112. if (!s.orderWindow.assignmentFields || typeof s.orderWindow.assignmentFields.total !== 'number') {
  113. return {
  114. ok: false,
  115. missing: false,
  116. reason: 'it predates assignment field coverage, so whether its per-person counts are ' +
  117. 'complete or blind cannot be known',
  118. }
  119. }
  120. // Same reasoning again for the multi-key Fathom source. A cache without this
  121. // field cannot say how many
  122. // keys were asked or whether any of them was dark, and neither default is
  123. // safe: `[]` renders as "live, from no keys", and leaving it undefined throws
  124. // somewhere inside the view-model instead of here, where the reason is known.
  125. if (s.fathomConfigured && !Array.isArray(s.fathomSources)) {
  126. return {
  127. ok: false,
  128. missing: false,
  129. reason: 'it predates per-key Fathom status, so whether every meeting key was ' +
  130. 'read or one of them was dark cannot be known',
  131. }
  132. }
  133. return { ok: true, snap: s }
  134. }