@things-factory/figure-service 10.1.28 → 10.1.31

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 (31) hide show
  1. package/dist-server/service/figure/figure-mutation.d.ts +7 -0
  2. package/dist-server/service/figure/figure-mutation.js +30 -0
  3. package/dist-server/service/figure/figure-mutation.js.map +1 -1
  4. package/dist-server/service/figure/figure-propose.js +1 -1
  5. package/dist-server/service/figure/figure-propose.js.map +1 -1
  6. package/dist-server/service/figure/figure-query.js +19 -5
  7. package/dist-server/service/figure/figure-query.js.map +1 -1
  8. package/dist-server/service/figure/figure-rename.d.ts +69 -0
  9. package/dist-server/service/figure/figure-rename.js +69 -0
  10. package/dist-server/service/figure/figure-rename.js.map +1 -0
  11. package/dist-server/service/figure/figure-tools.js +6 -4
  12. package/dist-server/service/figure/figure-tools.js.map +1 -1
  13. package/dist-server/service/figure/figure-type.d.ts +8 -2
  14. package/dist-server/service/figure/figure-type.js +12 -3
  15. package/dist-server/service/figure/figure-type.js.map +1 -1
  16. package/dist-server/tsconfig.tsbuildinfo +1 -1
  17. package/package.json +6 -6
  18. package/server/service/figure/figure-blueprint-query.test.ts +269 -1
  19. package/server/service/figure/figure-e2e-smoke.test.ts +1 -1
  20. package/server/service/figure/figure-mutation.ts +44 -0
  21. package/server/service/figure/figure-propose.ts +1 -1
  22. package/server/service/figure/figure-query.ts +36 -7
  23. package/server/service/figure/figure-rename-db.test.ts +138 -0
  24. package/server/service/figure/figure-rename.test.ts +99 -0
  25. package/server/service/figure/figure-rename.ts +98 -0
  26. package/server/service/figure/figure-tool-doors.test.ts +98 -9
  27. package/server/service/figure/figure-tools.test.ts +66 -0
  28. package/server/service/figure/figure-tools.ts +6 -4
  29. package/server/service/figure/figure-type.ts +11 -3
  30. package/translations/en.json +3 -0
  31. package/translations/ko.json +3 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@things-factory/figure-service",
3
- "version": "10.1.28",
3
+ "version": "10.1.31",
4
4
  "publishConfig": {
5
5
  "access": "public",
6
6
  "@things-factory:registry": "https://registry.npmjs.org"
@@ -23,15 +23,15 @@
23
23
  "migration:create": "node ../../node_modules/typeorm/cli.js migration:create ./server/migrations/migration"
24
24
  },
25
25
  "dependencies": {
26
- "@hatiolab/figure-model": "^0.1.21",
26
+ "@hatiolab/figure-model": "^0.1.27",
27
27
  "@operato/typeorm-history": "^10.0.0",
28
- "@things-factory/ai-client-base": "^10.1.28",
29
- "@things-factory/auth-base": "^10.1.28",
28
+ "@things-factory/ai-client-base": "^10.1.31",
29
+ "@things-factory/auth-base": "^10.1.31",
30
30
  "@things-factory/env": "^10.1.20",
31
- "@things-factory/shell": "^10.1.28",
31
+ "@things-factory/shell": "^10.1.31",
32
32
  "graphql-scalars": "^1.22.4",
33
33
  "graphql-upload": "^15.0.2",
34
34
  "koa-router": "^7.4.0"
35
35
  },
36
- "gitHead": "3a260a0f79bc6b41a692a103955bfcc541aff60e"
36
+ "gitHead": "5213e6f9913b40ea2618a2111d9884b792dbaa01"
37
37
  }
@@ -40,6 +40,12 @@ const sampleSource = JSON.stringify({
40
40
  ]
41
41
  })
42
42
 
43
+ /** The same box under a different type and part name, so a test can tell the sources apart. */
44
+ const boxNamed = (type: string, part: string) => {
45
+ const parsed = JSON.parse(sampleSource)
46
+ return JSON.stringify({ ...parsed, type, parts: [{ ...parsed.parts[0], name: part }] })
47
+ }
48
+
43
49
  beforeAll(async () => {
44
50
  Figure = loadCompiled('service/figure/figure').Figure
45
51
  FigureHistory = loadCompiled('service/figure/figure-history').FigureHistory
@@ -176,13 +182,227 @@ describe('figureBlueprint 질의 — 소비처(board, twin, things-scene)를 위
176
182
  expect(result.groups[0].members[0].transform.size.x).toBe(100)
177
183
  })
178
184
 
185
+ /*
186
+ The test above proves a release is reached through history, with one history row. It cannot tell
187
+ `version: 'DESC'` from `'ASC'`, nor from no ordering at all — with a single row every one of them
188
+ answers the same. Consumers are promised the LATEST release, so the ordering is the claim, and it
189
+ needs more than one row to be a claim at all. The rows go in out of order because an ordering bug
190
+ that reads insertion order would otherwise pass.
191
+ */
192
+ it('answers the highest released version when a type has several', async () => {
193
+ const repo = dataSource!.getRepository(Figure)
194
+ const historyRepo = dataSource!.getRepository(FigureHistory)
195
+
196
+ const figure = await repo.save({
197
+ domain: testDomain,
198
+ type: 'MULTI',
199
+ name: 'in authoring',
200
+ state: 'draft',
201
+ source: boxNamed('MULTI', 'unreleased-draft'),
202
+ version: 3
203
+ })
204
+
205
+ for (const version of [1, 3, 2]) {
206
+ await historyRepo.save({
207
+ domain: testDomain,
208
+ originalId: figure.id,
209
+ type: 'MULTI',
210
+ name: `v${version}`,
211
+ state: 'released',
212
+ version,
213
+ source: boxNamed('MULTI', `v${version}`)
214
+ })
215
+ }
216
+
217
+ const result = await new FigureQuery().figureBlueprint('MULTI', makeContext())
218
+
219
+ expect(result).not.toBeNull()
220
+ expect(result.groups[0].members[0].name).toBe('v3')
221
+ })
222
+
223
+ /*
224
+ Every edit to a released figure writes a history row, and those rows are not all releases. A
225
+ draft row sitting at a higher version than the last release is the ordinary state of a figure
226
+ being worked on, so reading the newest row rather than the newest RELEASED row would hand
227
+ consumers unreleased work — the leak the whole door exists to stop.
228
+ */
229
+ it('skips a draft history that sits above the last release', async () => {
230
+ const repo = dataSource!.getRepository(Figure)
231
+ const historyRepo = dataSource!.getRepository(FigureHistory)
232
+
233
+ const figure = await repo.save({
234
+ domain: testDomain,
235
+ type: 'WIP',
236
+ name: 'in authoring',
237
+ state: 'draft',
238
+ source: boxNamed('WIP', 'unreleased-draft'),
239
+ version: 5
240
+ })
241
+
242
+ await historyRepo.save({
243
+ domain: testDomain,
244
+ originalId: figure.id,
245
+ type: 'WIP',
246
+ name: 'v2',
247
+ state: 'released',
248
+ version: 2,
249
+ source: boxNamed('WIP', 'released-v2')
250
+ })
251
+ await historyRepo.save({
252
+ domain: testDomain,
253
+ originalId: figure.id,
254
+ type: 'WIP',
255
+ name: 'v5',
256
+ state: 'draft',
257
+ version: 5,
258
+ source: boxNamed('WIP', 'draft-v5')
259
+ })
260
+
261
+ const result = await new FigureQuery().figureBlueprint('WIP', makeContext())
262
+
263
+ expect(result).not.toBeNull()
264
+ expect(result.groups[0].members[0].name).toBe('released-v2')
265
+ })
266
+
267
+ /*
268
+ History rows are looked up by originalId, which is a figure id and so already unique. That makes
269
+ the domain in the same where-clause look redundant, and redundant-looking filters get deleted.
270
+ It is not redundant: it is the tenant boundary on a door that consumers call, so a test holds it.
271
+ */
272
+ it('does not reach a history row that belongs to another domain', async () => {
273
+ const domainRepo = dataSource!.getRepository(shell.Domain)
274
+ const other = await domainRepo.save({ name: 'other-domain', subdomain: 'other-domain' })
275
+
276
+ const repo = dataSource!.getRepository(Figure)
277
+ const historyRepo = dataSource!.getRepository(FigureHistory)
278
+
279
+ const figure = await repo.save({
280
+ domain: testDomain,
281
+ type: 'LEAKY',
282
+ name: 'never released here',
283
+ state: 'draft',
284
+ source: boxNamed('LEAKY', 'unreleased-draft'),
285
+ version: 0
286
+ })
287
+
288
+ await historyRepo.save({
289
+ domain: other,
290
+ originalId: figure.id,
291
+ type: 'LEAKY',
292
+ name: 'released elsewhere',
293
+ state: 'released',
294
+ version: 1,
295
+ source: boxNamed('LEAKY', 'other-domain-release')
296
+ })
297
+
298
+ const result = await new FigureQuery().figureBlueprint('LEAKY', makeContext())
299
+
300
+ expect(result).toBeNull()
301
+ })
302
+
303
+ /*
304
+ This used to answer null, and that was the defect. Null says one thing on this door — nobody
305
+ published this type — and the board reads it that way: it draws the stand-in of ADR-0063 결정 1 and
306
+ the scene opens. A released figure whose stored source no longer compiles is a different fact. The
307
+ author published it, the catalogue says released, and with null the only trace was a console line
308
+ on the server, so the author saw "my figure does not show on the board" and nothing more. That is
309
+ the silence ADR-0053 refuses (chief architect, ADR-0063 결정 2 보탬).
310
+
311
+ So it refuses with a name, and the name carries what someone needs to find the figure: the type,
312
+ its id, and the edition that does not compile. The sentence is built where the request's language
313
+ is known, which is why the gate throws no sentence of its own (ADR-0054).
314
+ */
315
+ it('refuses with a named refusal when the stored source cannot be compiled', async () => {
316
+ const repo = dataSource!.getRepository(Figure)
317
+ const saved = await repo.save({
318
+ domain: testDomain,
319
+ type: 'BROKEN',
320
+ name: 'corrupted',
321
+ state: 'released',
322
+ source: '{ this is not json',
323
+ version: 4
324
+ })
325
+
326
+ const query = new FigureQuery()
327
+ const spy = jest.spyOn(console, 'error').mockImplementation(() => {})
328
+ try {
329
+ await expect(query.figureBlueprint('BROKEN', makeContext())).rejects.toMatchObject({
330
+ name: 'Refusal',
331
+ code: 'figure-release-does-not-compile',
332
+ params: { type: 'BROKEN', figureId: saved.id, version: 4 }
333
+ })
334
+ } finally {
335
+ spy.mockRestore()
336
+ }
337
+ })
338
+
339
+ /*
340
+ The edition named in that refusal is the one the board was handed, which is not the row's own
341
+ version once the author starts editing again: the row moves on to the next draft while the board
342
+ keeps drawing the last release. Naming the draft would send someone to look at a source that is
343
+ not the one failing.
344
+ */
345
+ it('the refusal names the released edition, not the draft being edited', async () => {
346
+ const repo = dataSource!.getRepository(Figure)
347
+ const historyRepo = dataSource!.getRepository(FigureHistory)
348
+
349
+ const figure = await repo.save({
350
+ domain: testDomain,
351
+ type: 'BROKEN_HISTORY',
352
+ name: 'in authoring again',
353
+ state: 'draft',
354
+ source: boxNamed('BROKEN_HISTORY', 'healthy-draft'),
355
+ version: 9
356
+ })
357
+ await historyRepo.save({
358
+ domain: testDomain,
359
+ originalId: figure.id,
360
+ type: 'BROKEN_HISTORY',
361
+ name: 'v2',
362
+ state: 'released',
363
+ version: 2,
364
+ source: '{ this is not json'
365
+ })
366
+
367
+ const spy = jest.spyOn(console, 'error').mockImplementation(() => {})
368
+ try {
369
+ await expect(new FigureQuery().figureBlueprint('BROKEN_HISTORY', makeContext())).rejects.toMatchObject({
370
+ code: 'figure-release-does-not-compile',
371
+ params: { version: 2 }
372
+ })
373
+ } finally {
374
+ spy.mockRestore()
375
+ }
376
+ })
377
+
378
+ /*
379
+ A refusal only becomes a sentence in the caller's language by passing through `SpeakRefusals`,
380
+ which the class declares and which a direct call like the ones above never runs. Without that
381
+ declaration this refusal reaches the board as Apollo's INTERNAL_SERVER_ERROR carrying the English
382
+ fallback, and `extensions.refusal` — the thing that tells a client the server refused on purpose —
383
+ is absent. The tests above stay green either way, so the declaration is read from the source.
384
+ */
385
+ it('declares SpeakRefusals on the class, or the refusal arrives as a server error', () => {
386
+ const { readFileSync } = require('fs')
387
+ const { join } = require('path')
388
+ const source = readFileSync(join(__dirname, 'figure-query.ts'), 'utf-8')
389
+
390
+ const at = source.indexOf('export class FigureQuery')
391
+ expect(at).toBeGreaterThan(-1)
392
+ expect(source.slice(0, at)).toContain('@UseMiddleware(SpeakRefusals)')
393
+ })
394
+
179
395
  describe('a child domain shadows its parent only with a released version', () => {
180
396
  const boxOf = (type: string, part: string) =>
181
397
  JSON.stringify({ ...JSON.parse(sampleSource), type, parts: [{ ...JSON.parse(sampleSource).parts[0], name: part }] })
182
398
 
399
+ let childSeq = 0
400
+
183
401
  async function childOf(parent: any) {
184
402
  const repo = dataSource!.getRepository(shell.Domain)
185
- const saved = await repo.save({ name: 'child-domain', subdomain: 'child-domain', parent })
403
+ /* A chain needs a distinct subdomain per level; the column is unique. */
404
+ const name = `child-domain-${++childSeq}`
405
+ const saved = await repo.save({ name, subdomain: name, parent })
186
406
  return repo.findOne({ where: { id: saved.id } })
187
407
  }
188
408
 
@@ -213,6 +433,54 @@ describe('figureBlueprint 질의 — 소비처(board, twin, things-scene)를 위
213
433
 
214
434
  expect(result.groups[0].members[0].name).toBe('tenant')
215
435
  })
436
+
437
+ /*
438
+ The two cases above give the child a row whose own state decides the answer. This is the case
439
+ between them: the child released once and is editing again, so its row says draft while a
440
+ release of its own exists. The fallback must not read "the child row is a draft" as "the child
441
+ has nothing" — a tenant that starts a second edit would then see the standard figure come back
442
+ in place of the one it published.
443
+ */
444
+ it('uses the child own release when the child row is a draft again', async () => {
445
+ const child = await childOf(testDomain)
446
+
447
+ const repo = dataSource!.getRepository(Figure)
448
+ const historyRepo = dataSource!.getRepository(FigureHistory)
449
+
450
+ await repo.save({ domain: testDomain, type: 'CONVEYOR', name: '표준', state: 'released', source: boxOf('CONVEYOR', 'standard'), version: 1 })
451
+ const tenant = await repo.save({ domain: child, type: 'CONVEYOR', name: '테넌트', state: 'draft', source: boxOf('CONVEYOR', 'tenant-wip'), version: 1 })
452
+ await historyRepo.save({
453
+ domain: child,
454
+ originalId: tenant.id,
455
+ type: 'CONVEYOR',
456
+ name: '테넌트 v1',
457
+ state: 'released',
458
+ version: 1,
459
+ source: boxOf('CONVEYOR', 'tenant-v1')
460
+ })
461
+
462
+ const result = await new FigureQuery().figureBlueprint('CONVEYOR', contextIn(child))
463
+
464
+ expect(result).not.toBeNull()
465
+ expect(result.groups[0].members[0].name).toBe('tenant-v1')
466
+ })
467
+
468
+ /*
469
+ The lookup consults the caller's domain and its parent, and stops there. A grandchild therefore
470
+ does not inherit, which is the current rule rather than an oversight — pinning it here means
471
+ the day someone needs deeper inheritance they change this test on purpose and take the ruling
472
+ with them, instead of finding the depth by accident in a scene that renders nothing.
473
+ */
474
+ it('does not climb past the immediate parent', async () => {
475
+ const mid = await childOf(testDomain)
476
+ const leaf = await childOf(mid)
477
+ expect(leaf.parentId).toBe(mid.id)
478
+
479
+ const repo = dataSource!.getRepository(Figure)
480
+ await repo.save({ domain: testDomain, type: 'CONVEYOR', name: '표준', state: 'released', source: boxOf('CONVEYOR', 'standard'), version: 1 })
481
+
482
+ expect(await new FigureQuery().figureBlueprint('CONVEYOR', contextIn(leaf))).toBeNull()
483
+ })
216
484
  })
217
485
 
218
486
  /*
@@ -62,7 +62,7 @@ describeIf('AI 로 만든 figure 가 발행까지 가나', () => {
62
62
  test('움직이는 것을 만든다 — 문', async () => {
63
63
  const { made, gate } = await walk(
64
64
  '자동문',
65
- 'a двух-panel automatic door in a frame. add an animation clip that opens both panels together by sliding them apart.',
65
+ 'a two-panel automatic door in a frame. add an animation clip that opens both panels together by sliding them apart.',
66
66
  'AUTO_DOOR_E2E'
67
67
  )
68
68
  expect(gate.blocked).toBe(false)
@@ -8,6 +8,7 @@ import { FigureHistory } from './figure-history.js'
8
8
  import { FigurePatch, FigureSaveResult, NewFigure } from './figure-type.js'
9
9
  import { FigureProposal, ProposeRequest } from './figure-propose-type.js'
10
10
  import { inspectSource } from './figure-gate.js'
11
+ import { renameDraftType, renameFacts } from './figure-rename.js'
11
12
  import { parseSource } from './figure-source.js'
12
13
  import { ProposeFailure, proposeFigure } from './figure-propose.js'
13
14
 
@@ -78,6 +79,23 @@ export class FigureMutation {
78
79
  violations = parsed.violations
79
80
  }
80
81
 
82
+ /*
83
+ **타입은 발행 전에만 바뀐다** — ADR-0063 결정 4.
84
+
85
+ 발행된 뒤에는 이미 배치된 도면이 이 이름을 가리키고, 바꾸면 그 도면이 아무 오류 없이
86
+ 빈 자리가 된다. 발행된 적 없는 도형은 가리키는 도면이 있을 수 없다 — 도면은 최신
87
+ 발행판을 그리므로 그려질 자리가 없다.
88
+
89
+ 행과 정본 둘 다 고친다. 정본 안에도 `type` 이 있어서 한쪽만 고치면 저장된 JSON 과
90
+ 행이 다른 이름을 말하고, 발행할 때 어느 쪽이 도면에 적히는지가 갈린다.
91
+ */
92
+ let renamed: string | undefined
93
+ if (patch.type !== undefined && patch.type !== figure.type) {
94
+ const change = await this.renameType(figure, domain, patch.type, source, tx)
95
+ renamed = change.type
96
+ source = change.source
97
+ }
98
+
81
99
  /*
82
100
  **판 번호는 여기서 손대지 않는다.** 저장은 저작이고, 판은 발행에서 생긴다(`releaseFigure`).
83
101
  board 의 `updateBoard` 도 같다 — 저장 경로에는 version 이 없다.
@@ -93,6 +111,8 @@ export class FigureMutation {
93
111
  domain,
94
112
  ...figure,
95
113
  ...patch,
114
+ /* 앞뒤 공백을 떼고 겹침까지 본 이름. `patch.type` 을 그대로 두면 다듬기 전 값이 들어간다. */
115
+ ...(renamed !== undefined ? { type: renamed } : {}),
96
116
  source,
97
117
  /*
98
118
  **고치면 발행이 풀린다.**
@@ -114,6 +134,30 @@ export class FigureMutation {
114
134
  return { figure: saved, violations }
115
135
  }
116
136
 
137
+ /**
138
+ * 초안의 타입을 고친다 — 규칙은 `figure-rename.ts`, 여기서는 그 규칙이 볼 **사실만** 읽는다.
139
+ *
140
+ * 겹침은 DB 인덱스(`ix_figure_1`)도 잡지만 먼저 읽어 본다. 인덱스가 던지는 말은 저작자가
141
+ * 읽을 것이 못 되고, 어느 이름이 이미 쓰였는지도 안 알려 준다.
142
+ */
143
+ private async renameType(
144
+ figure: Figure,
145
+ domain: { id: string },
146
+ wanted: string,
147
+ source: string,
148
+ tx: { getRepository: <T>(target: new () => T) => any }
149
+ ): Promise<{ type: string; source: string }> {
150
+ /* 읽어 온 엔티티에는 관계가 안 실려 있다 — 도메인은 문맥에서 온 것을 쓴다. */
151
+ const facts = await renameFacts(
152
+ { figures: tx.getRepository(Figure), histories: tx.getRepository(FigureHistory) },
153
+ figure,
154
+ domain,
155
+ wanted
156
+ )
157
+
158
+ return renameDraftType({ type: figure.type, source }, wanted, facts)
159
+ }
160
+
117
161
  /**
118
162
  * 발행한다 — **판이 생기는 유일한 자리.**
119
163
  *
@@ -188,7 +188,7 @@ export function rulesPrompt(palette: string[]): string {
188
188
  'Hard limits — exceeding these makes the figure unusable at scale:',
189
189
  ` parts by detailLevel: ${partLimits}`,
190
190
  ` material groups (distinct token+preset+slot combinations) <= ${LIMITS.materialGroups}`,
191
- ` animated parts <= ${LIMITS.animatedParts}`,
191
+ ` independently transformed parts <= ${LIMITS.independentParts}`,
192
192
  ` transparent materials <= ${LIMITS.transparentMaterials}`,
193
193
  '',
194
194
  'Geometry rules:',
@@ -1,9 +1,16 @@
1
1
  import { In } from 'typeorm'
2
- import { Args, Arg, Ctx, Directive, Query, Resolver } from 'type-graphql'
2
+ import { Args, Arg, Ctx, Directive, Query, Resolver, UseMiddleware } from 'type-graphql'
3
3
  import { GraphQLJSON } from 'graphql-scalars'
4
4
  import { compile } from '@hatiolab/figure-model'
5
5
 
6
- import { Domain, getQueryBuilderFromListParams, getRepository, ListParam } from '@things-factory/shell'
6
+ import {
7
+ Domain,
8
+ getQueryBuilderFromListParams,
9
+ getRepository,
10
+ ListParam,
11
+ Refusal,
12
+ SpeakRefusals
13
+ } from '@things-factory/shell'
7
14
 
8
15
  import { Figure } from './figure.js'
9
16
  import { FigureHistory } from './figure-history.js'
@@ -11,6 +18,7 @@ import { inspectSource } from './figure-gate.js'
11
18
  import { FigureInspection, FigureList } from './figure-type.js'
12
19
 
13
20
  @Resolver(Figure)
21
+ @UseMiddleware(SpeakRefusals)
14
22
  export class FigureQuery {
15
23
  @Directive('@privilege(category: "figure", privilege: "query", domainOwnerGranted: true)')
16
24
  @Query(returns => Figure, { nullable: true, description: 'Finds a single figure by its ID.' })
@@ -194,14 +202,28 @@ export class FigureQuery {
194
202
  return null
195
203
  }
196
204
 
197
- const { figure, source: sourceToCompile } = found
205
+ const { figure, source: sourceToCompile, version } = found
198
206
 
199
207
  try {
200
208
  const parsed = typeof sourceToCompile === 'string' ? JSON.parse(sourceToCompile) : sourceToCompile
201
209
  return compile(parsed)
202
210
  } catch (err) {
203
211
  console.error(`[figureBlueprint] 타입 '${type}' (figure id: ${figure.id}) 청사진 컴파일 실패:`, err)
204
- return null
212
+
213
+ /*
214
+ Null says one thing here: nobody published this type (ADR-0063 결정 2 보탬). A released figure
215
+ whose stored source no longer compiles is a different fact, and answering null for it makes the
216
+ board draw a stand-in with the reason nowhere on screen — the author published, the catalogue
217
+ says released, and the only trace is the line above. That is the silence ADR-0053 refuses.
218
+
219
+ The board asks per type inside a Promise.all and collects a rejection into `failed`, so this
220
+ refusal costs that one figure its drawing and the scene still opens.
221
+ */
222
+ throw new Refusal(
223
+ 'figure-release-does-not-compile',
224
+ { type, figureId: figure.id, version },
225
+ 'the released source of {type} does not compile. open it in the modeller and release it again.'
226
+ )
205
227
  }
206
228
  }
207
229
  }
@@ -212,12 +234,15 @@ export class FigureQuery {
212
234
  * A released row gives its own source; a draft row gives the source of its latest released history.
213
235
  * A figure that was never released gives null, because drafts never reach consumers.
214
236
  */
215
- async function releasedSourceOf(domainId: string, type: string): Promise<{ figure: Figure; source: string } | null> {
237
+ async function releasedSourceOf(
238
+ domainId: string,
239
+ type: string
240
+ ): Promise<{ figure: Figure; source: string; version: number } | null> {
216
241
  const figure = await getRepository(Figure).findOne({ where: { domain: { id: domainId }, type } })
217
242
  if (!figure) return null
218
243
 
219
244
  if (figure.state === 'released' && figure.source) {
220
- return { figure, source: figure.source }
245
+ return { figure, source: figure.source, version: figure.version }
221
246
  }
222
247
 
223
248
  const latestReleased = await getRepository(FigureHistory).findOne({
@@ -225,5 +250,9 @@ async function releasedSourceOf(domainId: string, type: string): Promise<{ figur
225
250
  order: { version: 'DESC' }
226
251
  })
227
252
 
228
- return latestReleased?.source ? { figure, source: latestReleased.source } : null
253
+ /* The version travels with the source: a refusal names the edition that does not compile, and the
254
+ row's own version is the draft being edited, not the one the board was handed. */
255
+ return latestReleased?.source
256
+ ? { figure, source: latestReleased.source, version: latestReleased.version }
257
+ : null
229
258
  }
@@ -0,0 +1,138 @@
1
+ /**
2
+ * 이름을 바꾸기 전에 읽는 사실 셋 — **sqlite 를 실제로 들고** 확인한다.
3
+ *
4
+ * 규칙(`renameDraftType`)은 사실을 받아 판단하므로 DB 없이 시험한다. 그런데 그 사실을 읽는
5
+ * 질의가 틀리면 저작자에게는 저장 실패로만 보인다. 실제로 그랬다 — 판본을 `figure: { id }` 로
6
+ * 물었고 `FigureHistory` 는 관계 칸이 아니라 `originalId` 로 가리킨다. TypeORM 이 「그런 속성이
7
+ * 없다」로 던졌고, 순수 시험은 전부 초록이었다.
8
+ *
9
+ * 그래서 여기서는 질의를 **돌려 본다.**
10
+ */
11
+ import type { DataSource } from 'typeorm'
12
+
13
+ const { DataSource: TypeOrmDataSource } = require('typeorm')
14
+ const shell = require('@things-factory/shell')
15
+ const authBase = require('@things-factory/auth-base')
16
+
17
+ let dataSource: DataSource | null = null
18
+ let Figure: any
19
+ let FigureHistory: any
20
+ let renameFacts: any
21
+
22
+ function loadCompiled(modulePath: string): any {
23
+ const { existsSync } = require('fs')
24
+ const { join } = require('path')
25
+ const resolved = join(__dirname, '..', '..', '..', 'dist-server', `${modulePath}.js`)
26
+
27
+ if (!existsSync(resolved)) {
28
+ throw new Error(
29
+ `figure-service 산출물이 없다: ${resolved}\n` +
30
+ `이 시험은 tsc 산출물을 든다. 먼저 빌드할 것:\n` +
31
+ ` yarn workspace @things-factory/figure-service build:server`
32
+ )
33
+ }
34
+
35
+ return require(resolved)
36
+ }
37
+
38
+ beforeAll(async () => {
39
+ Figure = loadCompiled('service/figure/figure').Figure
40
+ FigureHistory = loadCompiled('service/figure/figure-history').FigureHistory
41
+ renameFacts = loadCompiled('service/figure/figure-rename').renameFacts
42
+
43
+ dataSource = new TypeOrmDataSource({
44
+ type: 'sqlite',
45
+ database: ':memory:',
46
+ synchronize: true,
47
+ dropSchema: true,
48
+ namingStrategy: new shell.NamingStrategy(),
49
+ entities: [...shell.entities, ...authBase.entities, Figure, FigureHistory],
50
+ logging: false
51
+ })
52
+
53
+ await dataSource.initialize()
54
+ shell.addDataSource('default', dataSource)
55
+ shell.addDataSource('tx', dataSource)
56
+ })
57
+
58
+ beforeEach(async () => {
59
+ await dataSource!.synchronize(true)
60
+ await seedDomain()
61
+ })
62
+
63
+ afterAll(async () => {
64
+ if (!dataSource) return
65
+ shell.removeDataSource('default')
66
+ shell.removeDataSource('tx')
67
+ await dataSource.destroy()
68
+ dataSource = null
69
+ })
70
+
71
+ let DOMAIN: { id: string }
72
+
73
+ /* 도형이 도메인을 가리킨다 — 행이 없으면 외래키가 먼저 막는다. */
74
+ async function seedDomain() {
75
+ const saved = await dataSource!.getRepository(shell.Domain).save({ name: 'rename-probe', subdomain: 'rename-probe' })
76
+ DOMAIN = { id: saved.id }
77
+ }
78
+
79
+ async function seedFigure(over: Record<string, unknown> = {}) {
80
+ return await dataSource!.getRepository(Figure).save({
81
+ type: 'FIGURE_MU6SZ5DL',
82
+ name: '초안',
83
+ source: '{}',
84
+ state: 'draft',
85
+ version: 0,
86
+ domain: DOMAIN,
87
+ ...over
88
+ })
89
+ }
90
+
91
+ const facts = async (figure: any, wanted: string) =>
92
+ await renameFacts(
93
+ { figures: dataSource!.getRepository(Figure), histories: dataSource!.getRepository(FigureHistory) },
94
+ figure,
95
+ DOMAIN,
96
+ wanted
97
+ )
98
+
99
+ describe('이름을 바꾸기 전에 읽는 사실', () => {
100
+ it('★ 판본 질의가 실제로 돈다 — 칸 이름이 틀리면 여기서 던진다', async () => {
101
+ const figure = await seedFigure()
102
+
103
+ expect(await facts(figure, 'ROBOT_ARM')).toEqual({ version: 0, hasHistory: false, takenByAnother: false })
104
+ })
105
+
106
+ it('★ 판본이 있으면 그것을 읽어 온다', async () => {
107
+ const figure = await seedFigure()
108
+ await dataSource!.getRepository(FigureHistory).save({
109
+ originalId: figure.id,
110
+ version: 1,
111
+ type: figure.type,
112
+ name: figure.name,
113
+ source: '{}',
114
+ domain: DOMAIN
115
+ })
116
+
117
+ expect((await facts(figure, 'ROBOT_ARM')).hasHistory).toBe(true)
118
+ })
119
+
120
+ it('같은 도메인에서 그 이름을 쓰는 다른 도형을 찾는다', async () => {
121
+ const figure = await seedFigure()
122
+ await seedFigure({ type: 'CONVEYOR', name: '컨베이어' })
123
+
124
+ expect((await facts(figure, 'CONVEYOR')).takenByAnother).toBe(true)
125
+ })
126
+
127
+ it('자기 이름은 겹침이 아니다', async () => {
128
+ const figure = await seedFigure()
129
+
130
+ expect((await facts(figure, figure.type)).takenByAnother).toBe(false)
131
+ })
132
+
133
+ it('빈 이름은 겹침을 묻지 않는다 — 규칙이 먼저 거절한다', async () => {
134
+ const figure = await seedFigure()
135
+
136
+ expect((await facts(figure, ' ')).takenByAnother).toBe(false)
137
+ })
138
+ })