@things-factory/figure-service 10.1.110 → 10.1.111

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 (30) hide show
  1. package/dist-server/service/figure/event-subscriber.js +4 -1
  2. package/dist-server/service/figure/event-subscriber.js.map +1 -1
  3. package/dist-server/service/figure/figure-format.d.ts +5 -3
  4. package/dist-server/service/figure/figure-format.js +21 -5
  5. package/dist-server/service/figure/figure-format.js.map +1 -1
  6. package/dist-server/service/figure/figure-history.d.ts +3 -0
  7. package/dist-server/service/figure/figure-history.js +22 -0
  8. package/dist-server/service/figure/figure-history.js.map +1 -1
  9. package/dist-server/service/figure/figure-mutation.d.ts +10 -0
  10. package/dist-server/service/figure/figure-mutation.js +54 -1
  11. package/dist-server/service/figure/figure-mutation.js.map +1 -1
  12. package/dist-server/service/figure/figure-type.d.ts +1 -0
  13. package/dist-server/service/figure/figure-type.js +5 -0
  14. package/dist-server/service/figure/figure-type.js.map +1 -1
  15. package/dist-server/service/figure/figure.d.ts +3 -0
  16. package/dist-server/service/figure/figure.js +16 -0
  17. package/dist-server/service/figure/figure.js.map +1 -1
  18. package/dist-server/tsconfig.tsbuildinfo +1 -1
  19. package/package.json +4 -4
  20. package/server/service/figure/event-subscriber.ts +4 -1
  21. package/server/service/figure/figure-deprecation-db.test.ts +107 -0
  22. package/server/service/figure/figure-format.test.ts +19 -1
  23. package/server/service/figure/figure-format.ts +27 -8
  24. package/server/service/figure/figure-history.ts +20 -0
  25. package/server/service/figure/figure-mutation.ts +45 -1
  26. package/server/service/figure/figure-release-record-db.test.ts +5 -0
  27. package/server/service/figure/figure-type.ts +5 -0
  28. package/server/service/figure/figure.ts +21 -0
  29. package/translations/en.json +4 -1
  30. package/translations/ko.json +4 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@things-factory/figure-service",
3
- "version": "10.1.110",
3
+ "version": "10.1.111",
4
4
  "publishConfig": {
5
5
  "access": "public",
6
6
  "@things-factory:registry": "https://registry.npmjs.org"
@@ -25,15 +25,15 @@
25
25
  "test:templates": "node --test scripts/templates.test.mts"
26
26
  },
27
27
  "dependencies": {
28
- "@hatiolab/figure-model": "^0.2.8",
28
+ "@hatiolab/figure-model": "^0.2.10",
29
29
  "@operato/typeorm-history": "^10.0.0",
30
30
  "@things-factory/ai-client-base": "^10.1.81",
31
- "@things-factory/auth-base": "^10.1.107",
31
+ "@things-factory/auth-base": "^10.1.111",
32
32
  "@things-factory/env": "^10.1.20",
33
33
  "@things-factory/shell": "^10.1.106",
34
34
  "graphql-scalars": "^1.22.4",
35
35
  "graphql-upload": "^15.0.2",
36
36
  "koa-router": "^7.4.0"
37
37
  },
38
- "gitHead": "b68feb6eccbef44af2a6365f384bd852dd70a8f9"
38
+ "gitHead": "f33122c15e5b3e45393129e9418e2c367a273ade"
39
39
  }
@@ -14,7 +14,8 @@ import { FigureHistory } from './figure-history.js'
14
14
  *
15
15
  * 발행 메모는 Figure 의 칸이 아니다. `releaseFigure` 가 저장할 엔티티에 `__releaseComment` 로 얹어
16
16
  * 두면 여기서 판본의 `comment` 로 옮긴다 — 메모는 그 판의 것이지 도형의 것이 아니다. 정본 형식
17
- * (`__releaseFormat`)과 직전 판에 견준 변경(`__releaseChanges`)도 같은 길로 옮긴다(ADR-0104).
17
+ * (`__releaseFormat`), 직전 판에 견준 변경(`__releaseChanges`), 발행 때 잰 값(`__releaseMetrics`)과
18
+ * 윗모습(`__releaseTopView`)도 같은 길로 옮긴다(ADR-0104).
18
19
  */
19
20
  /** What `releaseFigure` laid on the entity for this release, moved onto the history row. */
20
21
  function releaseRecordOnto(history: FigureHistory, entity: Figure): FigureHistory {
@@ -23,6 +24,8 @@ function releaseRecordOnto(history: FigureHistory, entity: Figure): FigureHistor
23
24
  history.assetFormat = e.__releaseFormat ?? null
24
25
  /* [] stays "[]" (nothing changed); null stays null (not compared). */
25
26
  history.changes = e.__releaseChanges == null ? null : JSON.stringify(e.__releaseChanges)
27
+ history.metrics = e.__releaseMetrics == null ? null : JSON.stringify(e.__releaseMetrics)
28
+ history.topView = e.__releaseTopView == null ? null : JSON.stringify(e.__releaseTopView)
26
29
  return history
27
30
  }
28
31
 
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Retiring a figure and its names by language (ADR-0104 decision 6), on sqlite with the history subscriber on.
3
+ * A retired figure stays released and is served as before; retiring writes no history row.
4
+ */
5
+ import type { DataSource } from 'typeorm'
6
+
7
+ const { DataSource: TypeOrmDataSource } = require('typeorm')
8
+ const shell = require('@things-factory/shell')
9
+ const authBase = require('@things-factory/auth-base')
10
+ const { createFigureAsset, addFigureBox, serializeFigureAsset, applyFigureAuthoring, proposeFigureOccupancy } = require('@hatiolab/figure-model')
11
+
12
+ let dataSource: DataSource | null = null
13
+ let Figure: any
14
+ let FigureHistory: any
15
+ let Subscriber: any
16
+ let FigureMutation: any
17
+ let assertNames: any
18
+
19
+ function loadCompiled(modulePath: string): any {
20
+ const { existsSync } = require('fs')
21
+ const { join } = require('path')
22
+ const resolved = join(__dirname, '..', '..', '..', 'dist-server', `${modulePath}.js`)
23
+ if (!existsSync(resolved))
24
+ throw new Error(`figure-service 산출물이 없다: ${resolved}\n먼저 빌드할 것:\n yarn workspace @things-factory/figure-service build:server`)
25
+ return require(resolved)
26
+ }
27
+
28
+ beforeAll(async () => {
29
+ Figure = loadCompiled('service/figure/figure').Figure
30
+ FigureHistory = loadCompiled('service/figure/figure-history').FigureHistory
31
+ Subscriber = loadCompiled('service/figure/event-subscriber').FigureHistoryEntitySubscriber
32
+ ;({ FigureMutation, assertNames } = loadCompiled('service/figure/figure-mutation'))
33
+ dataSource = new TypeOrmDataSource({
34
+ type: 'sqlite',
35
+ database: ':memory:',
36
+ synchronize: true,
37
+ dropSchema: true,
38
+ namingStrategy: new shell.NamingStrategy(),
39
+ entities: [...shell.entities, ...authBase.entities, Figure, FigureHistory],
40
+ subscribers: [Subscriber],
41
+ logging: false
42
+ })
43
+ await dataSource.initialize()
44
+ })
45
+
46
+ afterAll(async () => {
47
+ await dataSource?.destroy()
48
+ dataSource = null
49
+ })
50
+
51
+ const releasable = () => {
52
+ let a = addFigureBox(createFigureAsset('Mixer'), { id: 'body', size: { x: 100, y: 100, z: 100 }, position: { x: 0, y: 50, z: 0 } })
53
+ a = applyFigureAuthoring(a, { kind: 'attach', part: 'body', face: 'bottom', to: { plane: 'mounting-plane' } })
54
+ return serializeFigureAsset(applyFigureAuthoring(a, { kind: 'declare-occupancy', proposal: proposeFigureOccupancy(a, { over: 'range' }), placement: 'floor' }))
55
+ }
56
+
57
+ let seq = 0
58
+ async function released(type: string) {
59
+ const name = `retire-${++seq}`
60
+ const domain = await dataSource!.getRepository(shell.Domain).save({ name, subdomain: name })
61
+ const context = { state: { domain, user: undefined, tx: dataSource!.manager } }
62
+ const figure = await dataSource!.getRepository(Figure).save({ type, name: 'mixer', source: releasable(), state: 'draft', version: 0, domain })
63
+ await new FigureMutation().releaseFigure(figure.id, 'one', context)
64
+ return { figure, context, domain }
65
+ }
66
+ const historyCount = (id: string) => dataSource!.getRepository(FigureHistory).count({ where: { originalId: id } })
67
+
68
+ describe('retiring a figure', () => {
69
+ it('stays released, writes no history row, and names what to use instead', async () => {
70
+ const { figure, context, domain } = await released('OLD_MIXER')
71
+ await dataSource!.getRepository(Figure).save({ type: 'NEW_MIXER', name: 'new', source: releasable(), state: 'draft', version: 0, domain })
72
+ const retired = await new FigureMutation().setFigureDeprecation(figure.id, true, context, 'NEW_MIXER')
73
+ expect([retired.deprecated, retired.supersededBy, retired.state, retired.version]).toEqual([true, 'NEW_MIXER', 'released', 1])
74
+ expect(await historyCount(figure.id)).toBe(1)
75
+ const back = await new FigureMutation().setFigureDeprecation(figure.id, false, context, 'NEW_MIXER')
76
+ expect([back.deprecated, back.supersededBy, back.state]).toEqual([false, null, 'released'])
77
+ expect(await historyCount(figure.id)).toBe(1)
78
+ })
79
+
80
+ it('refuses a replacement that is not there, or itself', async () => {
81
+ const { figure, context } = await released('LONE_MIXER')
82
+ await expect(new FigureMutation().setFigureDeprecation(figure.id, true, context, 'NOWHERE')).rejects.toThrow(/NOWHERE/)
83
+ await expect(new FigureMutation().setFigureDeprecation(figure.id, true, context, 'LONE_MIXER')).rejects.toThrow(/자기 자신/)
84
+ })
85
+ })
86
+
87
+ describe('names by language', () => {
88
+ it('a language code to a name of 1-128 characters', () => {
89
+ expect(() => assertNames({ en: 'Mixer', 'zh-Hans': '搅拌机' })).not.toThrow()
90
+ expect(() => assertNames({ English: 'Mixer' })).toThrow(/English/)
91
+ expect(() => assertNames({ en: '' })).toThrow(/en/)
92
+ expect(() => assertNames({ en: 'x'.repeat(129) })).toThrow(/en/)
93
+ expect(() => assertNames(['en'])).toThrow()
94
+ })
95
+
96
+ it('kept on the figure and copied onto the release it goes out with', async () => {
97
+ const name = `names-${++seq}`
98
+ const domain = await dataSource!.getRepository(shell.Domain).save({ name, subdomain: name })
99
+ const context = { state: { domain, user: undefined, tx: dataSource!.manager } }
100
+ const figure = await dataSource!.getRepository(Figure).save({ type: 'NAMED', name: 'mixer', source: releasable(), state: 'draft', version: 0, domain })
101
+ await new FigureMutation().updateFigure(figure.id, { names: { en: 'Mixer', ko: '믹서' } }, context)
102
+ await new FigureMutation().releaseFigure(figure.id, 'one', context)
103
+ const [row] = await dataSource!.getRepository(FigureHistory).find({ where: { originalId: figure.id } })
104
+ expect(row.names).toEqual({ en: 'Mixer', ko: '믹서' })
105
+ await expect(new FigureMutation().updateFigure(figure.id, { names: { English: 'x' } }, context)).rejects.toThrow(/English/)
106
+ })
107
+ })
@@ -330,7 +330,12 @@ describe('figure-format — 판을 알아보는 문', () => {
330
330
  return serializeFigureAsset(applyFigureAuthoring(asset, { kind: 'declare-occupancy', proposal: proposeFigureOccupancy(asset, { over: 'range' }), placement: 'floor' }))
331
331
  }
332
332
  it('a first release: its format, and nothing changed', () => {
333
- expect(releaseRecordOf(serializeFigureAsset(figureAsset()))).toEqual({ format: 'figure-asset-1', changes: [] })
333
+ const record = releaseRecordOf(serializeFigureAsset(figureAsset()))
334
+ expect([record.format, record.changes]).toEqual(['figure-asset-1', []])
335
+ /* Not sized from the instance: nothing measured across sizes, only where its origin is. Its plan is its one body from above. */
336
+ expect(record.metrics).toEqual({ origin: { centred: true, off: [] } })
337
+ expect(record.topView.shapes.map(s => s.id)).toEqual(['body'])
338
+ expect(record.topView.size).toBeNull()
334
339
  })
335
340
  it('the same again changes nothing; a wider room is said and does not block', () => {
336
341
  const v1 = serializeFigureAsset(figureAsset())
@@ -349,4 +354,17 @@ describe('figure-format — 판을 알아보는 문', () => {
349
354
  for (const dict of [KO, EN]) for (const field of ['message', 'why']) expect(dict[`figure.gate.contract.${code}.${field}`]).toBeTruthy()
350
355
  })
351
356
  })
357
+
358
+ /* ADR-0104 decision 4: off the middle of its room is said at release, never refused. */
359
+ it('an origin off the middle of the room is a warning, and the release goes on', () => {
360
+ let asset = createFigureAsset('Door')
361
+ asset = addFigureBox(asset, { id: 'leaf', size: { x: 100, y: 200, z: 40 }, position: { x: 50, y: 100, z: 0 } })
362
+ asset = applyFigureAuthoring(asset, { kind: 'declare-occupancy', proposal: proposeFigureOccupancy(asset), placement: 'floor' })
363
+ const result = inspectSource(serializeFigureAsset(asset), null, undefined, 'DOOR')
364
+ const off = result.findings.find(f => f.code === 'origin-off-centre')!
365
+ expect(off.blocking).toBe(false)
366
+ expect(off.message).toMatch(/X 50 mm/)
367
+ expect(result.findings.filter(f => f.blocking).map(f => f.code)).not.toContain('origin-off-centre')
368
+ expect(inspectSource(serializeFigureAsset(figureAsset()), null, undefined, 'MIXER').findings.map(f => f.code)).not.toContain('origin-off-centre')
369
+ })
352
370
  })
@@ -3,8 +3,8 @@
3
3
  * board (`consumablePayloadOf`). The format is `figure-asset-1`, and the asset says so in its `version`; a source
4
4
  * that does not is refused by name, because a source the server cannot identify is one it must not save.
5
5
  */
6
- import { compileFigureAsset, inspectFigureAsset, isPlaceholderType, parseFigureAsset, serializeFigureAsset, figureContractChanges, figureCostOf, figureScoreOf } from '@hatiolab/figure-model'
7
- import type { FigureAsset, FigureContractChange, FigureContractError, FigureViolation } from '@hatiolab/figure-model'
6
+ import { compileFigureAsset, inspectFigureAsset, isPlaceholderType, parseFigureAsset, serializeFigureAsset, figureContractChanges, figureCostOf, figureOriginCentredOf, figureReleaseMetricsOf, figureScoreOf, figureTopViewOf } from '@hatiolab/figure-model'
7
+ import type { FigureAsset, FigureContractChange, FigureContractError, FigureReleaseMetrics, FigureTopView, FigureViolation } from '@hatiolab/figure-model'
8
8
 
9
9
  import type { FigureFinding } from './figure-type.js'
10
10
 
@@ -280,6 +280,21 @@ export function inspectSource(
280
280
  blocking: false
281
281
  })
282
282
  }
283
+ /*
284
+ Said, never refused (ADR-0104 decision 4): the origin is where the figure is fastened, and a door fastened at its
285
+ hinge is a right model. A drawing that places figures by the middle of their room reads this before it pins one.
286
+ */
287
+ const origin = figureOriginCentredOf(asset!)
288
+ if (origin && !origin.centred) {
289
+ const off = origin.off.map(o => `${o.axis.toUpperCase()} ${o.mm} mm`).join(', ')
290
+ findings.push({
291
+ code: 'origin-off-centre',
292
+ message: say('figure.gate.origin-off-centre.message', { off }, `The origin is off the middle of the declared room: ${off}.`),
293
+ why: say('figure.gate.origin-off-centre.why', {}, 'A drawing that places figures by the middle of their room puts this one that far from where it is drawn.'),
294
+ how: say('figure.gate.origin-off-centre.how', {}, 'Keep it when the figure is fastened there, as a door at its hinge. Move the origin to the middle to place it by its room.'),
295
+ blocking: false
296
+ })
297
+ }
283
298
  if (releasedJson) findings.push(...figureContractFindings(asset!, releasedJson, say))
284
299
  return { blocked: findings.some(finding => finding.blocking), findings }
285
300
  }
@@ -450,21 +465,25 @@ function figureContractWording(change: FigureContractChange, say: Say): Pick<Gat
450
465
  }
451
466
 
452
467
  /**
453
- * What a release records about itself (ADR-0104 decision 5): the format its asset is written in, and what changed
454
- * against the previous release -- the kernel's own comparison, kept as it was then so a consumer that pins releases
468
+ * What a release records about itself (ADR-0104 decision 5): the format its asset is written in, what it measured
469
+ * (its cost at its largest, whether resizing is stretching), its plan from above, and what changed against the previous release -- the kernel's own comparison, kept as it was then so a consumer that pins releases
455
470
  * reads it rather than computing it again. `[]` for a first release; `null` when the previous release could not be
456
471
  * read, which is a different fact from "nothing changed".
457
472
  */
458
- export function releaseRecordOf(sourceJson: string, releasedJson?: string | null): { format: string; changes: FigureContractChange[] | null } {
473
+ export function releaseRecordOf(
474
+ sourceJson: string,
475
+ releasedJson?: string | null
476
+ ): { format: string; changes: FigureContractChange[] | null; metrics: FigureReleaseMetrics; topView: FigureTopView } {
459
477
  const asset = parseFigureAsset(sourceJson)
460
- if (!releasedJson) return { format: asset.version, changes: [] }
478
+ const measured = { format: asset.version, metrics: figureReleaseMetricsOf(asset), topView: figureTopViewOf(asset, null) }
479
+ if (!releasedJson) return { ...measured, changes: [] }
461
480
  let before: FigureAsset
462
481
  try {
463
482
  before = parseFigureAsset(releasedJson)
464
483
  } catch {
465
- return { format: asset.version, changes: null }
484
+ return { ...measured, changes: null }
466
485
  }
467
- return { format: asset.version, changes: figureContractChanges(before, asset) }
486
+ return { ...measured, changes: figureContractChanges(before, asset) }
468
487
  }
469
488
 
470
489
  /**
@@ -1,4 +1,5 @@
1
1
  import { Field, ID, ObjectType, Float, Int } from 'type-graphql'
2
+ import { GraphQLJSON } from 'graphql-scalars'
2
3
  import { Column, Entity, Index, ManyToOne, PrimaryGeneratedColumn, RelationId } from 'typeorm'
3
4
 
4
5
  import {
@@ -55,6 +56,10 @@ export class FigureHistory implements HistoryEntityInterface<Figure> {
55
56
  @Field({ nullable: true, description: 'The description at this version.' })
56
57
  description?: string
57
58
 
59
+ @Column('simple-json', { nullable: true, default: null })
60
+ @Field(type => GraphQLJSON, { nullable: true, description: 'Display names by language at this version.' })
61
+ names?: Record<string, string>
62
+
58
63
  @Column({ nullable: true })
59
64
  @Field({ nullable: true, description: 'The classification code at this version.' })
60
65
  category?: string
@@ -110,6 +115,21 @@ export class FigureHistory implements HistoryEntityInterface<Figure> {
110
115
  })
111
116
  changes?: string
112
117
 
118
+ @Column({ nullable: true, ...longTextColumn() })
119
+ @Field({
120
+ nullable: true,
121
+ description:
122
+ 'What this version measured once, as JSON (figure-model figureReleaseMetricsOf): { atLargest?: { size, triangles, groups }, linear?: { scalable, parts, probe } }. Only for a figure that takes its size from the instance; {} otherwise.'
123
+ })
124
+ metrics?: string
125
+
126
+ @Column({ nullable: true, ...longTextColumn() })
127
+ @Field({
128
+ nullable: true,
129
+ description: 'The plan from above at the authored size, as JSON (figure-model figureTopViewOf): { shapes: [{ id, color, outline, holes, top, transparent }], size }.'
130
+ })
131
+ topView?: string
132
+
113
133
  @Column({ nullable: true })
114
134
  @Field({ nullable: true, description: 'When the original figure was created.' })
115
135
  createdAt?: Date
@@ -17,6 +17,15 @@ import { parseSource } from './figure-format.js'
17
17
  import { ProposeFigureFailure, proposeFigureSteps } from './figure-propose-steps.js'
18
18
  import { prepareFigureWrite, compareAndSwapFigure, pictureOf } from './figure-write.js'
19
19
 
20
+ /** Display names by language: a language code to a name of 1-128 characters. Refused whole, with the bad entry named. */
21
+ export function assertNames(names: unknown): asserts names is Record<string, string> {
22
+ if (!names || typeof names !== 'object' || Array.isArray(names)) throw new Error('names 는 { 언어: 이름 } 이어야 한다')
23
+ for (const [lang, name] of Object.entries(names)) {
24
+ if (!/^[a-z]{2,3}(-[A-Za-z0-9]{2,8})*$/.test(lang)) throw new Error(`'${lang}' 는 언어 코드가 아니다`)
25
+ if (typeof name !== 'string' || !name.trim() || name.length > 128) throw new Error(`'${lang}' 의 이름은 1~128자 글이어야 한다`)
26
+ }
27
+ }
28
+
20
29
  @Resolver(Figure)
21
30
  export class FigureMutation {
22
31
  @Directive('@transaction')
@@ -174,6 +183,8 @@ export class FigureMutation {
174
183
  행과 정본 둘 다 고친다. 정본 안에도 `type` 이 있어서 한쪽만 고치면 저장된 JSON 과
175
184
  행이 다른 이름을 말하고, 발행할 때 어느 쪽이 도면에 적히는지가 갈린다.
176
185
  */
186
+ if (patch.names !== undefined) assertNames(patch.names)
187
+
177
188
  let renamed: string | undefined
178
189
  if (patch.type !== undefined && patch.type !== figure.type) {
179
190
  const change = await this.renameType(figure, domain, patch.type, source, tx)
@@ -338,7 +349,9 @@ export class FigureMutation {
338
349
  /* 판본 구독자가 이것들을 판본의 comment · assetFormat · changes 로 옮긴다. Figure 의 칸이 아니다. */
339
350
  __releaseComment: comment ?? null,
340
351
  __releaseFormat: record.format,
341
- __releaseChanges: record.changes
352
+ __releaseChanges: record.changes,
353
+ __releaseMetrics: record.metrics,
354
+ __releaseTopView: record.topView
342
355
  } as any)
343
356
  }
344
357
 
@@ -408,6 +421,37 @@ export class FigureMutation {
408
421
  return true
409
422
  }
410
423
 
424
+ /**
425
+ * 퇴역시키거나 되살린다(ADR-0104 결정 6).
426
+ *
427
+ * 표시일 뿐이다. 발행판은 그대로 남고 받는 쪽은 그대로 받는다 — 발행판은 지우지 않는다. 그래서 **발행을 풀지 않는다**:
428
+ * 행을 엔티티로 저장하지 않고 질의로 고친다. 엔티티로 저장하면 발행된 행이라 판본 구독자가 같은 번호로 판본을 또
429
+ * 뜬다. 대신 쓸 것(`supersededBy`)은 같은 도메인(또는 상위)에 있는 다른 type 이어야 한다.
430
+ */
431
+ @Directive('@transaction')
432
+ @Directive('@privilege(category: "figure", privilege: "mutation", domainOwnerGranted: true)')
433
+ @Mutation(returns => Figure, { description: 'Retires a figure or brings it back. It stays released and served; supersededBy names the type to use instead.' })
434
+ async setFigureDeprecation(
435
+ @Arg('id') id: string,
436
+ @Arg('deprecated') deprecated: boolean,
437
+ @Ctx() context: ResolverContext,
438
+ @Arg('supersededBy', { nullable: true }) supersededBy?: string
439
+ ): Promise<Figure> {
440
+ const { domain, tx } = context.state
441
+ const repository = tx.getRepository(Figure)
442
+ const scope = { id: In([domain.id, domain.parentId].filter(Boolean)) }
443
+ const figure = await repository.findOne({ where: { domain: scope, id } })
444
+ if (!figure) throw new Error(`Figure ${id} 를 찾을 수 없다`)
445
+ const instead = deprecated ? supersededBy?.trim() || null : null
446
+ if (instead) {
447
+ if (instead === figure.type) throw new Error(`Figure '${figure.type}' 는 자기 자신으로 대신할 수 없다`)
448
+ const other = await repository.findOne({ where: { domain: scope, type: instead } })
449
+ if (!other) throw new Error(`대신 쓸 Figure '${instead}' 가 이 도메인에 없다`)
450
+ }
451
+ await repository.createQueryBuilder().update(Figure).set({ deprecated, supersededBy: instead }).where('id = :id', { id }).callListeners(false).execute()
452
+ return await repository.findOneOrFail({ where: { id } })
453
+ }
454
+
411
455
  /**
412
456
  * 저작 보조에게 제안을 시킨다(ADR-0094).
413
457
  *
@@ -63,6 +63,8 @@ describe('a release record on its history row', () => {
63
63
  expect(row.assetFormat).toBe('figure-asset-1')
64
64
  expect(JSON.parse(row.changes)).toEqual(changes)
65
65
  expect(row.comment).toBe('first')
66
+ expect(row.metrics).toBeNull()
67
+ expect(row.topView).toBeNull()
66
68
  })
67
69
  it('[] stays nothing changed, null stays not compared', async () => {
68
70
  expect((await release({ __releaseFormat: 'figure-asset-1', __releaseChanges: [] })).changes).toBe('[]')
@@ -90,5 +92,8 @@ describe('releaseFigure records it', () => {
90
92
  expect(rows[0].changes).toBe('[]')
91
93
  const codes = JSON.parse(rows[1].changes).map((c: any) => c.code)
92
94
  expect(codes).toContain('occupancy-changed')
95
+ /* What each release measured, and its plan from above, are on its own row. */
96
+ expect(JSON.parse(rows[0].metrics)).toEqual({ origin: { centred: true, off: [] } })
97
+ expect(JSON.parse(rows[1].topView).shapes.map((s: any) => s.id)).toEqual(['body'])
93
98
  })
94
99
  })
@@ -1,5 +1,6 @@
1
1
  import { FigureReplacedDraft } from './figure-replaced-draft.js'
2
2
  import { Field, InputType, Int, ObjectType } from 'type-graphql'
3
+ import { GraphQLJSON } from 'graphql-scalars'
3
4
 
4
5
  import { Figure } from './figure.js'
5
6
 
@@ -61,6 +62,10 @@ export class FigurePatch {
61
62
  @Field({ nullable: true, description: 'The new description.' })
62
63
  description?: string
63
64
 
65
+ /* 언어별 표시 이름. 안 준 것은 그대로, `{}` 는 전부 지운다. */
66
+ @Field(type => GraphQLJSON, { nullable: true, description: 'Display names by language. {} clears them; omitting the field leaves them.' })
67
+ names?: Record<string, string>
68
+
64
69
  @Field({ nullable: true, description: 'The new classification code.' })
65
70
  category?: string
66
71
 
@@ -10,6 +10,7 @@ import {
10
10
  UpdateDateColumn
11
11
  } from 'typeorm'
12
12
  import { Field, Float, ID, Int, ObjectType } from 'type-graphql'
13
+ import { GraphQLJSON } from 'graphql-scalars'
13
14
 
14
15
  import { User } from '@things-factory/auth-base'
15
16
  import { Domain, longTextColumn } from '@things-factory/shell'
@@ -84,6 +85,26 @@ export class Figure {
84
85
  @Field({ nullable: true, description: 'A detailed description for the figure.' })
85
86
  description?: string
86
87
 
88
+ /*
89
+ 언어별 표시 이름(ADR-0104 결정 6). 없는 언어는 `name` 을 보인다. 식별자는 여전히 `type` 이다 — 이름은 몇 번을
90
+ 바꿔도 도면이 가리키는 것이 달라지지 않는다.
91
+ */
92
+ @Column('simple-json', { nullable: true, default: null })
93
+ @Field(type => GraphQLJSON, { nullable: true, description: 'Display names by language, e.g. { "en": "...", "ko": "..." }. A language not here shows `name`.' })
94
+ names?: Record<string, string>
95
+
96
+ /*
97
+ 퇴역(ADR-0104 결정 6). 표시일 뿐이다 — 발행판은 지우지 않고 받는 쪽은 그대로 받는다. 무엇으로 대신하라는지는
98
+ `supersededBy`(그 도메인의 type)가 말한다. 퇴역은 저장이 아니라 `setFigureDeprecation` 이 정하고, 발행을 풀지 않는다.
99
+ */
100
+ @Column({ nullable: true, default: false })
101
+ @Field({ nullable: true, description: 'Retired: still released and still served, but not to be chosen for new work.' })
102
+ deprecated?: boolean
103
+
104
+ @Column({ nullable: true })
105
+ @Field({ nullable: true, description: 'The type to use instead of a retired figure.' })
106
+ supersededBy?: string
107
+
87
108
  /**
88
109
  * 분류 하나. **팔레트에서 묶어 보이는 기준이다.**
89
110
  *
@@ -75,5 +75,8 @@
75
75
  "figure.gate.contract.public-input-range-narrowed.why": "An instance may have set a value outside the new range. Which values are placed is not recorded.",
76
76
  "figure.gate.contract.public-input-added.message": "'{input}' can now be set on a board.",
77
77
  "figure.gate.contract.public-input-added.why": "Placed instances that give no value stand at its default.",
78
- "error.figure-release-not-found": "{type} has no release {version}. its latest release is {latest}."
78
+ "error.figure-release-not-found": "{type} has no release {version}. its latest release is {latest}.",
79
+ "figure.gate.origin-off-centre.message": "The origin is off the middle of the declared room: {off}.",
80
+ "figure.gate.origin-off-centre.why": "A drawing that places figures by the middle of their room puts this one that far from where it is drawn.",
81
+ "figure.gate.origin-off-centre.how": "Keep it when the figure is fastened there, as a door at its hinge. Move the origin to the middle to place it by its room."
79
82
  }
@@ -75,5 +75,8 @@
75
75
  "figure.gate.contract.public-input-range-narrowed.why": "새 범위 밖의 값을 준 인스턴스가 있을 수 있습니다. 어떤 값이 놓였는지는 기록하지 않습니다.",
76
76
  "figure.gate.contract.public-input-added.message": "'{input}' 값을 이제 보드에서 정할 수 있습니다.",
77
77
  "figure.gate.contract.public-input-added.why": "값을 주지 않은 인스턴스는 기본값으로 섭니다.",
78
- "error.figure-release-not-found": "{type} 에는 {version} 번 발행판이 없습니다. 최신 발행판은 {latest} 번입니다."
78
+ "error.figure-release-not-found": "{type} 에는 {version} 번 발행판이 없습니다. 최신 발행판은 {latest} 번입니다.",
79
+ "figure.gate.origin-off-centre.message": "원점이 차지 공간 가운데에서 비켜 있습니다: {off}.",
80
+ "figure.gate.origin-off-centre.why": "공간 가운데로 도형을 놓는 도면에서는 그린 자리에서 그만큼 떨어져 놓입니다.",
81
+ "figure.gate.origin-off-centre.how": "문의 경첩처럼 붙이는 자리라면 그대로 둡니다. 공간 가운데로 놓이게 하려면 원점을 가운데로 옮깁니다."
79
82
  }