@cat-factory/app 0.37.0 → 0.37.1

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.
@@ -0,0 +1,107 @@
1
+ import { type ComputedRef, type Ref, computed, ref } from 'vue'
2
+ import { useUpsertList } from '~/composables/useUpsertList'
3
+
4
+ /**
5
+ * The source-integration lifecycle shared by the document-source and task-source stores:
6
+ * the opt-in `available` gate, the per-source `connections` list, the `descriptorFor` /
7
+ * `connectionFor` / `isConnected` lookups, and a `probe()` that resolves all of it (hiding
8
+ * the UI when the integration is off). Both stores previously hand-rolled this, with
9
+ * inconsistent error handling — one captured the probe failure, the other swallowed it.
10
+ * Standardising here means every integration now records WHY a probe failed
11
+ * (`probeError`: a 503 "turned off on this deployment" vs a 500 "the backend errored"), so
12
+ * a settings panel can explain the empty state instead of a blanket "install it first".
13
+ *
14
+ * The store supplies only what differs: how to fetch its sources + connections, and the
15
+ * connect/disconnect calls (which feed `upsertConnection` / `removeConnection`). Source-
16
+ * specific extras (diagnostics, per-source enable toggles, plan/spawn) stay in the store.
17
+ */
18
+ export function useSourceIntegration<
19
+ Source extends string,
20
+ Conn extends { source: Source },
21
+ Desc extends { source: Source },
22
+ >(opts: {
23
+ /** Fetch the configured sources + live connections; throws when the integration is off. */
24
+ fetch: () => Promise<{ sources: Desc[]; connections: Conn[] }>
25
+ /** Gate the probe (e.g. skip until a workspace is selected). */
26
+ enabled?: () => boolean
27
+ }): {
28
+ available: Ref<boolean | null>
29
+ probeError: Ref<{ status: number | null; message: string } | null>
30
+ sources: Ref<Desc[]>
31
+ connections: Ref<Conn[]>
32
+ connectedSources: ComputedRef<Desc[]>
33
+ anyConnected: ComputedRef<boolean>
34
+ descriptorFor: (source: Source) => Desc | undefined
35
+ connectionFor: (source: Source) => Conn | undefined
36
+ isConnected: (source: Source) => boolean
37
+ upsertConnection: (conn: Conn) => void
38
+ removeConnection: (source: Source) => void
39
+ probe: () => Promise<void>
40
+ } {
41
+ /** null = unknown (not probed yet), true/false = integration on/off. */
42
+ const available = ref<boolean | null>(null)
43
+ /** Why the last probe failed, when it did (kept rather than swallowed). */
44
+ const probeError = ref<{ status: number | null; message: string } | null>(null)
45
+ const sources = ref<Desc[]>([]) as Ref<Desc[]>
46
+ const { items: connections, upsert: upsertConnection } = useUpsertList<Conn>({
47
+ key: (c) => c.source,
48
+ })
49
+
50
+ const connectedSources = computed(() =>
51
+ sources.value.filter((s) => connections.value.some((c) => c.source === s.source)),
52
+ )
53
+ const anyConnected = computed(() => connections.value.length > 0)
54
+
55
+ function descriptorFor(source: Source): Desc | undefined {
56
+ return sources.value.find((s) => s.source === source)
57
+ }
58
+ function connectionFor(source: Source): Conn | undefined {
59
+ return connections.value.find((c) => c.source === source)
60
+ }
61
+ function isConnected(source: Source): boolean {
62
+ return connectionFor(source) !== undefined
63
+ }
64
+ function removeConnection(source: Source) {
65
+ connections.value = connections.value.filter((c) => c.source !== source)
66
+ }
67
+
68
+ /** Probe the integration: resolves `available`, the sources and connections. */
69
+ async function probe() {
70
+ if (opts.enabled && !opts.enabled()) return
71
+ try {
72
+ const { sources: srcs, connections: conns } = await opts.fetch()
73
+ available.value = true
74
+ probeError.value = null
75
+ sources.value = srcs
76
+ connections.value = conns
77
+ } catch (e) {
78
+ // 503 (integration disabled) or any error → hide the UI entry points, but keep the
79
+ // reason so a panel can explain it (503 = off here; 500 = the backend errored, e.g. an
80
+ // unapplied migration).
81
+ available.value = false
82
+ const err = e as { statusCode?: number; data?: { error?: { message?: string } } }
83
+ const serverMessage = err?.data?.error?.message
84
+ probeError.value = {
85
+ status: err?.statusCode ?? null,
86
+ message: serverMessage || (e instanceof Error ? e.message : String(e)),
87
+ }
88
+ sources.value = []
89
+ connections.value = []
90
+ }
91
+ }
92
+
93
+ return {
94
+ available,
95
+ probeError,
96
+ sources,
97
+ connections,
98
+ connectedSources,
99
+ anyConnected,
100
+ descriptorFor,
101
+ connectionFor,
102
+ isConnected,
103
+ upsertConnection,
104
+ removeConnection,
105
+ probe,
106
+ }
107
+ }
@@ -0,0 +1,60 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { useUpsertList } from '~/composables/useUpsertList'
3
+
4
+ interface Item {
5
+ id: string
6
+ v: number
7
+ }
8
+
9
+ describe('useUpsertList', () => {
10
+ it('appends new items by default and replaces in place by key', () => {
11
+ const { items, upsert } = useUpsertList<Item>({ key: (x) => x.id })
12
+ upsert({ id: 'a', v: 1 })
13
+ upsert({ id: 'b', v: 2 })
14
+ upsert({ id: 'a', v: 9 }) // replace, not duplicate
15
+ expect(items.value).toEqual([
16
+ { id: 'a', v: 9 },
17
+ { id: 'b', v: 2 },
18
+ ])
19
+ })
20
+
21
+ it('prepends new items when prepend is set (newest-first)', () => {
22
+ const { items, upsert } = useUpsertList<Item>({ key: (x) => x.id, prepend: true })
23
+ upsert({ id: 'a', v: 1 })
24
+ upsert({ id: 'b', v: 2 })
25
+ expect(items.value.map((x) => x.id)).toEqual(['b', 'a'])
26
+ })
27
+
28
+ it('removes by key and looks up by key', () => {
29
+ const { items, upsert, remove, get } = useUpsertList<Item>({ key: (x) => x.id })
30
+ upsert({ id: 'a', v: 1 })
31
+ upsert({ id: 'b', v: 2 })
32
+ expect(get('b')).toEqual({ id: 'b', v: 2 })
33
+ remove('a')
34
+ expect(items.value.map((x) => x.id)).toEqual(['b'])
35
+ remove('missing') // no-op
36
+ expect(items.value).toHaveLength(1)
37
+ })
38
+
39
+ it('supports composite keys and hydrate-from-snapshot', () => {
40
+ interface Doc {
41
+ source: string
42
+ externalId: string
43
+ }
44
+ const { items, upsert, hydrate } = useUpsertList<Doc>({
45
+ key: (d) => `${d.source}:${d.externalId}`,
46
+ })
47
+ hydrate([{ source: 'jira', externalId: '1' }])
48
+ upsert({ source: 'jira', externalId: '1' }) // same composite key → replace
49
+ upsert({ source: 'gh', externalId: '1' }) // different source → new
50
+ expect(items.value).toHaveLength(2)
51
+ })
52
+
53
+ it('seeds from initial without aliasing the caller array', () => {
54
+ const seed: Item[] = [{ id: 'a', v: 1 }]
55
+ const { items, upsert } = useUpsertList<Item>({ key: (x) => x.id, initial: seed })
56
+ upsert({ id: 'b', v: 2 })
57
+ expect(items.value).toHaveLength(2)
58
+ expect(seed).toHaveLength(1) // original untouched
59
+ })
60
+ })
@@ -0,0 +1,57 @@
1
+ import { type Ref, ref } from 'vue'
2
+
3
+ /**
4
+ * A keyed list with find-by-key upsert — the pattern reimplemented in ~13 stores
5
+ * (`const i = list.findIndex((x) => x.id === item.id); if (i >= 0) list[i] = item else …`).
6
+ * Wraps a reactive `T[]` and exposes `upsert` (replace-in-place or insert), `remove`,
7
+ * `get`, and `hydrate` (replace from a server snapshot), all keyed by a caller-supplied
8
+ * `key` function. New items append by default, or `prepend: true` for newest-first inboxes.
9
+ *
10
+ * The returned `items` ref stays directly assignable, so a store can expose it under a
11
+ * domain name (`const { items: documents, upsert } = useUpsertList(...)`) and callers /
12
+ * tests can still do `store.documents = [...]`.
13
+ */
14
+ export function useUpsertList<T>(opts: {
15
+ /** Stable identity for an item (e.g. `(x) => x.id`, or `(x) => `${x.source}:${x.externalId}``). */
16
+ key: (item: T) => unknown
17
+ /** Insert position for a brand-new item: `true` ⇒ unshift (newest-first), else push. */
18
+ prepend?: boolean
19
+ /** Seed contents (copied, not aliased). */
20
+ initial?: T[]
21
+ }): {
22
+ items: Ref<T[]>
23
+ upsert: (item: T) => void
24
+ remove: (keyValue: unknown) => void
25
+ get: (keyValue: unknown) => T | undefined
26
+ hydrate: (next: T[]) => void
27
+ indexOf: (keyValue: unknown) => number
28
+ } {
29
+ const items = ref<T[]>(opts.initial ? [...opts.initial] : []) as Ref<T[]>
30
+
31
+ function indexOf(keyValue: unknown): number {
32
+ return items.value.findIndex((x) => opts.key(x) === keyValue)
33
+ }
34
+
35
+ function upsert(item: T) {
36
+ const i = indexOf(opts.key(item))
37
+ if (i >= 0) items.value[i] = item
38
+ else if (opts.prepend) items.value.unshift(item)
39
+ else items.value.push(item)
40
+ }
41
+
42
+ function remove(keyValue: unknown) {
43
+ const i = indexOf(keyValue)
44
+ if (i >= 0) items.value.splice(i, 1)
45
+ }
46
+
47
+ function get(keyValue: unknown): T | undefined {
48
+ const i = indexOf(keyValue)
49
+ return i >= 0 ? items.value[i] : undefined
50
+ }
51
+
52
+ function hydrate(next: T[]) {
53
+ items.value = [...next]
54
+ }
55
+
56
+ return { items, upsert, remove, get, hydrate, indexOf }
57
+ }
@@ -1,5 +1,5 @@
1
1
  import { defineStore } from 'pinia'
2
- import { computed, ref } from 'vue'
2
+ import { ref } from 'vue'
3
3
  import type {
4
4
  DocumentBoardPlan,
5
5
  DocumentConnection,
@@ -8,6 +8,8 @@ import type {
8
8
  DocumentSourceKind,
9
9
  SourceDocument,
10
10
  } from '~/types/domain'
11
+ import { useSourceIntegration } from '~/composables/useSourceIntegration'
12
+ import { useUpsertList } from '~/composables/useUpsertList'
11
13
  import { useWorkspaceStore } from '~/stores/workspace'
12
14
 
13
15
  /**
@@ -24,83 +26,46 @@ export const useDocumentsStore = defineStore('documents', () => {
24
26
  const api = useApi()
25
27
  const workspace = useWorkspaceStore()
26
28
 
27
- /** null = unknown (not probed yet), true/false = integration on/off. */
28
- const available = ref<boolean | null>(null)
29
- /** The configured sources and their connect/import descriptors. */
30
- const sources = ref<DocumentSourceDescriptor[]>([])
31
- /** Live connections, one per connected source. */
32
- const connections = ref<DocumentConnection[]>([])
33
- const documents = ref<SourceDocument[]>([])
29
+ // Shared opt-in / probe / connections lifecycle (see `useSourceIntegration`).
30
+ const integration = useSourceIntegration<
31
+ DocumentSourceKind,
32
+ DocumentConnection,
33
+ DocumentSourceDescriptor
34
+ >({
35
+ enabled: () => !!workspace.workspaceId,
36
+ fetch: async () => {
37
+ const [{ sources }, { connections }] = await Promise.all([
38
+ api.listDocumentSources(workspace.requireId()),
39
+ api.listDocumentConnections(workspace.requireId()),
40
+ ])
41
+ return { sources, connections }
42
+ },
43
+ })
44
+ const { available, sources, connections, connectedSources, anyConnected } = integration
45
+ const { descriptorFor, connectionFor, isConnected, probe } = integration
46
+
47
+ const { items: documents, upsert: upsertDoc } = useUpsertList<SourceDocument>({
48
+ key: (d) => `${d.source}:${d.externalId}`,
49
+ prepend: true,
50
+ })
34
51
  const loading = ref(false)
35
52
 
36
- /** Sources the workspace currently has a live connection to. */
37
- const connectedSources = computed(() =>
38
- sources.value.filter((s) => connections.value.some((c) => c.source === s.source)),
39
- )
40
- const anyConnected = computed(() => connections.value.length > 0)
41
-
42
- function descriptorFor(source: DocumentSourceKind): DocumentSourceDescriptor | undefined {
43
- return sources.value.find((s) => s.source === source)
44
- }
45
-
46
- function connectionFor(source: DocumentSourceKind): DocumentConnection | undefined {
47
- return connections.value.find((c) => c.source === source)
48
- }
49
-
50
- function isConnected(source: DocumentSourceKind): boolean {
51
- return connectionFor(source) !== undefined
52
- }
53
-
54
53
  /** Imported documents currently attached to a given block. */
55
54
  function docsForBlock(blockId: string): SourceDocument[] {
56
55
  return documents.value.filter((d) => d.linkedBlockId === blockId)
57
56
  }
58
57
 
59
- /** Merge a document returned by the backend into the local cache. */
60
- function upsertDoc(doc: SourceDocument) {
61
- const i = documents.value.findIndex(
62
- (d) => d.source === doc.source && d.externalId === doc.externalId,
63
- )
64
- if (i >= 0) documents.value[i] = doc
65
- else documents.value.unshift(doc)
66
- }
67
-
68
- function upsertConnection(conn: DocumentConnection) {
69
- const i = connections.value.findIndex((c) => c.source === conn.source)
70
- if (i >= 0) connections.value[i] = conn
71
- else connections.value.push(conn)
72
- }
73
-
74
- /** Probe the integration: resolves `available`, the sources and connections. */
75
- async function probe() {
76
- if (!workspace.workspaceId) return
77
- try {
78
- const [{ sources: srcs }, { connections: conns }] = await Promise.all([
79
- api.listDocumentSources(workspace.requireId()),
80
- api.listDocumentConnections(workspace.requireId()),
81
- ])
82
- available.value = true
83
- sources.value = srcs
84
- connections.value = conns
85
- } catch {
86
- // 503 (integration disabled) or any error → hide the UI entry points.
87
- available.value = false
88
- sources.value = []
89
- connections.value = []
90
- }
91
- }
92
-
93
58
  /** Connect the workspace to a source with its credential bag. */
94
59
  async function connect(source: DocumentSourceKind, credentials: Record<string, string>) {
95
60
  const conn = await api.connectDocumentSource(workspace.requireId(), source, credentials)
96
- upsertConnection(conn)
61
+ integration.upsertConnection(conn)
97
62
  available.value = true
98
63
  }
99
64
 
100
65
  /** Disconnect the workspace from a source. */
101
66
  async function disconnect(source: DocumentSourceKind) {
102
67
  await api.disconnectDocumentSource(workspace.requireId(), source)
103
- connections.value = connections.value.filter((c) => c.source !== source)
68
+ integration.removeConnection(source)
104
69
  }
105
70
 
106
71
  /** Load the imported documents for the workspace (across sources). */
@@ -1,6 +1,7 @@
1
1
  import { defineStore } from 'pinia'
2
- import { computed, ref } from 'vue'
2
+ import { computed } from 'vue'
3
3
  import type { Notification } from '~/types/domain'
4
+ import { useUpsertList } from '~/composables/useUpsertList'
4
5
  import { useWorkspaceStore } from '~/stores/workspace'
5
6
 
6
7
  /**
@@ -14,7 +15,11 @@ export const useNotificationsStore = defineStore('notifications', () => {
14
15
  const api = useApi()
15
16
 
16
17
  /** All open notifications, newest-first. */
17
- const open = ref<Notification[]>([])
18
+ const {
19
+ items: open,
20
+ upsert: upsertOpen,
21
+ remove,
22
+ } = useUpsertList<Notification>({ key: (n) => n.id, prepend: true })
18
23
 
19
24
  /** Replace the cache from a server snapshot. */
20
25
  function hydrate(notifications: Notification[]) {
@@ -28,13 +33,11 @@ export const useNotificationsStore = defineStore('notifications', () => {
28
33
  * replaced in place; a resolved one (acted/dismissed) is removed from the inbox.
29
34
  */
30
35
  function upsert(notification: Notification) {
31
- const i = open.value.findIndex((n) => n.id === notification.id)
32
36
  if (notification.status !== 'open') {
33
- if (i >= 0) open.value.splice(i, 1)
37
+ remove(notification.id)
34
38
  return
35
39
  }
36
- if (i >= 0) open.value[i] = notification
37
- else open.value.unshift(notification)
40
+ upsertOpen(notification)
38
41
  }
39
42
 
40
43
  /** Open notifications for a given block (for the board card badge). */
@@ -8,6 +8,8 @@ import type {
8
8
  TaskSourceKind,
9
9
  TaskSourceState,
10
10
  } from '~/types/domain'
11
+ import { useSourceIntegration } from '~/composables/useSourceIntegration'
12
+ import { useUpsertList } from '~/composables/useUpsertList'
11
13
  import { useWorkspaceStore } from '~/stores/workspace'
12
14
  import { useBoardStore } from '~/stores/board'
13
15
 
@@ -27,95 +29,42 @@ export const useTasksStore = defineStore('tasks', () => {
27
29
  const api = useApi()
28
30
  const workspace = useWorkspaceStore()
29
31
 
30
- /** null = unknown (not probed yet), true/false = integration on/off. */
31
- const available = ref<boolean | null>(null)
32
- /**
33
- * Why the last probe failed, when it did — captured (rather than swallowed) so
34
- * the settings panel can explain *why* nothing is surfaced (integration disabled
35
- * vs a server/backend error) instead of a blanket "install integration first".
36
- */
37
- const probeError = ref<{ status: number | null; message: string } | null>(null)
38
- /** The configured sources, each with its descriptor + per-workspace state (available + enabled). */
39
- const sources = ref<TaskSourceState[]>([])
40
- /** Live connections, one per connected (credentialed) source. */
41
- const connections = ref<TaskConnection[]>([])
42
- const tasks = ref<SourceTask[]>([])
32
+ // Shared opt-in / probe / connections lifecycle (see `useSourceIntegration`). Its
33
+ // `probeError` is what lets the settings panel explain *why* nothing is surfaced
34
+ // (integration disabled vs a server/backend error) instead of "install it first".
35
+ const integration = useSourceIntegration<TaskSourceKind, TaskConnection, TaskSourceState>({
36
+ enabled: () => !!workspace.workspaceId,
37
+ fetch: async () => {
38
+ const [{ sources }, { connections }] = await Promise.all([
39
+ api.listTaskSources(workspace.requireId()),
40
+ api.listTaskConnections(workspace.requireId()),
41
+ ])
42
+ return { sources, connections }
43
+ },
44
+ })
45
+ const { available, probeError, sources, connections, connectedSources, anyConnected } =
46
+ integration
47
+ const { descriptorFor, connectionFor, isConnected, probe } = integration
48
+
49
+ const { items: tasks, upsert: upsertTask } = useUpsertList<SourceTask>({
50
+ key: (t) => `${t.source}:${t.externalId}`,
51
+ prepend: true,
52
+ })
43
53
  /** The last live setup-check verdict per source (from `checkSetup`). */
44
54
  const diagnostics = ref<Partial<Record<TaskSourceKind, TaskSourceDiagnostic>>>({})
45
55
  /** The source currently running a setup check, if any. */
46
56
  const checking = ref<TaskSourceKind | null>(null)
47
57
  const loading = ref(false)
48
58
 
49
- /** Sources the workspace currently has a live connection to. */
50
- const connectedSources = computed(() =>
51
- sources.value.filter((s) => connections.value.some((c) => c.source === s.source)),
52
- )
53
- const anyConnected = computed(() => connections.value.length > 0)
54
-
55
59
  /** Sources offered for import: available (connected / App installed) AND enabled. */
56
60
  const offeredSources = computed(() => sources.value.filter((s) => s.available && s.enabled))
57
61
  const anyOffered = computed(() => offeredSources.value.length > 0)
58
62
 
59
- function descriptorFor(source: TaskSourceKind): TaskSourceState | undefined {
60
- return sources.value.find((s) => s.source === source)
61
- }
62
-
63
- function connectionFor(source: TaskSourceKind): TaskConnection | undefined {
64
- return connections.value.find((c) => c.source === source)
65
- }
66
-
67
- function isConnected(source: TaskSourceKind): boolean {
68
- return connectionFor(source) !== undefined
69
- }
70
-
71
63
  /** Imported issues currently attached to a given block. */
72
64
  function tasksForBlock(blockId: string): SourceTask[] {
73
65
  return tasks.value.filter((t) => t.linkedBlockId === blockId)
74
66
  }
75
67
 
76
- /** Merge an issue returned by the backend into the local cache. */
77
- function upsertTask(task: SourceTask) {
78
- const i = tasks.value.findIndex(
79
- (t) => t.source === task.source && t.externalId === task.externalId,
80
- )
81
- if (i >= 0) tasks.value[i] = task
82
- else tasks.value.unshift(task)
83
- }
84
-
85
- function upsertConnection(conn: TaskConnection) {
86
- const i = connections.value.findIndex((c) => c.source === conn.source)
87
- if (i >= 0) connections.value[i] = conn
88
- else connections.value.push(conn)
89
- }
90
-
91
- /** Probe the integration: resolves `available`, the sources and connections. */
92
- async function probe() {
93
- if (!workspace.workspaceId) return
94
- try {
95
- const [{ sources: srcs }, { connections: conns }] = await Promise.all([
96
- api.listTaskSources(workspace.requireId()),
97
- api.listTaskConnections(workspace.requireId()),
98
- ])
99
- available.value = true
100
- probeError.value = null
101
- sources.value = srcs
102
- connections.value = conns
103
- } catch (e) {
104
- // 503 (integration disabled) or any error → hide the UI entry points, but keep
105
- // the reason so the settings panel can explain it (a 503 is "turned off on this
106
- // deployment"; a 500 is "the backend errored — e.g. a migration isn't applied").
107
- available.value = false
108
- const err = e as { statusCode?: number; data?: { error?: { message?: string } } }
109
- const serverMessage = err?.data?.error?.message
110
- probeError.value = {
111
- status: err?.statusCode ?? null,
112
- message: serverMessage || (e instanceof Error ? e.message : String(e)),
113
- }
114
- sources.value = []
115
- connections.value = []
116
- }
117
- }
118
-
119
68
  /**
120
69
  * Run a live setup check for a source (authenticate + read), caching the verdict
121
70
  * so the panel can show exactly what's wrong (missing App / wrong token / lacking
@@ -137,14 +86,14 @@ export const useTasksStore = defineStore('tasks', () => {
137
86
  /** Connect the workspace to a source with its credential bag. */
138
87
  async function connect(source: TaskSourceKind, credentials: Record<string, string>) {
139
88
  const conn = await api.connectTaskSource(workspace.requireId(), source, credentials)
140
- upsertConnection(conn)
89
+ integration.upsertConnection(conn)
141
90
  available.value = true
142
91
  }
143
92
 
144
93
  /** Disconnect the workspace from a source. */
145
94
  async function disconnect(source: TaskSourceKind) {
146
95
  await api.disconnectTaskSource(workspace.requireId(), source)
147
- connections.value = connections.value.filter((c) => c.source !== source)
96
+ integration.removeConnection(source)
148
97
  }
149
98
 
150
99
  /** Enable or disable a source for the workspace (the per-workspace toggle). */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cat-factory/app",
3
- "version": "0.37.0",
3
+ "version": "0.37.1",
4
4
  "description": "Reusable Nuxt layer for the Agent Architecture Board SPA (components, stores, composables, pages). Consume it from a thin deployment app via `extends: ['@cat-factory/app']` and point it at your backend with NUXT_PUBLIC_API_BASE. See deploy/frontend for an example.",
5
5
  "repository": {
6
6
  "type": "git",