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

roster.ts

Language TypeScript5.4 KB158 lines

packages/ninety-client/src/roster.ts

  1. /**
  2. * Roster: user id -> name.
  3. *
  4. * `GET /users` returns every active user in the company, and
  5. * `GET /users/team/{teamId}` returns team membership that `GET /teams` does not
  6. * carry. Harvesting from payloads is still the cheapest source, and it is what
  7. * runs. Every to-do carries `createdBy` next to
  8. * `createdByUserId`, and every company to-do carries `ownerFirstName` /
  9. * `ownerLastName` inline, which resolves the whole roster on that path for zero
  10. * extra requests. `note()` is how those get recorded.
  11. *
  12. * Sources, cheapest first:
  13. * 1. SEED - pinned roster.json. Never expires, never overwritten. Keeps
  14. * someone with zero to-dos visible instead of silently absent:
  15. * the company sweep only returns people with open work.
  16. * 2. NOTED - free pairs off to-do payloads. No request at all.
  17. * 3. FETCH - GET /users/{id} for whoever is still unknown.
  18. *
  19. * Merge, never replace. A refresh that drops people is worse than a stale name.
  20. */
  21. import type { HttpClient } from './http.js'
  22. import { NinetyError } from './errors.js'
  23. import { RawUserSchema, normalizeUserName, UNASSIGNED, UNKNOWN_PERSON, type Person } from './types.js'
  24. export interface RosterEntry {
  25. id: string
  26. name: string
  27. }
  28. export interface RosterOptions {
  29. /** Pinned entries from roster.json. Always win. */
  30. seed?: RosterEntry[]
  31. /** How long a fetched name stays fresh. Default 24h; people rarely rename. */
  32. ttlMs?: number
  33. now?: () => number
  34. }
  35. type Source = 'seed' | 'noted' | 'fetch'
  36. interface CacheEntry {
  37. name: string
  38. fetchedAt: number
  39. source: Source
  40. }
  41. export class Roster {
  42. private readonly cache = new Map<string, CacheEntry>()
  43. private readonly inflight = new Map<string, Promise<void>>()
  44. private readonly failed = new Set<string>()
  45. private readonly ttlMs: number
  46. private readonly now: () => number
  47. constructor(
  48. private readonly http: HttpClient,
  49. opts: RosterOptions = {},
  50. ) {
  51. this.ttlMs = opts.ttlMs ?? 24 * 60 * 60 * 1000
  52. this.now = opts.now ?? (() => Date.now())
  53. for (const e of opts.seed ?? []) {
  54. this.cache.set(e.id, { name: e.name, fetchedAt: this.now(), source: 'seed' })
  55. }
  56. }
  57. /**
  58. * Record a free id -> name pair seen in a payload (e.g. createdByUserId +
  59. * createdBy on a to-do). Costs nothing and removes the id from the fetch list.
  60. * Never overrides a pinned seed entry.
  61. */
  62. note(id: string, name: string): void {
  63. if (!id || !name) return
  64. if (this.cache.get(id)?.source === 'seed') return
  65. this.cache.set(id, { name, fetchedAt: this.now(), source: 'noted' })
  66. this.failed.delete(id)
  67. }
  68. /** Synchronous lookup. Never returns a raw id as a name. */
  69. lookup(id: string | null | undefined): Person {
  70. if (!id) return UNASSIGNED
  71. const hit = this.cache.get(id)
  72. return hit ? { id, name: hit.name } : UNKNOWN_PERSON(id)
  73. }
  74. /** Fuzzy name -> entries. */
  75. find(query: string): RosterEntry[] {
  76. const q = query.trim().toLowerCase()
  77. if (!q) return []
  78. return [...this.cache]
  79. .filter(([, e]) => e.name.toLowerCase().includes(q))
  80. .map(([id, e]) => ({ id, name: e.name }))
  81. .sort((a, b) => a.name.localeCompare(b.name))
  82. }
  83. /** Ensure every id is resolved. Deduped; the HTTP token bucket paces it. */
  84. async resolveMany(ids: Iterable<string>): Promise<void> {
  85. const wanted = [...new Set([...ids].filter((id) => id && this.needsFetch(id)))]
  86. await Promise.all(wanted.map((id) => this.resolveOne(id)))
  87. }
  88. private needsFetch(id: string): boolean {
  89. if (this.failed.has(id)) return false
  90. const hit = this.cache.get(id)
  91. if (!hit) return true
  92. // Seeded and noted names came from a trusted payload; do not spend a request
  93. // re-confirming them.
  94. if (hit.source !== 'fetch') return false
  95. return this.now() - hit.fetchedAt > this.ttlMs
  96. }
  97. private resolveOne(id: string): Promise<void> {
  98. const existing = this.inflight.get(id)
  99. if (existing) return existing
  100. const p = (async () => {
  101. try {
  102. const raw = await this.http.get(`/users/${encodeURIComponent(id)}`)
  103. const parsed = RawUserSchema.safeParse(raw)
  104. const name = parsed.success ? normalizeUserName(parsed.data) : null
  105. if (name) {
  106. if (this.cache.get(id)?.source !== 'seed') {
  107. this.cache.set(id, { name, fetchedAt: this.now(), source: 'fetch' })
  108. }
  109. } else {
  110. this.failed.add(id)
  111. }
  112. } catch (err) {
  113. // A deactivated or invisible user must not fail the whole sync. Auth
  114. // failures are different: those mean nothing will work, so re-throw.
  115. if (err instanceof NinetyError && (err.status === 401 || err.status === 403)) throw err
  116. this.failed.add(id)
  117. } finally {
  118. this.inflight.delete(id)
  119. }
  120. })()
  121. this.inflight.set(id, p)
  122. return p
  123. }
  124. /** Ids that could not be resolved. Surface these; do not swallow them. */
  125. get unresolved(): string[] {
  126. return [...this.failed]
  127. }
  128. /** Where the names came from. Useful for a sync log line. */
  129. stats(): Record<Source, number> & { unresolved: number } {
  130. const out = { seed: 0, noted: 0, fetch: 0, unresolved: this.failed.size }
  131. for (const e of this.cache.values()) out[e.source]++
  132. return out
  133. }
  134. /** Everything known, for pinning back into roster.json. */
  135. toEntries(): RosterEntry[] {
  136. return [...this.cache]
  137. .map(([id, e]) => ({ id, name: e.name }))
  138. .sort((a, b) => a.name.localeCompare(b.name))
  139. }
  140. }