@things-factory/ai-assistant 10.1.6

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 (164) hide show
  1. package/client/components/board-ai-chat.test.ts +149 -0
  2. package/client/components/board-ai-chat.ts +3228 -0
  3. package/client/components/chat-defaults.ts +27 -0
  4. package/client/components/chat-echo-dedup.test.ts +230 -0
  5. package/client/components/chat-echo-dedup.ts +155 -0
  6. package/client/components/chat-input-builder.ts +69 -0
  7. package/client/components/markdown.test.ts +518 -0
  8. package/client/components/markdown.ts +508 -0
  9. package/client/components/mention-popup-helpers.ts +433 -0
  10. package/client/components/mention-popup.test.ts +588 -0
  11. package/client/components/mention-popup.ts +238 -0
  12. package/client/index.ts +14 -0
  13. package/client/tsconfig.json +13 -0
  14. package/client/utils/board-edit-patch.ts +374 -0
  15. package/dist-client/components/board-ai-chat.d.ts +423 -0
  16. package/dist-client/components/board-ai-chat.js +3034 -0
  17. package/dist-client/components/board-ai-chat.js.map +1 -0
  18. package/dist-client/components/board-ai-chat.test.d.ts +1 -0
  19. package/dist-client/components/board-ai-chat.test.js +138 -0
  20. package/dist-client/components/board-ai-chat.test.js.map +1 -0
  21. package/dist-client/components/chat-defaults.d.ts +8 -0
  22. package/dist-client/components/chat-defaults.js +22 -0
  23. package/dist-client/components/chat-defaults.js.map +1 -0
  24. package/dist-client/components/chat-echo-dedup.d.ts +54 -0
  25. package/dist-client/components/chat-echo-dedup.js +115 -0
  26. package/dist-client/components/chat-echo-dedup.js.map +1 -0
  27. package/dist-client/components/chat-echo-dedup.test.d.ts +1 -0
  28. package/dist-client/components/chat-echo-dedup.test.js +203 -0
  29. package/dist-client/components/chat-echo-dedup.test.js.map +1 -0
  30. package/dist-client/components/chat-input-builder.d.ts +55 -0
  31. package/dist-client/components/chat-input-builder.js +32 -0
  32. package/dist-client/components/chat-input-builder.js.map +1 -0
  33. package/dist-client/components/markdown.d.ts +60 -0
  34. package/dist-client/components/markdown.js +422 -0
  35. package/dist-client/components/markdown.js.map +1 -0
  36. package/dist-client/components/markdown.test.d.ts +1 -0
  37. package/dist-client/components/markdown.test.js +418 -0
  38. package/dist-client/components/markdown.test.js.map +1 -0
  39. package/dist-client/components/mention-popup-helpers.d.ts +165 -0
  40. package/dist-client/components/mention-popup-helpers.js +330 -0
  41. package/dist-client/components/mention-popup-helpers.js.map +1 -0
  42. package/dist-client/components/mention-popup.d.ts +35 -0
  43. package/dist-client/components/mention-popup.js +223 -0
  44. package/dist-client/components/mention-popup.js.map +1 -0
  45. package/dist-client/components/mention-popup.test.d.ts +1 -0
  46. package/dist-client/components/mention-popup.test.js +474 -0
  47. package/dist-client/components/mention-popup.test.js.map +1 -0
  48. package/dist-client/index.d.ts +14 -0
  49. package/dist-client/index.js +15 -0
  50. package/dist-client/index.js.map +1 -0
  51. package/dist-client/tsconfig.tsbuildinfo +1 -0
  52. package/dist-client/utils/board-edit-patch.d.ts +157 -0
  53. package/dist-client/utils/board-edit-patch.js +284 -0
  54. package/dist-client/utils/board-edit-patch.js.map +1 -0
  55. package/dist-server/index.d.ts +28 -0
  56. package/dist-server/index.js +32 -0
  57. package/dist-server/index.js.map +1 -0
  58. package/dist-server/service/chat-message/chat-message-broadcast.d.ts +13 -0
  59. package/dist-server/service/chat-message/chat-message-broadcast.js +13 -0
  60. package/dist-server/service/chat-message/chat-message-broadcast.js.map +1 -0
  61. package/dist-server/service/chat-message/chat-message-publish.d.ts +6 -0
  62. package/dist-server/service/chat-message/chat-message-publish.js +27 -0
  63. package/dist-server/service/chat-message/chat-message-publish.js.map +1 -0
  64. package/dist-server/service/chat-message/chat-message-subscription.d.ts +8 -0
  65. package/dist-server/service/chat-message/chat-message-subscription.js +57 -0
  66. package/dist-server/service/chat-message/chat-message-subscription.js.map +1 -0
  67. package/dist-server/service/chat-message/chat-message.d.ts +68 -0
  68. package/dist-server/service/chat-message/chat-message.js +172 -0
  69. package/dist-server/service/chat-message/chat-message.js.map +1 -0
  70. package/dist-server/service/chat-message/fold-history.d.ts +43 -0
  71. package/dist-server/service/chat-message/fold-history.js +33 -0
  72. package/dist-server/service/chat-message/fold-history.js.map +1 -0
  73. package/dist-server/service/chat-message/history-summary.d.ts +43 -0
  74. package/dist-server/service/chat-message/history-summary.js +77 -0
  75. package/dist-server/service/chat-message/history-summary.js.map +1 -0
  76. package/dist-server/service/chat-message/index.d.ts +5 -0
  77. package/dist-server/service/chat-message/index.js +12 -0
  78. package/dist-server/service/chat-message/index.js.map +1 -0
  79. package/dist-server/service/chat-message/llm-history.d.ts +46 -0
  80. package/dist-server/service/chat-message/llm-history.js +87 -0
  81. package/dist-server/service/chat-message/llm-history.js.map +1 -0
  82. package/dist-server/service/chat-session/chat-session.d.ts +60 -0
  83. package/dist-server/service/chat-session/chat-session.js +159 -0
  84. package/dist-server/service/chat-session/chat-session.js.map +1 -0
  85. package/dist-server/service/chat-session/index.d.ts +3 -0
  86. package/dist-server/service/chat-session/index.js +7 -0
  87. package/dist-server/service/chat-session/index.js.map +1 -0
  88. package/dist-server/service/chat-session/session-activity-broadcast.d.ts +30 -0
  89. package/dist-server/service/chat-session/session-activity-broadcast.js +34 -0
  90. package/dist-server/service/chat-session/session-activity-broadcast.js.map +1 -0
  91. package/dist-server/service/chat-session/session-activity-publish.d.ts +12 -0
  92. package/dist-server/service/chat-session/session-activity-publish.js +37 -0
  93. package/dist-server/service/chat-session/session-activity-publish.js.map +1 -0
  94. package/dist-server/service/chat-session/session-activity-subscription.d.ts +14 -0
  95. package/dist-server/service/chat-session/session-activity-subscription.js +87 -0
  96. package/dist-server/service/chat-session/session-activity-subscription.js.map +1 -0
  97. package/dist-server/service/chat-session/session-anchor.d.ts +26 -0
  98. package/dist-server/service/chat-session/session-anchor.js +100 -0
  99. package/dist-server/service/chat-session/session-anchor.js.map +1 -0
  100. package/dist-server/service/chat-session/session-inbox.d.ts +80 -0
  101. package/dist-server/service/chat-session/session-inbox.js +133 -0
  102. package/dist-server/service/chat-session/session-inbox.js.map +1 -0
  103. package/dist-server/service/chat-session-participant/chat-session-participant.d.ts +49 -0
  104. package/dist-server/service/chat-session-participant/chat-session-participant.js +141 -0
  105. package/dist-server/service/chat-session-participant/chat-session-participant.js.map +1 -0
  106. package/dist-server/service/chat-session-participant/index.d.ts +4 -0
  107. package/dist-server/service/chat-session-participant/index.js +10 -0
  108. package/dist-server/service/chat-session-participant/index.js.map +1 -0
  109. package/dist-server/service/chat-session-participant/invite-notification.d.ts +27 -0
  110. package/dist-server/service/chat-session-participant/invite-notification.js +30 -0
  111. package/dist-server/service/chat-session-participant/invite-notification.js.map +1 -0
  112. package/dist-server/service/chat-session-participant/presence.d.ts +4 -0
  113. package/dist-server/service/chat-session-participant/presence.js +22 -0
  114. package/dist-server/service/chat-session-participant/presence.js.map +1 -0
  115. package/dist-server/service/chat-session-resolver.d.ts +93 -0
  116. package/dist-server/service/chat-session-resolver.js +706 -0
  117. package/dist-server/service/chat-session-resolver.js.map +1 -0
  118. package/dist-server/service/index.d.ts +24 -0
  119. package/dist-server/service/index.js +39 -0
  120. package/dist-server/service/index.js.map +1 -0
  121. package/dist-server/service/subscribe-auth.d.ts +14 -0
  122. package/dist-server/service/subscribe-auth.js +33 -0
  123. package/dist-server/service/subscribe-auth.js.map +1 -0
  124. package/dist-server/service/types.d.ts +5 -0
  125. package/dist-server/service/types.js +12 -0
  126. package/dist-server/service/types.js.map +1 -0
  127. package/dist-server/tsconfig.tsbuildinfo +1 -0
  128. package/package.json +48 -0
  129. package/server/index.ts +28 -0
  130. package/server/service/chat-message/chat-message-broadcast.integration.test.ts +73 -0
  131. package/server/service/chat-message/chat-message-broadcast.test.ts +40 -0
  132. package/server/service/chat-message/chat-message-broadcast.ts +27 -0
  133. package/server/service/chat-message/chat-message-publish.ts +22 -0
  134. package/server/service/chat-message/chat-message-subscription.ts +45 -0
  135. package/server/service/chat-message/chat-message.ts +195 -0
  136. package/server/service/chat-message/fold-history.test.ts +98 -0
  137. package/server/service/chat-message/fold-history.ts +77 -0
  138. package/server/service/chat-message/history-summary.test.ts +127 -0
  139. package/server/service/chat-message/history-summary.ts +100 -0
  140. package/server/service/chat-message/index.ts +12 -0
  141. package/server/service/chat-message/llm-history.test.ts +194 -0
  142. package/server/service/chat-message/llm-history.ts +163 -0
  143. package/server/service/chat-session/chat-session.ts +193 -0
  144. package/server/service/chat-session/index.ts +5 -0
  145. package/server/service/chat-session/session-activity-broadcast.ts +57 -0
  146. package/server/service/chat-session/session-activity-publish.ts +44 -0
  147. package/server/service/chat-session/session-activity-subscription.ts +67 -0
  148. package/server/service/chat-session/session-anchor.ts +106 -0
  149. package/server/service/chat-session/session-inbox.test.ts +185 -0
  150. package/server/service/chat-session/session-inbox.ts +161 -0
  151. package/server/service/chat-session-participant/chat-session-participant.ts +135 -0
  152. package/server/service/chat-session-participant/index.ts +6 -0
  153. package/server/service/chat-session-participant/invite-notification.test.ts +30 -0
  154. package/server/service/chat-session-participant/invite-notification.ts +48 -0
  155. package/server/service/chat-session-participant/presence.test.ts +39 -0
  156. package/server/service/chat-session-participant/presence.ts +21 -0
  157. package/server/service/chat-session-resolver.ts +644 -0
  158. package/server/service/index.ts +37 -0
  159. package/server/service/session-anchor.test.ts +133 -0
  160. package/server/service/subscribe-auth.test.ts +57 -0
  161. package/server/service/subscribe-auth.ts +34 -0
  162. package/server/service/types.ts +15 -0
  163. package/things-factory.config.js +1 -0
  164. package/tsconfig.json +9 -0
@@ -0,0 +1,193 @@
1
+ /**
2
+ * ChatSession — Board 와 결합되는 AI 협력 세션.
3
+ *
4
+ * 한 보드에 여러 세션 가능 — DB 레벨 unique 제약 없음.
5
+ *
6
+ * 향후 폭증할 세션 분화 차원 (예: thread / 사용자별 / 컨텍스트별 design vs ops /
7
+ * archived / forked from clone / agent별) 을 미리 차단하지 않기 위해 구조 자체는 1:N
8
+ * 으로 열어둠. 단일 세션 정책 (현재 UX) 은 app 레벨 idempotent 로직 — `startBoardAISession`
9
+ * 이 boardId 매칭 시 기존 세션 반환, `chatSessionByBoard` 가 첫 매칭 반환.
10
+ *
11
+ * 미래에 다중 세션 활성화 시 discriminator 컬럼 (kind / name / threadId / creatorId 등)
12
+ * 을 그때 추가. 마이그레이션 부담 최소화 — 현재는 unique 만 풀어 자리 마련.
13
+ *
14
+ * board-service 의 Board 가 owning side 가 될 예정 (ChatSession FK).
15
+ */
16
+ import {
17
+ Column,
18
+ CreateDateColumn,
19
+ DeleteDateColumn,
20
+ Entity,
21
+ Index,
22
+ ManyToOne,
23
+ OneToMany,
24
+ PrimaryGeneratedColumn,
25
+ RelationId,
26
+ UpdateDateColumn
27
+ } from 'typeorm'
28
+ import { Field, ID, ObjectType } from 'type-graphql'
29
+
30
+ import { Domain } from '@things-factory/shell'
31
+ import { User } from '@things-factory/auth-base'
32
+ import { config } from '@things-factory/env'
33
+
34
+ import { ChatMessage } from '../chat-message/chat-message.js'
35
+
36
+ const ORMCONFIG = config.get('ormconfig', {})
37
+ const DATABASE_TYPE = ORMCONFIG.type
38
+
39
+ @Entity()
40
+ // 인덱스는 조회 성능용으로 유지 (chatSessionByBoard 가 [domain, boardId] 로 자주 쿼리).
41
+ // unique 제약은 의도적으로 제거 — 한 보드에 여러 세션을 미래에 허용하기 위함.
42
+ @Index('ix_chat_session_1', (session: ChatSession) => [session.domain, session.boardId])
43
+ // 앵커 조회용 — chatSessionsByAnchor 가 [domain, anchorType, anchorId] 로 질의.
44
+ @Index('ix_chat_session_2', (session: ChatSession) => [session.domain, session.anchorType, session.anchorId])
45
+ @ObjectType({
46
+ description: 'AI 협력 세션 — Board 와 결합. 한 보드에 여러 세션 가능 (thread / 사용자별 / 컨텍스트별 등 미래 확장).'
47
+ })
48
+ export class ChatSession {
49
+ @PrimaryGeneratedColumn('uuid')
50
+ @Field(type => ID, { nullable: true })
51
+ readonly id?: string
52
+
53
+ @ManyToOne(type => Domain)
54
+ @Field(type => Domain, { nullable: true })
55
+ domain?: Domain
56
+
57
+ @RelationId((session: ChatSession) => session.domain)
58
+ domainId?: string
59
+
60
+ /** Board.id 와 연결. Board 가 owning side 가 될 예정. 1:N — 한 보드에 여러 세션 가능.
61
+ *
62
+ * **앵커 일반화 이후에도 유지되는 하위호환 컬럼**이다(제거하지 말 것). 앵커가 board 인 세션은
63
+ * anchorId 와 함께 이 컬럼에도 같은 값을 쓴다(dual-write) — 기존 조회/인덱스/소비자가 그대로 동작한다. */
64
+ @Column({ nullable: true })
65
+ @Field({
66
+ nullable: true,
67
+ description:
68
+ 'Connected Board id. Multiple sessions per board allowed (future: threads / per-user / contexts). Kept for backward compatibility — board-anchored sessions dual-write this alongside anchorId.'
69
+ })
70
+ boardId?: string
71
+
72
+ /**
73
+ * 대화가 매달린 대상의 **종류**. 세션은 보드에만 매달리지 않는다 —
74
+ * 저작 대화는 보드에, 운영 대화는 공간(사이트)에 매달린다. 앵커가 대화의 성격과 이력 경계를 정한다.
75
+ *
76
+ * 'board' | 'space' | 'instance' | 'none'. 미설정(기존 데이터)은 boardId 가 있으면 board 로 읽는다 —
77
+ * 백필 마이그레이션 없이 lazy 하위호환(컬럼 추가만).
78
+ */
79
+ @Column({ nullable: true })
80
+ @Field({
81
+ nullable: true,
82
+ description:
83
+ "What this conversation is anchored to: 'board' (authoring a board) | 'space' (a site and the twins running there) | 'instance' | 'none'. Absent on legacy rows — treated as 'board' when boardId is present."
84
+ })
85
+ anchorType?: string
86
+
87
+ /** 앵커 대상의 id(보드 id · 공간 id · 인스턴스 id). anchorType 과 함께 의미를 갖는다. */
88
+ @Column({ nullable: true })
89
+ @Field({ nullable: true, description: 'Id of the anchored target (board id / space id / instance id).' })
90
+ anchorId?: string
91
+
92
+ /**
93
+ * 세션의 사용자 정의 이름 (탭 레이블 / 세션 식별 표시용).
94
+ *
95
+ * nullable — 미설정 시 UI 가 fallback 라벨 ("세션 N" 또는 createdAt 기반) 사용. 사용자가
96
+ * 다중 세션 운영 시 의미 부여 (예: "초기 설계", "운영 시뮬레이션", "사용자A 의 사적 세션").
97
+ */
98
+ @Column({ nullable: true, length: 200 })
99
+ @Field({ nullable: true, description: 'User-given name of this session (tab label / identification).' })
100
+ name?: string
101
+
102
+ @ManyToOne(type => User)
103
+ @Field(type => User, { nullable: true, description: 'User who created this session.' })
104
+ creator?: User
105
+
106
+ @RelationId((session: ChatSession) => session.creator)
107
+ creatorId?: string
108
+
109
+ @ManyToOne(type => User)
110
+ @Field(type => User, { nullable: true, description: 'User who last updated this session.' })
111
+ updater?: User
112
+
113
+ @RelationId((session: ChatSession) => session.updater)
114
+ updaterId?: string
115
+
116
+ /** 토큰 절감용 — 오래된 메시지를 LLM 으로 압축한 요약. Phase 2 에서 작성. */
117
+ /**
118
+ * 마지막 메시지 시각 — **비정규화**. 목록 정렬(최근 활동순)과 안 읽음 판정의 기준.
119
+ *
120
+ * 왜 비정규화인가: 세션마다 메시지를 집계하면(MAX/COUNT + group by) 목록 한 번에 집계 쿼리가 붙고,
121
+ * 이 프레임워크는 5개 DB 드라이버를 지원해야 한다. 세션 컬럼 하나로 두면 정렬·비교가 값싸게 끝나고
122
+ * 드라이버 차이가 없다. 메시지를 저장할 때 함께 갱신한다(activityPatch).
123
+ */
124
+ @Column({ nullable: true })
125
+ @Field({ nullable: true, description: 'Timestamp of the last message in this session (denormalized for list ordering and unread comparison — avoids per-session aggregation across the supported DB drivers).' })
126
+ lastMessageAt?: Date
127
+
128
+ /** 목록에 보여줄 마지막 메시지 미리보기 — 목록에서 메시지 테이블을 다시 읽지 않기 위해 짧게 저장. */
129
+ @Column({ nullable: true, length: 200 })
130
+ @Field({ nullable: true, description: 'Short preview of the last message, stored so the session list does not need to query messages.' })
131
+ lastMessagePreview?: string
132
+
133
+ @Column({
134
+ nullable: true,
135
+ type:
136
+ DATABASE_TYPE == 'mysql' || DATABASE_TYPE == 'mariadb'
137
+ ? 'longtext'
138
+ : DATABASE_TYPE == 'oracle'
139
+ ? 'clob'
140
+ : DATABASE_TYPE == 'mssql'
141
+ ? 'nvarchar'
142
+ : 'text',
143
+ length: DATABASE_TYPE == 'mssql' ? 'MAX' : undefined
144
+ })
145
+ @Field({ nullable: true, description: 'Compressed summary of older messages (for token saving).' })
146
+ lastSummary?: string
147
+
148
+ /**
149
+ * `lastSummary` 가 **어디까지 덮는지** — 이 메시지까지 요약에 반영됐다.
150
+ *
151
+ * 이게 없으면 누적 요약을 할 수 없다: 매 턴 전체를 다시 요약하거나(비용) 이미 요약한 구간을
152
+ * 또 이어 붙인다(중복). 이력에서 이 id 를 못 찾으면(삭제·편집) 덮은 범위를 알 수 없으므로
153
+ * 전부 미반영으로 보고 다시 요약한다 — 빠뜨리는 쪽보다 안전하다.
154
+ */
155
+ @Column({ nullable: true })
156
+ @Field({ nullable: true, description: 'Id of the last message folded into lastSummary — enables incremental summarization instead of re-reading the whole history.' })
157
+ summaryUpToMessageId?: string
158
+
159
+ /** 사용 모델 식별 — 'anthropic:claude-sonnet-4-6' 등 */
160
+ @Column({ nullable: true })
161
+ @Field({ nullable: true })
162
+ aiClientId?: string
163
+
164
+ @CreateDateColumn()
165
+ @Field({ nullable: true })
166
+ createdAt?: Date
167
+
168
+ @UpdateDateColumn()
169
+ @Field({ nullable: true })
170
+ updatedAt?: Date
171
+
172
+ @DeleteDateColumn()
173
+ deletedAt?: Date
174
+
175
+ // ── 관계 (TypeORM 만, GraphQL 노출은 별도 query 통해 페이징) ──
176
+ @OneToMany(type => ChatMessage, message => message.session)
177
+ messages?: ChatMessage[]
178
+
179
+ /*
180
+ * ── 보드 패치 관계를 끊었다 (2026-09-10) ──────────────────────────────────
181
+ * 여기 `@OneToMany(PatchEntry)` 가 있었다. 그래서 **대화 세션이 보드 편집기를 알아야** 했고,
182
+ * 세션 계열을 중립 패키지로 옮기려는 순간 `ai-assistant → board-ai → ai-assistant` 순환이
183
+ * 된다.
184
+ *
185
+ * 끊어도 잃는 것이 없다 — 이 관계를 **자료로 읽는 자리가 하나도 없었다**(전 저장소에서
186
+ * `session.patches` 를 읽는 코드 0 건). 유일한 쓰임이 `PatchEntry` 쪽의 역방향 선언 한 줄이었고,
187
+ * TypeORM 은 역방향 없이도 `@ManyToOne` 을 만든다. 패치는 `sessionId` 로 찾는다
188
+ * (`chatPatches` 가 이미 그렇게 한다).
189
+ *
190
+ * 방향은 남는다: **보드 패치가 세션을 안다. 세션은 보드 패치를 모른다.** 종속이 한쪽이면
191
+ * 중립 쪽이 옮겨질 수 있다.
192
+ */
193
+ }
@@ -0,0 +1,5 @@
1
+ import { ChatSession } from './chat-session.js'
2
+
3
+ export { ChatSession }
4
+
5
+ export const entities = [ChatSession]
@@ -0,0 +1,57 @@
1
+ /*
2
+ * 세션 활동 방송 — 순수 로직(토픽·페이로드·필터). 데코레이터·pubsub import 없음 → 단위 테스트 가능.
3
+ *
4
+ * ── 왜 메시지 방송(chat-message)과 따로 두는가 ──────────────────────────────
5
+ * 기존 `chatMessageActivity` 는 **그 세션을 열어 둔** 구독자에게 메시지 본문을 보낸다. 그래서 창을 닫아
6
+ * 두면 새 메시지를 알 수 없다 — 초대 알림 한 번 받고 나면 이후 대화는 사용자가 스스로 들어와 봐야 안다.
7
+ *
8
+ * 이 토픽은 **내가 참여한 모든 세션**의 활동을 알린다. 목적이 배지·목록 갱신이므로 본문을 싣지 않고
9
+ * 최소 정보(세션·앵커·시각·미리보기)만 보낸다:
10
+ * · 열지도 않은 세션들의 본문이 한 채널로 쏟아지는 것을 피한다(대역·주의 낭비).
11
+ * · 본문이 필요한 시점은 그 세션을 열 때이고, 그때는 기존 메시지 구독이 담당한다.
12
+ *
13
+ * ── 구독자 판정을 필터에서 DB 로 하지 않는다 ────────────────────────────────
14
+ * 필터는 **구독자 × 메시지마다** 돈다. 여기서 DB 를 읽으면 방송 한 번이 구독자 수만큼의 질의가 된다.
15
+ * 그래서 발행 시점에 참여자 id 목록을 페이로드에 실어 보내고, 필터는 배열 포함 검사만 한다.
16
+ * 참여자 조회는 발행당 1회 — 사람 대화 빈도에서 감당 가능한 비용이다.
17
+ */
18
+
19
+ /** 세션 활동 방송 토픽. */
20
+ export const SESSION_ACTIVITY_TOPIC = 'board-ai-session-activity'
21
+
22
+ /** 방송 페이로드 — 필터용 메타(도메인·참여자) + 목록 갱신용 최소 정보. 본문은 싣지 않는다. */
23
+ /**
24
+ * 활동 종류.
25
+ * message — 새 메시지. **안 읽음 배지를 올린다.**
26
+ * meta — 이름 변경·참여자 변동 등 세션 자체의 변화. 목록은 갱신하지만 배지는 올리지 않는다.
27
+ * (이름이 바뀐 것을 "새 소식" 으로 알리면 소음이 된다.)
28
+ */
29
+ export type SessionActivityKind = 'message' | 'meta'
30
+
31
+ export interface SessionActivityPayload {
32
+ domainId: string
33
+ sessionId: string
34
+ kind: SessionActivityKind
35
+ /** 이 세션 참여자 user id — 필터가 이 배열로 수신자를 정한다(필터에서 DB 를 읽지 않기 위함). */
36
+ participantIds: string[]
37
+ anchorType?: string
38
+ anchorId?: string
39
+ /** 활동 시각(ISO) — 클라가 목록 정렬·안 읽음 판정에 쓴다. */
40
+ at: string
41
+ /** 한 줄 미리보기(짧게). 본문 전체가 아니다. */
42
+ preview?: string
43
+ /** 이 활동을 만든 사용자 — 자기 발신은 배지를 올리지 않도록 클라가 쓴다. */
44
+ actorId?: string
45
+ }
46
+
47
+ /**
48
+ * 같은 도메인 + **내가 참여자인 세션**의 활동만 통과. 도메인 격리가 보안 핵심이고,
49
+ * 참여자 검사가 "내 것만" 을 성립시킨다(참여하지 않은 대화의 활동을 받지 않는다).
50
+ */
51
+ export function sessionActivityFilter(domainId: string, userId: string) {
52
+ return (payload: SessionActivityPayload): boolean =>
53
+ !!payload &&
54
+ payload.domainId === domainId &&
55
+ Array.isArray(payload.participantIds) &&
56
+ payload.participantIds.includes(userId)
57
+ }
@@ -0,0 +1,44 @@
1
+ /*
2
+ * 세션 활동 발행 — pubsub 의존만(데코레이터 없음). 순수 로직은 session-activity-broadcast.ts.
3
+ *
4
+ * 참여자 목록을 여기서 읽어 페이로드에 싣는다 — 필터는 구독자 × 메시지마다 돌기 때문에 거기서 DB 를
5
+ * 읽으면 방송 한 번이 구독자 수만큼의 질의가 된다. 발행당 1회로 끝낸다.
6
+ *
7
+ * best-effort: 실패해도 대화·저장을 막지 않는다(배지가 한 번 늦을 뿐).
8
+ */
9
+ import { pubsub, getRepository } from '@things-factory/shell'
10
+
11
+ import { ChatSessionParticipant } from '../chat-session-participant/chat-session-participant.js'
12
+ import { SESSION_ACTIVITY_TOPIC, type SessionActivityPayload, type SessionActivityKind } from './session-activity-broadcast.js'
13
+
14
+ export async function publishSessionActivity(input: {
15
+ domainId: string
16
+ sessionId: string
17
+ /** message = 새 메시지(배지 올림) · meta = 이름 변경 등(목록만 갱신). 기본 message. */
18
+ kind?: SessionActivityKind
19
+ anchorType?: string
20
+ anchorId?: string
21
+ at?: Date
22
+ preview?: string
23
+ actorId?: string
24
+ }): Promise<void> {
25
+ try {
26
+ const participants = await getRepository(ChatSessionParticipant).find({
27
+ where: { domain: { id: input.domainId } as any, session: { id: input.sessionId } as any }
28
+ })
29
+ const payload: SessionActivityPayload = {
30
+ domainId: input.domainId,
31
+ sessionId: input.sessionId,
32
+ kind: input.kind ?? 'message',
33
+ participantIds: participants.map(p => (p as any).userId).filter(Boolean),
34
+ anchorType: input.anchorType,
35
+ anchorId: input.anchorId,
36
+ at: (input.at ?? new Date()).toISOString(),
37
+ preview: input.preview,
38
+ actorId: input.actorId
39
+ }
40
+ pubsub.publish(SESSION_ACTIVITY_TOPIC, payload)
41
+ } catch {
42
+ /* 방송 실패는 대화를 막지 않는다 */
43
+ }
44
+ }
@@ -0,0 +1,67 @@
1
+ /*
2
+ * MySessionActivitySubscription — **내가 참여한 모든 세션**의 활동 알림.
3
+ *
4
+ * 기존 chatMessageActivity 는 그 세션을 열어 둔 구독자에게 본문을 보낸다 → 창을 닫아 두면 새 메시지를
5
+ * 알 수 없었다. 이 구독은 배지·목록 갱신용으로 최소 정보만 보낸다(본문 없음).
6
+ *
7
+ * 도메인 격리는 필터가, "내 것만" 은 참여자 검사가 성립시킨다(session-activity-broadcast.ts).
8
+ */
9
+ import { Resolver, Subscription, Root, ObjectType, Field } from 'type-graphql'
10
+ import { filter, pipe } from 'graphql-yoga'
11
+ import { pubsub } from '@things-factory/shell'
12
+
13
+ import { SESSION_ACTIVITY_TOPIC, sessionActivityFilter, type SessionActivityPayload } from './session-activity-broadcast.js'
14
+ import { assertDomainSubscribeAllowed } from '../subscribe-auth.js'
15
+
16
+ export { publishSessionActivity } from './session-activity-publish.js'
17
+
18
+ @ObjectType({ description: 'Activity notice for a session the current user participates in — badge/list refresh only, no message body.' })
19
+ export class SessionActivity {
20
+ @Field({ description: 'Session that had activity.' })
21
+ sessionId!: string
22
+
23
+ @Field({
24
+ description:
25
+ "Kind of activity: 'message' (new message — raise the unread badge) or 'meta' (session itself changed, e.g. renamed — refresh the list but do not badge)."
26
+ })
27
+ kind!: string
28
+
29
+ @Field({ nullable: true, description: "What the session is anchored to: 'board' | 'space' | 'instance' | 'none'." })
30
+ anchorType?: string
31
+
32
+ @Field({ nullable: true, description: 'Id of the anchored target.' })
33
+ anchorId?: string
34
+
35
+ @Field({ description: 'Activity timestamp (ISO).' })
36
+ at!: string
37
+
38
+ @Field({ nullable: true, description: 'One-line preview (not the full body).' })
39
+ preview?: string
40
+
41
+ @Field({ nullable: true, description: 'User who caused the activity — clients skip badging their own.' })
42
+ actorId?: string
43
+ }
44
+
45
+ @Resolver(SessionActivity)
46
+ export class MySessionActivitySubscription {
47
+ @Subscription(() => SessionActivity, {
48
+ description:
49
+ 'Subscribes to activity in every session the current user participates in, so a closed chat surface can still show unread badges and refresh its list.',
50
+ subscribe: async ({ context }) => {
51
+ const { domain, user } = context.state
52
+ await assertDomainSubscribeAllowed(domain, user, (d, u) => process.superUserGranted(d, u))
53
+ return pipe(pubsub.subscribe(SESSION_ACTIVITY_TOPIC), filter(sessionActivityFilter(domain.id, user.id)))
54
+ }
55
+ })
56
+ mySessionActivity(@Root() payload: SessionActivityPayload): SessionActivity {
57
+ return {
58
+ sessionId: payload.sessionId,
59
+ kind: payload.kind ?? 'message',
60
+ anchorType: payload.anchorType,
61
+ anchorId: payload.anchorId,
62
+ at: payload.at,
63
+ preview: payload.preview,
64
+ actorId: payload.actorId
65
+ }
66
+ }
67
+ }
@@ -0,0 +1,106 @@
1
+ /*
2
+ * 세션 앵커 순수 로직 — 리졸버와 분리.
3
+ *
4
+ * chat-session-resolver 는 type-graphql 의 **매개변수 데코레이터**(@Arg)를 쓰므로 jest 의 babel
5
+ * 파서가 읽지 못한다. 순수 함수를 여기 두면 단위 테스트가 가능하다(chat-echo-dedup ·
6
+ * chat-input-builder 와 같은 관례).
7
+ *
8
+ * **앵커란**: 대화가 무엇에 매달려 있는지. 저작 대화는 보드에, 운영 대화는 공간(사이트와 그 공간에서
9
+ * 도는 트윈들)에 매달린다. 앵커가 대화의 성격과 **이력 경계**를 정한다 — 보드 편집 이력에 운영
10
+ * 문답이 끼지 않아야 하고, 그 반대도 마찬가지다.
11
+ *
12
+ * **하위호환**: 기존 행은 앵커 컬럼이 비어 있고 boardId 만 있다. 백필하지 않고 **읽을 때** board
13
+ * 앵커로 간주한다(컬럼 추가만으로 끝내기 위함). 보드 앵커 세션은 anchorId 와 boardId 를 함께
14
+ * 쓴다(dual-write) — 기존 조회·인덱스·소비자(operato-board 등)가 그대로 동작한다.
15
+ *
16
+ * ── Why this file validates instead of casting (2026-09-09) ──────────────────────
17
+ * `anchorColumns` used to do `(anchorType || 'none') as SessionAnchorType`. That is a cast, not a
18
+ * check: the type existed and nothing enforced it, so any string reached a nullable varchar and
19
+ * was stored. The plant lane was about to add a product and stopped to ask which value to write,
20
+ * which is the only reason this was noticed — the column would have accepted whatever it sent.
21
+ *
22
+ * A wrong value here is not a wrong value in one row. The anchor decides which conversations are
23
+ * listed together and which history a reply may see, so a typo silently creates a second,
24
+ * invisible bucket: the session is written, the inbox query for the real anchor does not match
25
+ * it, and the conversation is simply gone from the list with no error anywhere.
26
+ *
27
+ * Two rules follow, and both are refusals rather than defaults:
28
+ *
29
+ * · An unknown anchor type throws. A default of 'none' would be worse than a typo, because it
30
+ * turns "you named something that does not exist" into "this conversation is about nothing".
31
+ * · Every anchor except 'none' requires an id. An anchor is a pointer; `('space', '')` points
32
+ * nowhere, and `anchorWhere` would then look for an empty string while `anchorColumns` stored
33
+ * NULL — the lookup misses its own row and every call creates another session.
34
+ *
35
+ * 'none' therefore means exactly one thing: **not addressable by anchor.** It is the UI state for
36
+ * "nothing picked yet" (twin's dock refuses to create a session while it holds 'none'), not a
37
+ * value a product writes because it has nothing narrower to point at. A product whose subject is
38
+ * the whole tenant anchors to 'domain' and holds the domain id — that conversation is listable and
39
+ * has a history boundary, and "unanchored" has neither.
40
+ *
41
+ * Adding a value to this set is an architect's call, not a lane's. A new anchor type means a new
42
+ * kind of subject a conversation can be about, and it is stored — once rows carry it, renaming it
43
+ * orphans them.
44
+ */
45
+
46
+ /**
47
+ * 대화가 매달릴 수 있는 것. **저장되는 값이므로 늘리는 것은 아키텍트 판단이다.**
48
+ *
49
+ * board 보드 저작 대화 — 그 보드의 편집 이력
50
+ * space 공간 운영 대화 — 사이트와 그 공간에서 도는 트윈들
51
+ * instance 트윈 하나에 대한 대화
52
+ * domain 테넌트 전체가 주제인 대화 — 공장 전체 문답처럼 더 좁게 가리킬 것이 없을 때
53
+ * none 앵커로 찾을 수 없는 대화. 「아직 아무것도 고르지 않았다」는 화면 상태다
54
+ */
55
+ export const SESSION_ANCHOR_TYPES = ['board', 'space', 'instance', 'domain', 'none'] as const
56
+
57
+ export type SessionAnchorType = (typeof SESSION_ANCHOR_TYPES)[number]
58
+
59
+ export function isSessionAnchorType(value: unknown): value is SessionAnchorType {
60
+ return typeof value === 'string' && (SESSION_ANCHOR_TYPES as readonly string[]).includes(value)
61
+ }
62
+
63
+ function assertAnchorType(anchorType: string | undefined, where: string): SessionAnchorType {
64
+ const type = anchorType || 'none'
65
+ if (!isSessionAnchorType(type)) {
66
+ throw new Error(
67
+ `[board-ai/session-anchor] ${where}: '${type}' is not an anchor type. ` +
68
+ `Allowed: ${SESSION_ANCHOR_TYPES.join(' · ')}. ` +
69
+ 'The anchor decides which conversations are listed together and which history a reply may see, ' +
70
+ 'so an unrecognised value would write a session the inbox can never find. ' +
71
+ 'If a new kind of subject is needed, raise it — this set is stored in rows.'
72
+ )
73
+ }
74
+ return type
75
+ }
76
+
77
+ /** 앵커 → 저장할 컬럼 값. 보드 앵커는 boardId 에도 같은 값을 넣어 기존 조회가 그대로 동작하게 한다. */
78
+ export function anchorColumns(
79
+ anchorType: string | undefined,
80
+ anchorId: string | undefined
81
+ ): { anchorType: SessionAnchorType; anchorId?: string; boardId?: string } {
82
+ const type = assertAnchorType(anchorType, 'anchorColumns')
83
+ const id = anchorId || undefined
84
+ if (type !== 'none' && !id) {
85
+ throw new Error(
86
+ `[board-ai/session-anchor] anchorColumns: anchor '${type}' was given no id. ` +
87
+ 'An anchor is a pointer, so this would store NULL while the lookup searches for the id it was ' +
88
+ 'given — the session would not be found by its own anchor and every call would create another. ' +
89
+ "Use 'none' for a conversation that is deliberately not addressable by anchor."
90
+ )
91
+ }
92
+ return { anchorType: type, anchorId: id, boardId: type === 'board' ? id : undefined }
93
+ }
94
+
95
+ /**
96
+ * 앵커 조회 조건. 보드 앵커는 **앵커 컬럼이 빈 옛 행도 잡아야** 하므로 boardId 로 찾는다
97
+ * (dual-write 덕분에 신규 행도 같은 조건에 걸린다). 그 밖 앵커는 앵커 컬럼으로 찾는다.
98
+ *
99
+ * 여기서도 앵커 이름을 검사한다 — 읽기 경로에서 틀린 이름은 오류가 아니라 **0 행**으로 나오고,
100
+ * 화면은 「대화가 없다」로 그린다. 그 증상으로는 원인을 못 찾는다.
101
+ */
102
+ export function anchorWhere(domainId: string, anchorType: string, anchorId: string): any {
103
+ const type = assertAnchorType(anchorType, 'anchorWhere')
104
+ if (type === 'board') return { boardId: anchorId, domain: { id: domainId } }
105
+ return { anchorType: type, anchorId, domain: { id: domainId } }
106
+ }
@@ -0,0 +1,185 @@
1
+ /*
2
+ * 받은편지함(세션 목록) 검증.
3
+ *
4
+ * 무엇을 지키는가:
5
+ * ① **최근 활동순** — 대화가 늘면 "어디로 돌아갈지"를 목록에서 찾는다. 활동이 없으면 생성순으로 떨어진다.
6
+ * ② **안 읽음 판정** — 세션의 마지막 활동이 내 읽은 지점보다 뒤일 때만. 참여자가 아니면 배지를 띄우지
7
+ * 않는다(내 것이 아닌 대화에 미읽음을 표시하면 소음이다).
8
+ * ③ **같은 시각은 안 읽음이 아니다** — 읽은 직후 같은 밀리초 활동을 미읽음으로 잡으면 방금 읽은 화면에
9
+ * 배지가 남는다.
10
+ * ④ **미리보기 정규화** — 목록 한 줄에 들어가야 하고 컬럼 길이 제한이 있다.
11
+ *
12
+ * 집계 쿼리를 쓰지 않는 설계라(5개 DB 드라이버) 판정이 전부 값 비교로 끝난다 — 그래서 순수 테스트가 된다.
13
+ */
14
+ import { buildInbox, countUnreadSessions, activityPatch, applyHidden, searchInbox } from './session-inbox'
15
+
16
+ const t = (iso: string) => new Date(iso)
17
+
18
+ const S = (id: string, lastMessageAt?: string, createdAt = '2026-07-01T00:00:00Z', name?: string) => ({
19
+ id,
20
+ name,
21
+ lastMessageAt: lastMessageAt ? t(lastMessageAt) : null,
22
+ createdAt: t(createdAt)
23
+ })
24
+
25
+ describe('정렬', () => {
26
+ it('최근 활동순 내림차순', () => {
27
+ const out = buildInbox(
28
+ [S('a', '2026-07-10T10:00:00Z'), S('b', '2026-07-12T10:00:00Z'), S('c', '2026-07-11T10:00:00Z')],
29
+ []
30
+ )
31
+ expect(out.map(e => e.id)).toEqual(['b', 'c', 'a'])
32
+ })
33
+
34
+ it('활동이 없으면 생성 시각으로 떨어진다 — 빈 대화가 목록 위를 차지하지 않는다', () => {
35
+ const out = buildInbox(
36
+ [S('old', undefined, '2026-07-01T00:00:00Z'), S('new', undefined, '2026-07-09T00:00:00Z'), S('active', '2026-07-05T00:00:00Z')],
37
+ []
38
+ )
39
+ expect(out.map(e => e.id)).toEqual(['new', 'active', 'old'])
40
+ })
41
+ })
42
+
43
+ describe('안 읽음 판정', () => {
44
+ it('활동이 읽은 지점보다 뒤면 안 읽음', () => {
45
+ const out = buildInbox([S('a', '2026-07-12T10:00:00Z')], [{ sessionId: 'a', lastReadAt: t('2026-07-12T09:00:00Z') }])
46
+ expect(out[0].unread).toBe(true)
47
+ })
48
+
49
+ it('읽은 지점이 활동보다 뒤면 읽음', () => {
50
+ const out = buildInbox([S('a', '2026-07-12T10:00:00Z')], [{ sessionId: 'a', lastReadAt: t('2026-07-12T11:00:00Z') }])
51
+ expect(out[0].unread).toBe(false)
52
+ })
53
+
54
+ it('같은 시각은 안 읽음이 아니다 — 방금 읽은 화면에 배지가 남지 않게', () => {
55
+ const same = '2026-07-12T10:00:00Z'
56
+ const out = buildInbox([S('a', same)], [{ sessionId: 'a', lastReadAt: t(same) }])
57
+ expect(out[0].unread).toBe(false)
58
+ })
59
+
60
+ it('참여자인데 한 번도 읽지 않았고 활동이 있으면 안 읽음(초대 직후)', () => {
61
+ const out = buildInbox([S('a', '2026-07-12T10:00:00Z')], [{ sessionId: 'a', lastReadAt: null }])
62
+ expect(out[0]).toMatchObject({ participant: true, unread: true })
63
+ })
64
+
65
+ it('참여자가 아니면 안 읽음으로 표시하지 않는다 — 내 것이 아닌 대화에 배지 금지', () => {
66
+ const out = buildInbox([S('a', '2026-07-12T10:00:00Z')], [])
67
+ expect(out[0]).toMatchObject({ participant: false, unread: false })
68
+ })
69
+
70
+ it('활동이 없는 빈 세션은 안 읽음이 아니다', () => {
71
+ const out = buildInbox([S('a')], [{ sessionId: 'a', lastReadAt: null }])
72
+ expect(out[0].unread).toBe(false)
73
+ })
74
+
75
+ it('문자열 시각(직렬화된 값)도 비교된다', () => {
76
+ const out = buildInbox(
77
+ [{ id: 'a', lastMessageAt: '2026-07-12T10:00:00Z', createdAt: '2026-07-01T00:00:00Z' }],
78
+ [{ sessionId: 'a', lastReadAt: '2026-07-12T09:00:00Z' }]
79
+ )
80
+ expect(out[0].unread).toBe(true)
81
+ })
82
+ })
83
+
84
+ describe('배지 수', () => {
85
+ it('안 읽은 **세션 수**를 센다(메시지 개수가 아니다)', () => {
86
+ const entries = buildInbox(
87
+ [S('a', '2026-07-12T10:00:00Z'), S('b', '2026-07-12T10:00:00Z'), S('c', '2026-07-12T10:00:00Z')],
88
+ [
89
+ { sessionId: 'a', lastReadAt: null },
90
+ { sessionId: 'b', lastReadAt: t('2026-07-12T11:00:00Z') },
91
+ { sessionId: 'c', lastReadAt: null }
92
+ ]
93
+ )
94
+ expect(countUnreadSessions(entries)).toBe(2)
95
+ })
96
+ })
97
+
98
+ describe('활동 갱신 값', () => {
99
+ it('미리보기는 개행·연속 공백을 한 칸으로 눌러 한 줄로 만든다', () => {
100
+ const { lastMessagePreview } = activityPatch('첫 줄\n\n 둘째 줄 ')
101
+ expect(lastMessagePreview).toBe('첫 줄 둘째 줄')
102
+ })
103
+
104
+ it('미리보기는 200자로 자른다 — 컬럼 길이 제한', () => {
105
+ const { lastMessagePreview } = activityPatch('가'.repeat(500))
106
+ expect(lastMessagePreview!.length).toBe(200)
107
+ })
108
+
109
+ it('시각을 주면 그것을 쓴다(테스트·백필 재현성)', () => {
110
+ const at = t('2026-07-12T10:00:00Z')
111
+ expect(activityPatch('x', at).lastMessageAt).toBe(at)
112
+ })
113
+
114
+ it('빈 본문도 안전하다', () => {
115
+ expect(activityPatch('').lastMessagePreview).toBe('')
116
+ })
117
+ })
118
+
119
+ describe('applyHidden — 내 목록에서만 숨기기', () => {
120
+ const entry = (id: string, lastMessageAt?: string): any => ({
121
+ id,
122
+ lastMessageAt,
123
+ unread: false,
124
+ participant: true
125
+ })
126
+
127
+ it('숨긴 대화는 기본 목록에서 빠진다', () => {
128
+ const out = applyHidden([entry('a', '2026-07-30T01:00:00Z'), entry('b')], new Map([['a', '2026-07-30T02:00:00Z']]))
129
+ expect(out.map(e => e.id)).toEqual(['b'])
130
+ })
131
+
132
+ it('숨긴 뒤 새 활동이 생기면 다시 보인다 — 참여 중인 대화에서 조용히 잘려 나가지 않게', () => {
133
+ const out = applyHidden([entry('a', '2026-07-30T03:00:00Z')], new Map([['a', '2026-07-30T02:00:00Z']]))
134
+ expect(out.map(e => e.id)).toEqual(['a'])
135
+ expect(out[0].hidden).toBeUndefined()
136
+ })
137
+
138
+ it('필터를 켜면 숨긴 것도 표시와 함께 돌려준다 — 되돌릴 수 있어야 한다', () => {
139
+ const out = applyHidden(
140
+ [entry('a', '2026-07-30T01:00:00Z'), entry('b')],
141
+ new Map([['a', '2026-07-30T02:00:00Z']]),
142
+ true
143
+ )
144
+ expect(out.map(e => e.id)).toEqual(['a', 'b'])
145
+ expect(out[0].hidden).toBe(true)
146
+ expect(out[1].hidden).toBeUndefined()
147
+ })
148
+
149
+ it('숨김 기록이 없으면 아무것도 바꾸지 않는다', () => {
150
+ const entries = [entry('a'), entry('b')]
151
+ expect(applyHidden(entries, new Map())).toEqual(entries)
152
+ })
153
+ })
154
+
155
+ describe('searchInbox — 대량 목록의 풀텍스트 필터', () => {
156
+ const rows: any[] = [
157
+ { id: 'a', name: 'A-DC 포화 원인', lastMessagePreview: '점유율이 92% 입니다', unread: false, participant: true },
158
+ { id: 'b', name: 'Dock 배치 검토', lastMessagePreview: '입고 도크를 늘리면', unread: false, participant: true },
159
+ { id: 'c', lastMessagePreview: '이름 없는 대화', unread: false, participant: true }
160
+ ]
161
+
162
+ it('이름으로 찾는다', () => {
163
+ expect(searchInbox(rows, '포화').map(e => e.id)).toEqual(['a'])
164
+ })
165
+
166
+ it('미리보기 본문으로도 찾는다 — 제목만으로는 돌아갈 대화를 못 찾는다', () => {
167
+ expect(searchInbox(rows, '입고').map(e => e.id)).toEqual(['b'])
168
+ })
169
+
170
+ it('대소문자를 무시한다', () => {
171
+ expect(searchInbox(rows, 'dock').map(e => e.id)).toEqual(['b'])
172
+ })
173
+
174
+ it('이름이 없는 대화도 미리보기로 걸린다', () => {
175
+ expect(searchInbox(rows, '이름 없는').map(e => e.id)).toEqual(['c'])
176
+ })
177
+
178
+ it('빈 검색어는 전체를 그대로 돌려준다(필터 없음)', () => {
179
+ expect(searchInbox(rows, ' ')).toBe(rows)
180
+ })
181
+
182
+ it('맞는 것이 없으면 빈 목록 — 조용히 전체를 보여주지 않는다', () => {
183
+ expect(searchInbox(rows, 'zzz')).toEqual([])
184
+ })
185
+ })