@cat-factory/app 0.238.0 → 0.241.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 (37) hide show
  1. package/app/components/documents/DocumentImportModal.vue +2 -0
  2. package/app/components/documents/DocumentSyncState.logic.spec.ts +33 -0
  3. package/app/components/documents/DocumentSyncState.logic.ts +38 -0
  4. package/app/components/documents/DocumentSyncState.vue +181 -0
  5. package/app/components/documents/TaskContextDocs.vue +21 -10
  6. package/app/components/layout/AccountAuditLog.logic.spec.ts +107 -0
  7. package/app/components/layout/AccountAuditLog.logic.ts +101 -0
  8. package/app/components/layout/AccountAuditLog.vue +148 -0
  9. package/app/components/layout/AccountTeamSettings.vue +39 -0
  10. package/app/components/panels/MergerResultView.vue +1 -0
  11. package/app/components/panels/ReportsPanel.vue +72 -4
  12. package/app/components/panels/StepToolServers.logic.spec.ts +41 -1
  13. package/app/components/panels/StepToolServers.logic.ts +38 -0
  14. package/app/components/panels/StepToolServers.vue +28 -8
  15. package/app/components/riskPolicy/RiskPolicyPicker.logic.ts +7 -1
  16. package/app/composables/api/accounts.ts +12 -0
  17. package/app/composables/api/documents.ts +9 -0
  18. package/app/composables/useDocumentFreshness.ts +111 -0
  19. package/app/stores/accounts.audit.spec.ts +74 -0
  20. package/app/stores/accounts.ts +75 -0
  21. package/app/stores/board/moveRefusal.spec.ts +40 -0
  22. package/app/stores/board/moveRefusal.ts +34 -0
  23. package/app/stores/board/placement.ts +6 -1
  24. package/app/stores/documents.spec.ts +156 -0
  25. package/app/stores/documents.ts +14 -0
  26. package/app/types/documents.ts +4 -0
  27. package/i18n/locales/de.json +79 -3
  28. package/i18n/locales/en.json +79 -3
  29. package/i18n/locales/es.json +79 -3
  30. package/i18n/locales/fr.json +79 -3
  31. package/i18n/locales/he.json +79 -3
  32. package/i18n/locales/it.json +79 -3
  33. package/i18n/locales/ja.json +79 -3
  34. package/i18n/locales/pl.json +79 -3
  35. package/i18n/locales/tr.json +79 -3
  36. package/i18n/locales/uk.json +79 -3
  37. package/package.json +2 -2
@@ -0,0 +1,111 @@
1
+ import { ref } from 'vue'
2
+ import type {
3
+ DocumentFreshness,
4
+ DocumentOrigin,
5
+ DocumentSourceKind,
6
+ RefreshedDocumentView,
7
+ SourceDocument,
8
+ } from '~/types/domain'
9
+
10
+ /**
11
+ * A verdict plus WHEN it was reached. The two are one value because either alone is a half-truth:
12
+ * the verdict says the copy matched the source, and only the stamp says how long ago that was true
13
+ * of a page someone else is still editing. A surface handed the verdict without the stamp can only
14
+ * render it as "just now", which is exactly the false confidence this feature exists to remove: an
15
+ * hour-old confirmation would keep showing a green check.
16
+ *
17
+ * `checkedAt` is stamped where the answer LANDS rather than on the server, so it is on the clock the
18
+ * person reading it is looking at. A skewed browser clock renders its own time consistently; a
19
+ * server stamp would render "checked at 14:32" to someone whose clock says 14:29.
20
+ */
21
+ export interface DatedFreshness {
22
+ readonly verdict: DocumentFreshness
23
+ /** Epoch ms, on the reader's own clock. */
24
+ readonly checkedAt: number
25
+ }
26
+
27
+ /**
28
+ * The "is the copy on the board still the current one" half of the documents store: the per-document
29
+ * verdict a manual check produced, the in-flight flags, and the call that produces both.
30
+ *
31
+ * Its own collaborator rather than more lines in the store because it holds a DIFFERENT KIND of
32
+ * state from everything around it. The store's other state is the projection itself (sources,
33
+ * connections, imported rows), which the backend owns and the SPA mirrors; a freshness verdict is a
34
+ * statement about one MOMENT ("as of the click, this was the live revision"), owned by nothing and
35
+ * true of no row. Keeping it separate is what makes the three rules below expressible at all instead
36
+ * of folding into the document list and quietly becoming a claim about it.
37
+ *
38
+ * The rules, in one place so no surface can get half of them right:
39
+ *
40
+ * - **An absent verdict means "nobody has asked", never "unknown".** Listing documents
41
+ * deliberately probes nothing (confirming costs a round trip to the source per page), so a
42
+ * freshly imported document has no verdict and is perfectly fine, which is not the same fact as
43
+ * a check that ran and could not conclude.
44
+ * - **The verdict never merges into the row.** A refresh that finds nothing changed writes
45
+ * nothing, so `syncedAt` legitimately stays where it was; folding a confirmation into the row
46
+ * would either claim a write that never happened or leave the confirmation sitting on a body
47
+ * the source has moved past since.
48
+ * - **A verdict belongs to the BOARD it was asked on.** The same Figma file can be imported into
49
+ * two boards, and the pair `(source, externalId)` is identical in both, so a map keyed by that
50
+ * alone would render board A's "confirmed, revision v3" against board B's row, which nobody
51
+ * checked, breaking the first rule in the one direction that cannot be noticed. Hence
52
+ * {@link keyOf}, and hence the read below asking for the ACTIVE board each time rather than
53
+ * capturing it once.
54
+ */
55
+ export function useDocumentFreshness(deps: {
56
+ /**
57
+ * The board these maps are ABOUT. A getter rather than a value because it changes under a
58
+ * long-lived store, and every read has to see the change.
59
+ */
60
+ workspaceId: () => string | null
61
+ /** Ask the backend to re-confirm one document. Only a CONNECTABLE source can be asked. */
62
+ refresh: (source: DocumentSourceKind, externalId: string) => Promise<RefreshedDocumentView>
63
+ /** Reconcile the (possibly rewritten) row back into the list every surface reads. */
64
+ onRefreshed: (document: SourceDocument) => void
65
+ }): {
66
+ refresh: (source: DocumentSourceKind, externalId: string) => Promise<RefreshedDocumentView>
67
+ freshnessFor: (source: DocumentOrigin, externalId: string) => DatedFreshness | undefined
68
+ isRefreshing: (source: DocumentOrigin, externalId: string) => boolean
69
+ } {
70
+ const freshness = ref<Record<string, DatedFreshness>>({})
71
+ const refreshing = ref<Record<string, boolean>>({})
72
+ const keyOf = (workspaceId: string | null, source: DocumentOrigin, externalId: string) =>
73
+ `${workspaceId ?? ''}:${source}:${externalId}`
74
+
75
+ async function refresh(source: DocumentSourceKind, externalId: string) {
76
+ // Captured ONCE, at the start, and used for every write below: a check that outlives a board
77
+ // switch must land under the board it was asked on, not whichever one is showing when it
78
+ // returns. Re-reading the getter at the end would file the answer under a board that never
79
+ // asked, which is the same wrong render the key exists to prevent, just harder to reproduce.
80
+ const board = deps.workspaceId()
81
+ const key = keyOf(board, source, externalId)
82
+ refreshing.value = { ...refreshing.value, [key]: true }
83
+ try {
84
+ const result = await deps.refresh(source, externalId)
85
+ // The document LIST is the active board's, and it is not keyed by board, so a row can only be
86
+ // merged into it while that board is still the one showing. The verdict is filed either way:
87
+ // it stays true of the board it was asked on, and switching back should not have to re-ask.
88
+ if (deps.workspaceId() === board) deps.onRefreshed(result.document)
89
+ freshness.value = {
90
+ ...freshness.value,
91
+ [key]: { verdict: result.freshness, checkedAt: Date.now() },
92
+ }
93
+ return result
94
+ } finally {
95
+ // Cleared however the call ended. A failure that left the flag set would disable the button
96
+ // that is the whole remedy, so the person could not try again.
97
+ const { [key]: _settled, ...rest } = refreshing.value
98
+ refreshing.value = rest
99
+ }
100
+ }
101
+
102
+ // The verdict map itself stays INSIDE: a caller that could write it could record a conclusion
103
+ // nobody reached, which is the one thing this vocabulary exists to make impossible.
104
+ return {
105
+ refresh,
106
+ freshnessFor: (source, externalId) =>
107
+ freshness.value[keyOf(deps.workspaceId(), source, externalId)],
108
+ isRefreshing: (source, externalId) =>
109
+ !!refreshing.value[keyOf(deps.workspaceId(), source, externalId)],
110
+ }
111
+ }
@@ -0,0 +1,74 @@
1
+ import { describe, it, expect, vi } from 'vitest'
2
+ import { useAccountsStore } from '~/stores/accounts'
3
+
4
+ /**
5
+ * The audit feed and the one write that produces a row in it.
6
+ *
7
+ * What these guard is the seam between them. Forcing a member's sessions to end leaves nothing
8
+ * visible in the roster — the row IS the trace — so the feed has to be brought back into step.
9
+ * Doing that by awaiting the read inside the write is what conflated the two: a revocation that
10
+ * had already succeeded was reported to the admin as "could not sign the member out" whenever the
11
+ * follow-up read failed, which on a deployment with no audit store wired is every time.
12
+ */
13
+ describe('accounts store — audit feed and forced revocation', () => {
14
+ it('reports a successful revocation as successful even when the audit read is broken', async () => {
15
+ // The 204 landed. Nothing about a failing GET afterwards changes that, and telling an admin
16
+ // otherwise invites them to do it again or to escalate a problem they do not have.
17
+ const listAuditEvents = vi.fn(() => Promise.reject(new Error('audit store down')))
18
+ const revokeMemberSessions = vi.fn(() => Promise.resolve())
19
+ vi.stubGlobal('useApi', () => ({ listAuditEvents, revokeMemberSessions }))
20
+
21
+ const store = useAccountsStore()
22
+ await expect(store.revokeMemberSessions('acc_1', 'usr_2')).resolves.toBeUndefined()
23
+ expect(revokeMemberSessions).toHaveBeenCalledWith('acc_1', 'usr_2')
24
+ })
25
+
26
+ it('does not read the audit log from the write path at all', async () => {
27
+ // The write must not pay for a read nothing may be showing: the panel is absent in basic mode
28
+ // and on any deployment that wires no audit store. Marking the feed stale leaves the reload to
29
+ // the viewer, which is the only place that knows it is on screen and the only place that
30
+ // renders a failed read AS a failed read.
31
+ const listAuditEvents = vi.fn(() => Promise.resolve({ events: [], nextCursor: null }))
32
+ vi.stubGlobal('useApi', () => ({
33
+ listAuditEvents,
34
+ revokeMemberSessions: () => Promise.resolve(),
35
+ }))
36
+
37
+ const store = useAccountsStore()
38
+ await store.revokeMemberSessions('acc_1', 'usr_2')
39
+
40
+ expect(listAuditEvents).not.toHaveBeenCalled()
41
+ expect(store.auditStale).toBe(true)
42
+ })
43
+
44
+ it('propagates a failed revocation, which IS the caller’s business', async () => {
45
+ vi.stubGlobal('useApi', () => ({
46
+ listAuditEvents: () => Promise.resolve({ events: [], nextCursor: null }),
47
+ revokeMemberSessions: () => Promise.reject(new Error('nope')),
48
+ }))
49
+
50
+ const store = useAccountsStore()
51
+ store.auditStale = false
52
+
53
+ await expect(store.revokeMemberSessions('acc_1', 'usr_2')).rejects.toThrow('nope')
54
+ // Nothing was written, so the feed is not behind.
55
+ expect(store.auditStale).toBe(false)
56
+ })
57
+
58
+ it('clears the stale flag on the reload ATTEMPT, not on its success', async () => {
59
+ // Clearing on success would re-trigger the watch that just failed, on every failure, forever.
60
+ // The failure is reported by the viewer's own error slot instead.
61
+ vi.stubGlobal('useApi', () => ({
62
+ listAuditEvents: () => Promise.reject(new Error('still down')),
63
+ revokeMemberSessions: () => Promise.resolve(),
64
+ }))
65
+
66
+ const store = useAccountsStore()
67
+ await store.revokeMemberSessions('acc_1', 'usr_2')
68
+ expect(store.auditStale).toBe(true)
69
+
70
+ await expect(store.loadAuditEvents('acc_1')).rejects.toThrow('still down')
71
+ expect(store.auditStale).toBe(false)
72
+ expect(store.auditLoading).toBe(false)
73
+ })
74
+ })
@@ -1,6 +1,7 @@
1
1
  import { defineStore } from 'pinia'
2
2
  import { computed, ref } from 'vue'
3
3
  import { useUpsertList } from '~/composables/useUpsertList'
4
+ import type { AuditEventWire } from '@cat-factory/contracts'
4
5
  import type {
5
6
  Account,
6
7
  AccountInvitation,
@@ -113,6 +114,73 @@ export const useAccountsStore = defineStore(
113
114
  return updated
114
115
  }
115
116
 
117
+ // ---- audit log + session revocation -----------------------------------
118
+ // The audit log is a paginated, append-only feed, so it is kept OUT of `useUpsertList`: the
119
+ // rows never change and never arrive out of band, and a keyed upsert list would quietly
120
+ // reorder a page whose whole meaning is its order. Pages are appended in the order the server
121
+ // served them, and `auditCursor` is opaque — it round-trips verbatim, never inspected.
122
+ const auditEvents = ref<AuditEventWire[]>([])
123
+ const auditCursor = ref<string | null>(null)
124
+ const auditLoading = ref(false)
125
+
126
+ /**
127
+ * The audit feed has fallen behind something this session did. Set by the writers below and
128
+ * cleared by whoever reloads; the audit viewer watches it.
129
+ *
130
+ * A flag rather than a reload, because reloading here is what conflated two outcomes: the
131
+ * revocation and the feed refresh are separate calls that fail separately, and awaiting the
132
+ * second inside the first reported a revocation that HAD succeeded as "could not sign the
133
+ * member out" whenever the read failed after it. It also fired on surfaces with no audit
134
+ * panel rendered (basic mode, and any account whose deployment wires no audit store), paying
135
+ * for a read nothing was going to show and turning its 503 into an error about the write.
136
+ *
137
+ * The viewer owns the reload instead, which is where it belongs: it already distinguishes a
138
+ * failed page from an empty one, and a refresh failure now renders in that slot rather than
139
+ * as a false report about the revocation.
140
+ */
141
+ const auditStale = ref(false)
142
+
143
+ /**
144
+ * Load the newest page, replacing whatever was held. Used on open and on refresh, so a reader
145
+ * is never shown a feed spliced from two different moments.
146
+ */
147
+ async function loadAuditEvents(accountId: string) {
148
+ auditLoading.value = true
149
+ // Cleared on ATTEMPT, not on success. A failed reload is reported by the viewer's own error
150
+ // slot, and leaving the flag set would re-trigger the watch that just failed.
151
+ auditStale.value = false
152
+ try {
153
+ const page = await api.listAuditEvents(accountId)
154
+ auditEvents.value = page.events
155
+ auditCursor.value = page.nextCursor
156
+ } finally {
157
+ auditLoading.value = false
158
+ }
159
+ }
160
+
161
+ /** Append the next (older) page. A no-op at the end of the log. */
162
+ async function loadMoreAuditEvents(accountId: string) {
163
+ if (!auditCursor.value || auditLoading.value) return
164
+ auditLoading.value = true
165
+ try {
166
+ const page = await api.listAuditEvents(accountId, { cursor: auditCursor.value })
167
+ auditEvents.value = [...auditEvents.value, ...page.events]
168
+ auditCursor.value = page.nextCursor
169
+ } finally {
170
+ auditLoading.value = false
171
+ }
172
+ }
173
+
174
+ /**
175
+ * End every session a member holds. Their membership and roles are untouched, so the roster
176
+ * needs no patching — what changed is not visible in it, which is why the audit feed is
177
+ * marked stale instead: the revocation's only lasting trace is the row it wrote.
178
+ */
179
+ async function revokeMemberSessions(accountId: string, userId: string) {
180
+ await api.revokeMemberSessions(accountId, userId)
181
+ auditStale.value = true
182
+ }
183
+
116
184
  // ---- email sender connection -----------------------------------------
117
185
 
118
186
  const emailConnection = ref<EmailConnection | null>(null)
@@ -144,6 +212,10 @@ export const useAccountsStore = defineStore(
144
212
  ready,
145
213
  members,
146
214
  invitations,
215
+ auditEvents,
216
+ auditCursor,
217
+ auditLoading,
218
+ auditStale,
147
219
  emailConnection,
148
220
  emailConfigured,
149
221
  load,
@@ -155,6 +227,9 @@ export const useAccountsStore = defineStore(
155
227
  invite,
156
228
  revokeInvite,
157
229
  setMemberRoles,
230
+ loadAuditEvents,
231
+ loadMoreAuditEvents,
232
+ revokeMemberSessions,
158
233
  loadEmailConnection,
159
234
  connectEmail,
160
235
  disconnectEmail,
@@ -0,0 +1,40 @@
1
+ import type { RiskPolicySelectionRefusal } from '@cat-factory/contracts'
2
+ import { describe, expect, it } from 'vitest'
3
+ import en from '../../../i18n/locales/en.json'
4
+ import { moveRefusalKey } from './moveRefusal'
5
+
6
+ /** The thrown shape the contract client produces for a refused reparent (`ApiError.body`). */
7
+ const refused = (reason: string) => ({
8
+ body: { error: { code: 'forbidden', details: { reason } } },
9
+ })
10
+
11
+ const REASONS: RiskPolicySelectionRefusal[] = [
12
+ 'relaxes_role_sandbox',
13
+ 'relaxes_role_submission_allowlist',
14
+ 'relaxes_role_class_rule',
15
+ ]
16
+
17
+ describe('moveRefusalKey', () => {
18
+ it('maps every refusal reason to a key the catalog actually holds', () => {
19
+ // Derived from the contracts union rather than a list written here, so a new reason fails
20
+ // this rather than silently reaching the user as the backend's untranslated English.
21
+ const catalog = (en as { board: { toast: { moveRefused: Record<string, string> } } }).board
22
+ .toast.moveRefused
23
+ for (const reason of REASONS) {
24
+ const key = moveRefusalKey(refused(reason))
25
+ expect(key, `${reason} has no key`).toBe(`board.toast.moveRefused.${reason}`)
26
+ expect(catalog[reason], `${reason} has no copy`).toBeTruthy()
27
+ }
28
+ // And nothing else: copy for a reason the backend cannot send is copy nobody translates for
29
+ // a purpose.
30
+ expect(Object.keys(catalog).sort()).toEqual([...REASONS].sort())
31
+ })
32
+
33
+ it('falls back to the raw message for anything else', () => {
34
+ // A 403 the guard did not raise, a network fault, a reason a newer backend knows and this
35
+ // build does not: the backend's own prose is the honest last resort, not a wrong translation.
36
+ expect(moveRefusalKey(refused('some_future_reason'))).toBeNull()
37
+ expect(moveRefusalKey({ body: { error: { code: 'forbidden' } } })).toBeNull()
38
+ expect(moveRefusalKey(new Error('Network down'))).toBeNull()
39
+ })
40
+ })
@@ -0,0 +1,34 @@
1
+ import type { RiskPolicySelectionRefusal } from '@cat-factory/contracts'
2
+ import { apiErrorReason } from '~/composables/api/errors'
3
+
4
+ /**
5
+ * The translated description for a reparent the backend refused on merge-preset grounds, or
6
+ * `null` when the failure is anything else.
7
+ *
8
+ * A cross-home drag carries a task into another workspace's preset library, which re-decides
9
+ * whether its runs are sandboxed for the mover's role (ADR 0037), so the backend refuses one that
10
+ * would drop a restriction they are under. That refusal is a condition a person can act on, and
11
+ * the backend does not localize prose: it emits the machine-readable `details.reason` and the SPA
12
+ * maps it here, exactly as `usePipelineErrorToast` maps a conflict's. Without this the drag toast
13
+ * showed the raw English `ForbiddenError` message to every locale.
14
+ *
15
+ * The copy is deliberately NOT the picker's `riskPolicy.picker.refused.*`, which is worded for
16
+ * someone holding a control: this person picked no policy at all, and telling them about "the
17
+ * policy you picked" sends them looking for a picker they never touched.
18
+ *
19
+ * Keyed on the contracts union so a renamed reason fails the typecheck rather than silently
20
+ * falling through to the untranslated prose. An unrecognised reason returns `null` and the caller
21
+ * falls back to the backend's own message, which is the honest last resort.
22
+ */
23
+ const MOVE_REFUSAL_KEY: Record<RiskPolicySelectionRefusal, string> = {
24
+ relaxes_role_sandbox: 'board.toast.moveRefused.relaxes_role_sandbox',
25
+ relaxes_role_submission_allowlist: 'board.toast.moveRefused.relaxes_role_submission_allowlist',
26
+ relaxes_role_class_rule: 'board.toast.moveRefused.relaxes_role_class_rule',
27
+ }
28
+
29
+ /** The i18n key for a thrown reparent error's refusal reason, else `null`. */
30
+ export function moveRefusalKey(error: unknown): string | null {
31
+ const reason = apiErrorReason(error)
32
+ if (!reason) return null
33
+ return MOVE_REFUSAL_KEY[reason as RiskPolicySelectionRefusal] ?? null
34
+ }
@@ -2,6 +2,7 @@ import type { UpdateBlockInput } from '@cat-factory/contracts'
2
2
  import { useServicesStore } from '~/stores/services'
3
3
  import { useWorkspaceStore } from '~/stores/workspace'
4
4
  import { createBoardDependencies } from './dependencies'
5
+ import { moveRefusalKey } from './moveRefusal'
5
6
  import type { BoardWriteContext } from './context'
6
7
  import { UNDO_WINDOW_MS } from './context'
7
8
 
@@ -69,9 +70,13 @@ export function createBoardPlacement(ctx: BoardWriteContext) {
69
70
  } catch (e) {
70
71
  b.parentId = prevParentId
71
72
  b.position = prevPosition
73
+ // A cross-home drag can be refused on merge-preset grounds, which is a condition the mover
74
+ // can act on rather than a fault. The backend sends the machine-readable reason and no
75
+ // translated prose, so map it here; anything else keeps the raw message as the last resort.
76
+ const refusal = moveRefusalKey(e)
72
77
  toast.add({
73
78
  title: tr('board.toast.moveFailed'),
74
- description: e instanceof Error ? e.message : String(e),
79
+ description: refusal ? tr(refusal) : e instanceof Error ? e.message : String(e),
75
80
  icon: 'i-lucide-triangle-alert',
76
81
  color: 'error',
77
82
  })
@@ -0,0 +1,156 @@
1
+ import { describe, it, expect, beforeEach, vi } from 'vitest'
2
+ import { useDocumentsStore } from '~/stores/documents'
3
+ import { useWorkspaceStore } from '~/stores/workspace'
4
+ import type { DocumentFreshness, SourceDocument } from '~/types/domain'
5
+
6
+ // The document store's REFRESH half: what a person gets back when they ask a source whether the
7
+ // copy on the board is still the current one.
8
+ //
9
+ // Two things are worth pinning here rather than leaving to the component. The verdict is kept
10
+ // SEPARATE from the row because it is a statement about a moment, not a property of the projection,
11
+ // so an absent entry has to keep meaning "nobody has asked" (listing documents deliberately probes
12
+ // nothing). And a refresh that pulls a moved page has to reconcile the returned row into the same
13
+ // list every surface reads, or the panel goes on showing the title and excerpt import stored while
14
+ // claiming, one line below, that it just confirmed the newer revision.
15
+
16
+ function doc(over: Partial<SourceDocument> = {}): SourceDocument {
17
+ return {
18
+ source: 'figma',
19
+ externalId: 'file1:1-2',
20
+ title: 'Checkout flow',
21
+ url: 'https://figma.com/design/file1',
22
+ excerpt: 'Checkout',
23
+ linkedBlockId: 'task_1',
24
+ role: null,
25
+ docKind: null,
26
+ syncedAt: 1_000,
27
+ ...over,
28
+ }
29
+ }
30
+
31
+ const CONFIRMED: DocumentFreshness = { status: 'confirmed', version: 'v2', change: 'reimported' }
32
+
33
+ /** The store reads `useApi()` off the auto-import, so each test stubs just what it calls. */
34
+ function stubApi(over: Record<string, unknown>) {
35
+ vi.stubGlobal('useApi', () => ({
36
+ listDocuments: () => Promise.resolve([]),
37
+ ...over,
38
+ }))
39
+ }
40
+
41
+ beforeEach(() => {
42
+ useWorkspaceStore().workspaceId = 'ws1'
43
+ })
44
+
45
+ describe('documents store: manual refresh', () => {
46
+ it('reconciles the refreshed row into the list every surface reads', async () => {
47
+ stubApi({
48
+ listDocuments: () => Promise.resolve([doc()]),
49
+ refreshDocument: () =>
50
+ Promise.resolve({
51
+ document: doc({ title: 'Checkout flow v2', syncedAt: 2_000 }),
52
+ freshness: CONFIRMED,
53
+ }),
54
+ })
55
+ const store = useDocumentsStore()
56
+ await store.loadDocuments()
57
+
58
+ await store.refresh('figma', 'file1:1-2')
59
+
60
+ // Upserted by `(source, externalId)`, not appended: a refresh must not leave the old title
61
+ // sitting beside the new one in a picker keyed by the same document.
62
+ expect(store.documents).toHaveLength(1)
63
+ expect(store.documents[0]?.title).toBe('Checkout flow v2')
64
+ })
65
+
66
+ it('records the verdict beside the row, and reports "nobody asked" until someone does', async () => {
67
+ stubApi({
68
+ refreshDocument: () => Promise.resolve({ document: doc(), freshness: CONFIRMED }),
69
+ })
70
+ const store = useDocumentsStore()
71
+
72
+ // An unasked document has NO verdict. It must not read as unknown-and-therefore-suspect: a
73
+ // freshly imported page is fine, it simply has not been re-checked since.
74
+ expect(store.freshnessFor('figma', 'file1:1-2')).toBeUndefined()
75
+
76
+ await store.refresh('figma', 'file1:1-2')
77
+
78
+ expect(store.freshnessFor('figma', 'file1:1-2')?.verdict).toEqual(CONFIRMED)
79
+ // Stamped with WHEN it was reached, because a verdict never expires: what keeps an hour-old
80
+ // confirmation from rendering as the present state of a page that has had an hour to move is
81
+ // that the moment travels with it.
82
+ expect(store.freshnessFor('figma', 'file1:1-2')?.checkedAt).toBeTypeOf('number')
83
+ // Scoped to the document that was asked about, never to the source.
84
+ expect(store.freshnessFor('figma', 'other:9-9')).toBeUndefined()
85
+ })
86
+
87
+ it('never shows one board\u2019s verdict against another board\u2019s row', async () => {
88
+ // The same Figma file can be imported into two boards, and `(source, externalId)` is identical
89
+ // in both, so a verdict keyed by that pair alone would render board A's "confirmed, revision
90
+ // v2" against a board B row nobody has ever checked. That breaks the "absent means nobody has
91
+ // asked" rule in the one direction nothing can notice, since the wrong answer looks like a
92
+ // right one.
93
+ stubApi({
94
+ refreshDocument: () => Promise.resolve({ document: doc(), freshness: CONFIRMED }),
95
+ })
96
+ const store = useDocumentsStore()
97
+ await store.refresh('figma', 'file1:1-2')
98
+ expect(store.freshnessFor('figma', 'file1:1-2')?.verdict).toEqual(CONFIRMED)
99
+
100
+ useWorkspaceStore().workspaceId = 'ws2'
101
+
102
+ expect(store.freshnessFor('figma', 'file1:1-2')).toBeUndefined()
103
+
104
+ // …and switching back does not lose it: the verdict stays true of the board it was asked on.
105
+ useWorkspaceStore().workspaceId = 'ws1'
106
+ expect(store.freshnessFor('figma', 'file1:1-2')?.verdict).toEqual(CONFIRMED)
107
+ })
108
+
109
+ it('does not merge a check that outlived a board switch into the new board\u2019s list', async () => {
110
+ // The list is the ACTIVE board's and is not keyed by board, so a row arriving after the switch
111
+ // would be a document from somewhere else appearing on a board that never imported it. The
112
+ // verdict is still filed, under the board that asked.
113
+ let resolve!: (v: unknown) => void
114
+ stubApi({
115
+ listDocuments: () => Promise.resolve([]),
116
+ refreshDocument: () =>
117
+ new Promise((res) => {
118
+ resolve = res
119
+ }),
120
+ })
121
+ const store = useDocumentsStore()
122
+ await store.loadDocuments()
123
+
124
+ const pending = store.refresh('figma', 'file1:1-2')
125
+ useWorkspaceStore().workspaceId = 'ws2'
126
+ resolve({ document: doc(), freshness: CONFIRMED })
127
+ await pending
128
+
129
+ expect(store.documents).toHaveLength(0)
130
+ useWorkspaceStore().workspaceId = 'ws1'
131
+ expect(store.freshnessFor('figma', 'file1:1-2')?.verdict).toEqual(CONFIRMED)
132
+ })
133
+
134
+ it('reports in-flight state per document, and clears it when the source refuses', async () => {
135
+ let reject!: (e: Error) => void
136
+ stubApi({
137
+ refreshDocument: () =>
138
+ new Promise((_res, rej) => {
139
+ reject = rej
140
+ }),
141
+ })
142
+ const store = useDocumentsStore()
143
+
144
+ const pending = store.refresh('figma', 'file1:1-2')
145
+ expect(store.isRefreshing('figma', 'file1:1-2')).toBe(true)
146
+ expect(store.isRefreshing('figma', 'other:9-9')).toBe(false)
147
+
148
+ reject(new Error('figma 429'))
149
+ await expect(pending).rejects.toThrow('figma 429')
150
+
151
+ // A failure that left the flag set would disable the button that is the whole remedy, and the
152
+ // person would have no way to try again.
153
+ expect(store.isRefreshing('figma', 'file1:1-2')).toBe(false)
154
+ expect(store.freshnessFor('figma', 'file1:1-2')).toBeUndefined()
155
+ })
156
+ })
@@ -13,6 +13,7 @@ import type {
13
13
  SourceDocument,
14
14
  } from '~/types/domain'
15
15
  import { isConnectableSource } from '@cat-factory/contracts'
16
+ import { useDocumentFreshness } from '~/composables/useDocumentFreshness'
16
17
  import { useSourceIntegration } from '~/composables/useSourceIntegration'
17
18
  import { useUpsertList } from '~/composables/useUpsertList'
18
19
  import { useWorkspaceStore } from '~/stores/workspace'
@@ -56,6 +57,16 @@ export const useDocumentsStore = defineStore('documents', () => {
56
57
  })
57
58
  const loading = ref(false)
58
59
 
60
+ // The "is this still the current revision" half, in its own collaborator: a verdict is a
61
+ // statement about a moment rather than a property of a row, and `useDocumentFreshness` owns why
62
+ // the two must not merge.
63
+ const { refresh, freshnessFor, isRefreshing } = useDocumentFreshness({
64
+ workspaceId: () => workspace.workspaceId,
65
+ refresh: (source, externalId) =>
66
+ api.refreshDocument(workspace.requireId(), { source, externalId }),
67
+ onRefreshed: upsertDoc,
68
+ })
69
+
59
70
  // Workspace+DocKind template / exemplar role links (WS1). Loaded lazily when the management
60
71
  // panel opens; the full list of role-tagged documents across kinds.
61
72
  const roleLinks = ref<SourceDocument[]>([])
@@ -224,6 +235,9 @@ export const useDocumentsStore = defineStore('documents', () => {
224
235
  loadDocuments,
225
236
  resolveRef,
226
237
  importDocument,
238
+ refresh,
239
+ freshnessFor,
240
+ isRefreshing,
227
241
  search,
228
242
  plan,
229
243
  spawn,
@@ -15,6 +15,10 @@ export type {
15
15
  CredentialField,
16
16
  DocumentSourceDescriptor,
17
17
  DocumentConnection,
18
+ DocumentFreshness,
19
+ DocumentFreshnessChange,
20
+ DocumentFreshnessGap,
21
+ RefreshedDocumentView,
18
22
  SourceDocument,
19
23
  DocumentSearchResult,
20
24
  DocumentRefReason,