@things-factory/figure-service 10.1.3 → 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 (67) hide show
  1. package/coverage/clover.xml +6 -0
  2. package/coverage/coverage-final.json +1 -0
  3. package/coverage/lcov-report/base.css +224 -0
  4. package/coverage/lcov-report/block-navigation.js +87 -0
  5. package/coverage/lcov-report/favicon.png +0 -0
  6. package/coverage/lcov-report/index.html +101 -0
  7. package/coverage/lcov-report/prettify.css +1 -0
  8. package/coverage/lcov-report/prettify.js +2 -0
  9. package/coverage/lcov-report/sort-arrow-sprite.png +0 -0
  10. package/coverage/lcov-report/sorter.js +210 -0
  11. package/coverage/lcov.info +0 -0
  12. package/dist-server/index.d.ts +2 -0
  13. package/dist-server/index.js +13 -0
  14. package/dist-server/index.js.map +1 -1
  15. package/dist-server/routers/figure-thumbnail-router.d.ts +24 -0
  16. package/dist-server/routers/figure-thumbnail-router.js +93 -0
  17. package/dist-server/routers/figure-thumbnail-router.js.map +1 -0
  18. package/dist-server/routes.d.ts +1 -0
  19. package/dist-server/routes.js +21 -0
  20. package/dist-server/routes.js.map +1 -0
  21. package/dist-server/service/figure/event-subscriber.d.ts +21 -0
  22. package/dist-server/service/figure/event-subscriber.js +51 -0
  23. package/dist-server/service/figure/event-subscriber.js.map +1 -0
  24. package/dist-server/service/figure/figure-gate.d.ts +55 -0
  25. package/dist-server/service/figure/figure-gate.js +64 -0
  26. package/dist-server/service/figure/figure-gate.js.map +1 -0
  27. package/dist-server/service/figure/figure-history.d.ts +43 -0
  28. package/dist-server/service/figure/figure-history.js +155 -0
  29. package/dist-server/service/figure/figure-history.js.map +1 -0
  30. package/dist-server/service/figure/figure-mutation.d.ts +45 -0
  31. package/dist-server/service/figure/figure-mutation.js +174 -1
  32. package/dist-server/service/figure/figure-mutation.js.map +1 -1
  33. package/dist-server/service/figure/figure-query.d.ts +24 -1
  34. package/dist-server/service/figure/figure-query.js +75 -0
  35. package/dist-server/service/figure/figure-query.js.map +1 -1
  36. package/dist-server/service/figure/figure-tools.d.ts +10 -0
  37. package/dist-server/service/figure/figure-tools.js +304 -0
  38. package/dist-server/service/figure/figure-tools.js.map +1 -0
  39. package/dist-server/service/figure/figure-type.d.ts +25 -1
  40. package/dist-server/service/figure/figure-type.js +58 -5
  41. package/dist-server/service/figure/figure-type.js.map +1 -1
  42. package/dist-server/service/figure/figure.d.ts +9 -0
  43. package/dist-server/service/figure/figure.js +15 -3
  44. package/dist-server/service/figure/figure.js.map +1 -1
  45. package/dist-server/service/figure/index.d.ts +4 -1
  46. package/dist-server/service/figure/index.js +5 -2
  47. package/dist-server/service/figure/index.js.map +1 -1
  48. package/dist-server/service/index.d.ts +6 -2
  49. package/dist-server/service/index.js +17 -2
  50. package/dist-server/service/index.js.map +1 -1
  51. package/dist-server/tsconfig.tsbuildinfo +1 -0
  52. package/package.json +9 -6
  53. package/server/index.ts +13 -0
  54. package/server/routers/figure-thumbnail-router.ts +110 -0
  55. package/server/routes.ts +24 -0
  56. package/server/service/figure/event-subscriber.ts +49 -0
  57. package/server/service/figure/figure-gate.ts +119 -0
  58. package/server/service/figure/figure-history.ts +130 -0
  59. package/server/service/figure/figure-mutation.ts +180 -1
  60. package/server/service/figure/figure-propose.test.ts +136 -4
  61. package/server/service/figure/figure-query.ts +63 -1
  62. package/server/service/figure/figure-tools.test.ts +284 -0
  63. package/server/service/figure/figure-tools.ts +320 -0
  64. package/server/service/figure/figure-type.ts +49 -2
  65. package/server/service/figure/figure.ts +15 -3
  66. package/server/service/figure/index.ts +4 -1
  67. package/server/service/index.ts +23 -2
@@ -4,8 +4,10 @@ import { Arg, Ctx, Directive, Mutation, Resolver } from 'type-graphql'
4
4
  import { getRepository } from '@things-factory/shell'
5
5
 
6
6
  import { Figure } from './figure.js'
7
+ import { FigureHistory } from './figure-history.js'
7
8
  import { FigurePatch, FigureSaveResult, NewFigure } from './figure-type.js'
8
9
  import { FigureProposal, ProposeRequest } from './figure-propose-type.js'
10
+ import { inspectSource } from './figure-gate.js'
9
11
  import { parseSource } from './figure-source.js'
10
12
  import { ProposeFailure, proposeFigure } from './figure-propose.js'
11
13
 
@@ -76,17 +78,194 @@ export class FigureMutation {
76
78
  violations = parsed.violations
77
79
  }
78
80
 
81
+ /*
82
+ **판 번호는 여기서 손대지 않는다.** 저장은 저작이고, 판은 발행에서 생긴다(`releaseFigure`).
83
+ board 의 `updateBoard` 도 같다 — 저장 경로에는 version 이 없다.
84
+ */
79
85
  const saved = await repository.save({
86
+ /*
87
+ `domain` 을 명시로 넣는다 — 읽어 온 엔티티에는 관계가 안 실려 있다.
88
+
89
+ 판본 스냅샷은 넘겨받은 엔티티를 베끼므로, 여기서 빠지면 판본의 도메인이 비고 그 판은
90
+ 도메인으로 거르는 조회에 **한 줄도 안 걸린다**(실제로 그랬다). board 의 저장들도 같은
91
+ 자리에서 domain 을 함께 넣는다.
92
+ */
93
+ domain,
80
94
  ...figure,
81
95
  ...patch,
82
96
  source,
97
+ /*
98
+ **고치면 발행이 풀린다.**
99
+
100
+ 발행은 「이 판을 써도 된다」는 약속이고, 그 뒤에 정본이 바뀌면 약속한 것과 배포된 것이
101
+ 달라진다. 그래서 저장은 언제나 초안으로 되돌리고, 다시 내보내려면 발행을 한 번 더 한다
102
+ (판 번호가 하나 오르고 판본이 하나 더 남는다). board 의 `updateBoard` 가 같은 한 줄을 쓴다.
103
+ */
104
+ state: 'draft',
83
105
  ...(derived ?? {}),
84
106
  // 썸네일이 **썸네일로서** 바뀐 때만 시각을 갱신한다. 이름·설명 변경에는 안 건드린다.
85
107
  thumbnailUpdatedAt:
86
108
  patch.thumbnail !== undefined && patch.thumbnail !== figure.thumbnail
87
109
  ? new Date()
88
110
  : figure.thumbnailUpdatedAt,
89
- version: (figure.version ?? 1) + 1,
111
+ updater: user
112
+ })
113
+
114
+ return { figure: saved, violations }
115
+ }
116
+
117
+ /**
118
+ * 발행한다 — **판이 생기는 유일한 자리.**
119
+ *
120
+ * ## 무엇이 일어나나
121
+ *
122
+ * `state` 가 `released` 가 되고, 판 번호가 하나 오르고, 그 순간의 도형이 판본으로 남는다
123
+ * (`FigureHistory` — 스냅샷은 구독자가 뜬다). 저작 중 저장은 이 셋 중 아무것도 하지 않는다.
124
+ *
125
+ * ## 왜 이미 발행된 것을 거절하나
126
+ *
127
+ * 발행은 소비처에게 하는 약속이다. 같은 판을 두 번 발행하면 무엇이 약속인지 흐려진다. 고쳐서
128
+ * 다시 내려면 초안으로 되돌린 뒤 발행한다 — board 가 같은 규칙이다.
129
+ *
130
+ * ## 번호는 판본에서 센다
131
+ *
132
+ * 도형의 현재 번호가 아니라 **판본의 최대 번호 + 1** 이다. 되돌리기로 옛 판이 현재가 되어도
133
+ * 다음 발행이 이미 쓴 번호를 다시 쓰지 않는다.
134
+ *
135
+ * ## 게이트를 통과해야 한다
136
+ *
137
+ * 크기를 바꿨을 때 조각이 갈라지는 도형은 발행되지 않는다(`figure-gate.ts`). 소비처는 형상을
138
+ * 늘려 쓰므로 — 그것이 이 형식의 값이다 — 늘려도 견디는지가 약속의 일부다. 화면은 같은 판정을
139
+ * `inspectFigure` 로 미리 물어 이유를 보여 준다.
140
+ */
141
+ @Directive('@transaction')
142
+ @Directive('@privilege(category: "figure", privilege: "mutation", domainOwnerGranted: true)')
143
+ @Mutation(returns => Figure, { description: 'Releases a figure — raises its version and keeps the released state as history.' })
144
+ async releaseFigure(
145
+ @Arg('id') id: string,
146
+ @Arg('comment', { nullable: true }) comment: string,
147
+ @Ctx() context: ResolverContext
148
+ ): Promise<Figure> {
149
+ const { domain, user, tx } = context.state
150
+ const repository = tx.getRepository(Figure)
151
+ const historyRepository = tx.getRepository(FigureHistory)
152
+
153
+ const figure = await repository.findOne({
154
+ where: { domain: { id: In([domain.id, domain.parentId].filter(Boolean)) }, id }
155
+ })
156
+ if (!figure) {
157
+ throw new Error(`Figure ${id} 를 찾을 수 없다`)
158
+ }
159
+
160
+ if (figure.state === 'released') {
161
+ throw new Error(`Figure '${figure.type}' 은 이미 발행되어 있다. 고쳐서 다시 내려면 초안으로 되돌린 뒤 발행한다`)
162
+ }
163
+
164
+ /*
165
+ ── 게이트는 여기 있어야 한다 ────────────────────────────────────────────
166
+ 화면도 같은 판정을 미리 물어(`inspectFigure`) 이유를 보여 주지만, **막는 자리는 이곳뿐이다.**
167
+ 물어본 뒤 정본이 바뀌었을 수 있고, 화면을 거치지 않는 부름도 있다.
168
+
169
+ 막는 이유는 사유와 함께 낸다. 「발행할 수 없습니다」로 뭉치면 무엇을 고쳐야 하는지 모른다.
170
+ */
171
+ const inspection = inspectSource(figure.source)
172
+ if (inspection.blocked) {
173
+ const reasons = inspection.findings
174
+ .filter(finding => finding.blocking)
175
+ .map(finding => (finding.at ? `${finding.message} (${finding.at})` : finding.message))
176
+
177
+ throw new Error(`Figure '${figure.type}' 은 아직 발행할 수 없다 — ${reasons.join(' · ')}`)
178
+ }
179
+
180
+ const highest = await historyRepository
181
+ .createQueryBuilder('history')
182
+ .select('MAX(history.version)', 'max')
183
+ .where('history.originalId = :id', { id: figure.id })
184
+ .getRawOne()
185
+
186
+ const nextVersion = highest?.max ? Number(highest.max) + 1 : 1
187
+
188
+ return await repository.save({
189
+ /*
190
+ `domain` 을 명시로 넣는다 — 읽어 온 엔티티에는 관계가 안 실려 있다.
191
+
192
+ 판본 스냅샷은 넘겨받은 엔티티를 베끼므로, 여기서 빠지면 판본의 도메인이 비고 그 판은
193
+ 도메인으로 거르는 조회에 **한 줄도 안 걸린다**(실제로 그랬다). board 의 저장들도 같은
194
+ 자리에서 domain 을 함께 넣는다.
195
+ */
196
+ domain,
197
+ ...figure,
198
+ version: nextVersion,
199
+ state: 'released',
200
+ updater: user,
201
+ /* 판본 구독자가 이것을 판본의 comment 로 옮긴다. Figure 의 칸이 아니다. */
202
+ __releaseComment: comment ?? null
203
+ } as any)
204
+ }
205
+
206
+ /**
207
+ * 옛 판으로 되돌린다.
208
+ *
209
+ * ## 무엇이 돌아오나
210
+ *
211
+ * 그 판의 **정본과 속성 정의, 그리고 그림**이다. 이름·설명·분류는 그대로 둔다 — 그것들은 언제든
212
+ * 바꿔도 안전한 카탈로그의 말이고, 되돌리기가 개명까지 되돌리면 사람이 시키지 않은 일을 한다.
213
+ *
214
+ * ## 되돌려도 발행은 아니다
215
+ *
216
+ * 초안으로 돌아온다. 다시 내보내려면 발행을 한 번 더 하고, 그때 판 번호는 **이미 쓴 번호 다음**을
217
+ * 받는다 — 3판을 되살렸다고 다시 3판이 되지 않는다. board 도 같다.
218
+ *
219
+ * ## 비용은 다시 잰다
220
+ *
221
+ * 판본이 든 점수·삼각형은 **그때의 형식**으로 잰 값이다. 지금 이 도형이 그 내용이 되었으므로
222
+ * 오늘 기준으로 다시 계산한다. 옛 값은 판본에 그대로 남아 있다.
223
+ */
224
+ @Directive('@transaction')
225
+ @Directive('@privilege(category: "figure", privilege: "mutation", domainOwnerGranted: true)')
226
+ @Mutation(returns => FigureSaveResult, { description: 'Reverts a figure to one of its released versions. It comes back as a draft.' })
227
+ async revertFigureVersion(
228
+ @Arg('id') id: string,
229
+ @Arg('version') version: number,
230
+ @Ctx() context: ResolverContext
231
+ ): Promise<FigureSaveResult> {
232
+ const { domain, user, tx } = context.state
233
+ const repository = tx.getRepository(Figure)
234
+ const historyRepository = tx.getRepository(FigureHistory)
235
+
236
+ const figure = await repository.findOne({
237
+ where: { domain: { id: In([domain.id, domain.parentId].filter(Boolean)) }, id }
238
+ })
239
+ if (!figure) {
240
+ throw new Error(`Figure ${id} 를 찾을 수 없다`)
241
+ }
242
+
243
+ const history = await historyRepository.findOne({
244
+ where: { domain: { id: In([domain.id, domain.parentId].filter(Boolean)) }, originalId: id, version }
245
+ })
246
+ if (!history) {
247
+ throw new Error(`Figure '${figure.type}' 에 ${version} 판이 없다`)
248
+ }
249
+
250
+ /* 형식 검사도 다시 받는다 — 옛 판이 오늘의 형식을 어길 수 있고, 그것을 조용히 들이지 않는다. */
251
+ const { source, derived, violations } = parseSource(history.source)
252
+
253
+ const saved = await repository.save({
254
+ /*
255
+ `domain` 을 명시로 넣는다 — 읽어 온 엔티티에는 관계가 안 실려 있다.
256
+
257
+ 판본 스냅샷은 넘겨받은 엔티티를 베끼므로, 여기서 빠지면 판본의 도메인이 비고 그 판은
258
+ 도메인으로 거르는 조회에 **한 줄도 안 걸린다**(실제로 그랬다). board 의 저장들도 같은
259
+ 자리에서 domain 을 함께 넣는다.
260
+ */
261
+ domain,
262
+ ...figure,
263
+ source,
264
+ ...derived,
265
+ properties: history.properties,
266
+ thumbnail: history.thumbnail,
267
+ thumbnailUpdatedAt: history.thumbnail === figure.thumbnail ? figure.thumbnailUpdatedAt : new Date(),
268
+ state: 'draft',
90
269
  updater: user
91
270
  })
92
271
 
@@ -37,22 +37,38 @@ function candidate(over: Record<string, unknown> = {}) {
37
37
  }
38
38
  }
39
39
 
40
- /** 정해 둔 답을 차례로 내놓는 가짜 모델. */
41
- function stubClient(answers: unknown[]): AIClient & { calls: number } {
40
+ /**
41
+ * 정해 둔 답을 차례로 내놓는 가짜 모델.
42
+ *
43
+ * **받은 말을 적어 둔다.** 프롬프트에 실려 나가는 지시 중에 지켜져야 할 것이 있어서다 —
44
+ * 고치는 요청에서 「부품 이름을 바꾸지 말라」가 빠지면 AI 가 이름을 갈아 버리고, 그러면
45
+ * 이미 배치된 보드의 바인딩이 끊긴다. 답만 보는 모형으로는 그것이 나갔는지 알 수 없다.
46
+ */
47
+ function stubClient(answers: unknown[]): AIClient & { calls: number; sent: any[][]; system?: string } {
42
48
  const client = {
43
49
  id: 'stub',
44
50
  calls: 0,
51
+ sent: [] as any[][],
52
+ system: undefined as string | undefined,
45
53
  async chat() {
46
54
  return ''
47
55
  },
48
- async generateJSON() {
56
+ async generateJSON(messages: any[], options?: { systemPrompt?: string }) {
57
+ client.sent.push(JSON.parse(JSON.stringify(messages)))
58
+ client.system = options?.systemPrompt
49
59
  const answer = answers[client.calls]
50
60
  client.calls++
51
61
  if (answer instanceof Error) throw answer
52
62
  return answer
53
63
  }
54
64
  }
55
- return client as unknown as AIClient & { calls: number }
65
+ return client as unknown as AIClient & { calls: number; sent: any[][]; system?: string }
66
+ }
67
+
68
+ /** 첫 요청에 실려 나간 사람 쪽 말. 이어 붙인 것까지 한 덩어리로 본다. */
69
+ function firstAsk(client: { sent: any[][] }): string {
70
+ const content = client.sent[0]![0]!.content
71
+ return typeof content === 'string' ? content : JSON.stringify(content)
56
72
  }
57
73
 
58
74
  afterEach(() => setDefaultAIClient(undefined))
@@ -162,3 +178,119 @@ describe('후보를 만들 때', () => {
162
178
  )
163
179
  })
164
180
  })
181
+
182
+ describe('무엇을 시키는지가 프롬프트에 실린다', () => {
183
+ /*
184
+ 고치는 요청에서 이 지시가 빠지면 AI 가 부품 이름을 갈아 버린다. 부품 이름은 **저장되는
185
+ 식별자**라서, 이미 배치된 보드의 바인딩·애니메이션이 그 이름을 가리키고 있다. 조용히
186
+ 끊기고, 끊긴 것은 그 보드를 열어 봐야 드러난다.
187
+ */
188
+ it('고치는 요청이면 지금 정본과 「이름을 바꾸지 말라」가 함께 나간다', async () => {
189
+ const client = stubClient([candidate()])
190
+ setDefaultAIClient(client)
191
+
192
+ const base = candidate({ type: 'MIXER' }) as never
193
+ await proposeFigure({ prompt: '재질을 줄여줘', palette: PALETTE, base })
194
+
195
+ const ask = firstAsk(client)
196
+ expect(ask).toContain('Revise this existing figure')
197
+ expect(ask).toMatch(/renaming a part breaks bindings/)
198
+ // 지금 정본이 실제로 실려 나가야 한다 — 없으면 모델이 처음부터 만든다.
199
+ expect(ask).toContain('"name":"body"')
200
+ })
201
+
202
+ /*
203
+ 타입 이름을 안 주면 `FIGURE` 로 나간다. **조용히 지어지는 값**이라 못 박아 둔다 — 저작면이
204
+ 타입을 늘 주므로 이 자리는 평소 안 보이지만, 안 주는 부르는 쪽이 생기면 모델이 만든 것이
205
+ 전부 `FIGURE` 라는 이름을 갖게 된다. 타입 이름은 저장되는 식별자다.
206
+ */
207
+ it('타입을 안 주면 FIGURE 로 나간다 — 조용히 빈 이름을 보내지 않는다', async () => {
208
+ const client = stubClient([candidate()])
209
+ setDefaultAIClient(client)
210
+
211
+ await proposeFigure({ prompt: '무언가', palette: PALETTE })
212
+
213
+ expect(firstAsk(client)).toContain('type "FIGURE"')
214
+ })
215
+
216
+ it('새로 만드는 요청이면 타입 이름이 나가고 고치라는 말은 안 나간다', async () => {
217
+ const client = stubClient([candidate()])
218
+ setDefaultAIClient(client)
219
+
220
+ await proposeFigure({ prompt: '교반기', palette: PALETTE, type: 'MIXER' })
221
+
222
+ const ask = firstAsk(client)
223
+ expect(ask).toContain('type "MIXER"')
224
+ expect(ask).not.toContain('Revise this existing figure')
225
+ })
226
+
227
+ /*
228
+ 그림은 **비례를 보라고** 주는 것이지 표면 무늬를 베끼라고 주는 것이 아니다. 그 말이 빠지면
229
+ 모델이 사진의 로고·녹·글자까지 부품으로 만들려 들고, 로우폴리에서 그것은 부품 낭비다.
230
+ */
231
+ it('그림을 주면 비례를 보라는 말과 함께 나간다', async () => {
232
+ const client = stubClient([candidate()])
233
+ setDefaultAIClient(client)
234
+
235
+ await proposeFigure({
236
+ prompt: '이 설비',
237
+ palette: PALETTE,
238
+ type: 'MIXER',
239
+ image: { data: Buffer.from('89504e47', 'hex'), mediaType: 'image/png' }
240
+ })
241
+
242
+ const ask = firstAsk(client)
243
+ expect(ask).toMatch(/proportions and masses, not for surface detail/)
244
+ // 그림 자체도 실려야 한다 — 말만 나가고 그림이 빠지면 모델이 볼 것이 없다.
245
+ expect(ask).toContain('"type":"image"')
246
+ })
247
+ })
248
+
249
+ describe('모델이 JSON 을 못 낼 때', () => {
250
+ /*
251
+ 어댑터가 던지는 경우다. 그것도 사유이므로 **돌려주고 다시 시킨다** — 던져 버리면 저작자는
252
+ 「안 됐다」만 보고, 무엇이 문제였는지도 모델이 고칠 기회도 없다.
253
+ */
254
+ it('던진 사유를 돌려주고 다시 시킨다', async () => {
255
+ const client = stubClient([new Error('unterminated string'), candidate()])
256
+ setDefaultAIClient(client)
257
+
258
+ const result = await proposeFigure({ prompt: '교반기', palette: PALETTE, type: 'MIXER' })
259
+
260
+ expect(result.attempts).toBe(2)
261
+ expect(client.calls).toBe(2)
262
+
263
+ // 두 번째 요청에 그 사유가 실려야 한다.
264
+ const retry = JSON.stringify(client.sent[1])
265
+ expect(retry).toContain('unterminated string')
266
+ expect(retry).toMatch(/Answer with the JSON object only/)
267
+ })
268
+
269
+ it('세 번 다 던지면 못 만들었다고 말한다', async () => {
270
+ setDefaultAIClient(stubClient([new Error('a'), new Error('b'), new Error('c')]))
271
+
272
+ await expect(proposeFigure({ prompt: '교반기', palette: PALETTE, type: 'MIXER' })).rejects.toThrow(ProposeFailure)
273
+ })
274
+ })
275
+
276
+ describe('정책을 넘은 후보는 막지 않고 알린다', () => {
277
+ /*
278
+ 형식의 두 갈래를 그대로 지킨다 — `errors` 는 막고 `violations` 는 알린다. 정책 위반으로
279
+ 후보를 버리면 저작자가 판단할 기회가 없어진다. 랜드마크 설비 하나는 정교해도 되고, 그
280
+ 대가가 숫자로 보이면 된다.
281
+ */
282
+ it('등급 한도를 넘겨도 후보로 오고 위반이 함께 온다', async () => {
283
+ const many = Array.from({ length: PART_LIMIT.S + 1 }, (_, at) => ({
284
+ name: `part-${at}`,
285
+ primitive: PRIMITIVE_KINDS[0],
286
+ transform: { position: { x: at * 10, y: 0, z: 0 }, size: { x: 20, y: 20, z: 20 } },
287
+ material: { token: PALETTE[0] }
288
+ }))
289
+ setDefaultAIClient(stubClient([candidate({ detailLevel: 'S', parts: many })]))
290
+
291
+ const result = await proposeFigure({ prompt: '단순한 것', palette: PALETTE, type: 'MIXER' })
292
+
293
+ expect(result.source.parts).toHaveLength(PART_LIMIT.S + 1)
294
+ expect(result.violations.map(one => one.code)).toContain('too-many-parts')
295
+ })
296
+ })
@@ -4,7 +4,9 @@ import { Args, Arg, Ctx, Directive, Query, Resolver } from 'type-graphql'
4
4
  import { Domain, getQueryBuilderFromListParams, getRepository, ListParam } from '@things-factory/shell'
5
5
 
6
6
  import { Figure } from './figure.js'
7
- import { FigureList } from './figure-type.js'
7
+ import { FigureHistory } from './figure-history.js'
8
+ import { inspectSource } from './figure-gate.js'
9
+ import { FigureInspection, FigureList } from './figure-type.js'
8
10
 
9
11
  @Resolver(Figure)
10
12
  export class FigureQuery {
@@ -45,6 +47,66 @@ export class FigureQuery {
45
47
  *
46
48
  * `state` 로 걸러지지 않는다 — `draft` 인 Figure 의 이름도 이미 점유된 것이다.
47
49
  */
50
+ /**
51
+ * 발행된 판들 — 최근 것부터.
52
+ *
53
+ * 저작 중 저장은 여기 없다. 판본은 발행에서만 생기므로(`releaseFigure`), 이 목록이 곧
54
+ * 「이 도형이 몇 번 약속되었나」다.
55
+ */
56
+ @Directive('@privilege(category: "figure", privilege: "query", domainOwnerGranted: true)')
57
+ @Query(returns => [FigureHistory], { description: 'Released versions of a figure, newest first.' })
58
+ async figureVersions(@Arg('id') id: string, @Ctx() context: ResolverContext): Promise<FigureHistory[]> {
59
+ const { domain } = context.state
60
+
61
+ return await getRepository(FigureHistory).find({
62
+ where: { domain: { id: In([domain.id, domain.parentId].filter(Boolean)) }, originalId: id },
63
+ relations: ['updater'],
64
+ order: { version: 'DESC' },
65
+ take: 10
66
+ })
67
+ }
68
+
69
+ /**
70
+ * 지금 소비처가 쓰는 판.
71
+ *
72
+ * 도형의 현재 정본은 저작 중일 수 있다(초안). 배치된 것이 보는 것은 **마지막으로 발행된 판**이고,
73
+ * 그것을 이 자리가 답한다.
74
+ */
75
+ @Directive('@privilege(category: "figure", privilege: "query", domainOwnerGranted: true)')
76
+ @Query(returns => FigureHistory, { nullable: true, description: 'The most recently released version of a figure.' })
77
+ async figureReleased(@Arg('id') id: string, @Ctx() context: ResolverContext): Promise<FigureHistory> {
78
+ const { domain } = context.state
79
+
80
+ return await getRepository(FigureHistory).findOne({
81
+ where: { domain: { id: In([domain.id, domain.parentId].filter(Boolean)) }, originalId: id },
82
+ order: { version: 'DESC' }
83
+ })
84
+ }
85
+
86
+ /**
87
+ * 발행해도 되나 — **누르기 전에** 묻는 자리.
88
+ *
89
+ * 판정은 `figure-gate.ts` 가 하고 이 자리는 정본을 꺼내 넘긴다. 같은 판정을 `releaseFigure` 도
90
+ * 부른다 — 화면이 물어 보고 통과했더라도 그 사이에 정본이 바뀌었을 수 있으므로, 막는 것은 언제나
91
+ * 뮤테이션이다. 이 질의는 **이유를 미리 보여 주기 위한 것**이고 허가증이 아니다.
92
+ */
93
+ @Directive('@privilege(category: "figure", privilege: "query", domainOwnerGranted: true)')
94
+ @Query(returns => FigureInspection, { description: 'Whether a figure may be released, with the reasons.' })
95
+ async inspectFigure(@Arg('id') id: string, @Ctx() context: ResolverContext): Promise<FigureInspection> {
96
+ const { domain } = context.state
97
+
98
+ const figure = await getRepository(Figure).findOne({
99
+ where: { domain: { id: In([domain.id, domain.parentId].filter(Boolean)) }, id },
100
+ select: { id: true, source: true }
101
+ })
102
+
103
+ if (!figure) {
104
+ throw new Error(`Figure ${id} 를 찾을 수 없다`)
105
+ }
106
+
107
+ return inspectSource(figure.source)
108
+ }
109
+
48
110
  @Directive('@privilege(category: "figure", privilege: "query", domainOwnerGranted: true)')
49
111
  @Query(returns => [String], { description: 'All figure type names in scope — used to check name collisions before create.' })
50
112
  async figureTypeNames(@Ctx() context: ResolverContext): Promise<string[]> {