@things-factory/figure-service 10.1.110 → 10.1.112

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 (61) hide show
  1. package/dist-server/index.js +15 -0
  2. package/dist-server/index.js.map +1 -1
  3. package/dist-server/routers/figure-thumbnail-router.d.ts +12 -3
  4. package/dist-server/routers/figure-thumbnail-router.js +36 -30
  5. package/dist-server/routers/figure-thumbnail-router.js.map +1 -1
  6. package/dist-server/service/figure/event-subscriber.js +4 -1
  7. package/dist-server/service/figure/event-subscriber.js.map +1 -1
  8. package/dist-server/service/figure/figure-format.d.ts +5 -3
  9. package/dist-server/service/figure/figure-format.js +21 -5
  10. package/dist-server/service/figure/figure-format.js.map +1 -1
  11. package/dist-server/service/figure/figure-history.d.ts +4 -0
  12. package/dist-server/service/figure/figure-history.js +27 -0
  13. package/dist-server/service/figure/figure-history.js.map +1 -1
  14. package/dist-server/service/figure/figure-mutation.d.ts +10 -0
  15. package/dist-server/service/figure/figure-mutation.js +65 -9
  16. package/dist-server/service/figure/figure-mutation.js.map +1 -1
  17. package/dist-server/service/figure/figure-replaced-draft.d.ts +1 -0
  18. package/dist-server/service/figure/figure-replaced-draft.js +5 -0
  19. package/dist-server/service/figure/figure-replaced-draft.js.map +1 -1
  20. package/dist-server/service/figure/figure-revert.js +9 -4
  21. package/dist-server/service/figure/figure-revert.js.map +1 -1
  22. package/dist-server/service/figure/figure-thumbnail-store.d.ts +35 -0
  23. package/dist-server/service/figure/figure-thumbnail-store.js +96 -0
  24. package/dist-server/service/figure/figure-thumbnail-store.js.map +1 -0
  25. package/dist-server/service/figure/figure-thumbnail-upgrade.d.ts +8 -0
  26. package/dist-server/service/figure/figure-thumbnail-upgrade.js +56 -0
  27. package/dist-server/service/figure/figure-thumbnail-upgrade.js.map +1 -0
  28. package/dist-server/service/figure/figure-type.d.ts +1 -0
  29. package/dist-server/service/figure/figure-type.js +5 -0
  30. package/dist-server/service/figure/figure-type.js.map +1 -1
  31. package/dist-server/service/figure/figure-write.d.ts +14 -10
  32. package/dist-server/service/figure/figure-write.js +9 -4
  33. package/dist-server/service/figure/figure-write.js.map +1 -1
  34. package/dist-server/service/figure/figure.d.ts +4 -0
  35. package/dist-server/service/figure/figure.js +21 -0
  36. package/dist-server/service/figure/figure.js.map +1 -1
  37. package/dist-server/service/figure/index.d.ts +1 -1
  38. package/dist-server/service/index.d.ts +1 -1
  39. package/dist-server/tsconfig.tsbuildinfo +1 -1
  40. package/package.json +5 -4
  41. package/server/index.ts +15 -0
  42. package/server/routers/figure-thumbnail-router.ts +40 -36
  43. package/server/service/figure/event-subscriber.ts +4 -1
  44. package/server/service/figure/figure-deprecation-db.test.ts +107 -0
  45. package/server/service/figure/figure-format.test.ts +19 -1
  46. package/server/service/figure/figure-format.ts +27 -8
  47. package/server/service/figure/figure-history.ts +29 -0
  48. package/server/service/figure/figure-mutation.ts +56 -10
  49. package/server/service/figure/figure-release-record-db.test.ts +5 -0
  50. package/server/service/figure/figure-replaced-draft.ts +9 -0
  51. package/server/service/figure/figure-revert-db.test.ts +6 -5
  52. package/server/service/figure/figure-revert.ts +9 -4
  53. package/server/service/figure/figure-thumbnail-db.test.ts +218 -0
  54. package/server/service/figure/figure-thumbnail-store.ts +107 -0
  55. package/server/service/figure/figure-thumbnail-upgrade.pg.test.ts +93 -0
  56. package/server/service/figure/figure-thumbnail-upgrade.ts +70 -0
  57. package/server/service/figure/figure-type.ts +5 -0
  58. package/server/service/figure/figure-write.ts +15 -4
  59. package/server/service/figure/figure.ts +30 -0
  60. package/translations/en.json +4 -1
  61. 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.112",
4
4
  "publishConfig": {
5
5
  "access": "public",
6
6
  "@things-factory:registry": "https://registry.npmjs.org"
@@ -25,15 +25,16 @@
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/attachment-base": "^10.1.112",
32
+ "@things-factory/auth-base": "^10.1.111",
32
33
  "@things-factory/env": "^10.1.20",
33
34
  "@things-factory/shell": "^10.1.106",
34
35
  "graphql-scalars": "^1.22.4",
35
36
  "graphql-upload": "^15.0.2",
36
37
  "koa-router": "^7.4.0"
37
38
  },
38
- "gitHead": "b68feb6eccbef44af2a6365f384bd852dd70a8f9"
39
+ "gitHead": "e300d1b60b98ccb80380afe8df6d2807b1bcff91"
39
40
  }
package/server/index.ts CHANGED
@@ -20,6 +20,21 @@ process.on('bootstrap-module-start' as any, () => {
20
20
  console.log(`[figure-service:bootstrap] recipe templates ${templates.length}${refused.length ? `, left out: ${refused.join(' | ')}` : ''}`)
21
21
  })
22
22
 
23
+ /*
24
+ * The pictures an install kept as base64 move into attachment-base by their content, once, at start (ADR-0104 decision
25
+ * 6). In the boot's one line: it writes, and sqlite holds one runner. A failure is said and the server goes on.
26
+ */
27
+ import { getDataSource, inBootTurn } from '@things-factory/shell'
28
+ import { moveBase64Thumbnails } from './service/figure/figure-thumbnail-upgrade.js'
29
+ process.on('bootstrap-module-start' as any, () => {
30
+ inBootTurn(() => moveBase64Thumbnails(getDataSource()))
31
+ .then(moved => {
32
+ const any = moved.filter(m => m.moved || m.kept)
33
+ if (any.length) console.log(`[figure-service:bootstrap] thumbnails moved: ${any.map(m => `${m.table} ${m.moved}${m.kept ? ` (kept as base64: ${m.kept})` : ''}`).join(', ')}`)
34
+ })
35
+ .catch(error => console.error(`[figure-service] 썸네일을 옮기지 못했다 — ${error?.message ?? error}`))
36
+ })
37
+
23
38
  export * from './service/index.js'
24
39
  export * from './migrations/index.js'
25
40
  export { registerFigureToolCategories } from './service/figure/figure-tools.js'
@@ -7,6 +7,7 @@ import { getRepository } from '@things-factory/shell'
7
7
  import { readFileSync } from 'fs'
8
8
 
9
9
  import { Figure } from '../service/figure/figure.js'
10
+ import { thumbnailAttachmentOf, thumbnailFiles, type ThumbnailFiles } from '../service/figure/figure-thumbnail-store.js'
10
11
  import { recipeTemplates } from '../service/figure-recipe/figure-recipe-templates.js'
11
12
 
12
13
  /**
@@ -14,7 +15,7 @@ import { recipeTemplates } from '../service/figure-recipe/figure-recipe-template
14
15
  *
15
16
  * ## 왜 필요한가
16
17
  *
17
- * 썸네일은 base64 문자열로 칸에 들어 있다(`Board.thumbnail` 과 같은 방식). 그것을 목록 질의가
18
+ * 썸네일은 attachment-base 가 내용 해시로 갖는다(ADR-0104 결정 6). 옛날에는 base64 문자열로 칸에 있었고, 그것을 목록 질의가
18
19
  * 함께 실어 오면 카탈로그를 열 때마다 그림 전부가 JSON 안에 담겨 다시 온다 — data URL 은
19
20
  * 브라우저가 캐시할 수 없다. [확인함] 표본 14개에 227KB, 한 장 최대 20KB.
20
21
  *
@@ -29,21 +30,11 @@ import { recipeTemplates } from '../service/figure-recipe/figure-recipe-template
29
30
  * `/thumbnail/:id` 로 두면 먼저 등록된 쪽이 이기고, 남의 표에서 id 를 찾아 404 를 낸다.
30
31
  * 2. **행 전체를 읽는다.** board 는 `findOneBy` 로 행을 다 읽는다. figure 행에는 정본(`source`)이
31
32
  * 붙어 있어, 그림 한 장에 정본까지 읽게 된다. 필요한 칸만 고른다.
32
- * 3. **png 로 못 박는다.** figure 썸네일은 webp 다(모델러가 그렇게 만든다). data URL 이 스스로
33
- * 말하는 형식을 쓴다 — 못 박으면 브라우저가 확장자와 다른 바이트를 받는다.
33
+ * 3. **png 로 못 박는다.** figure 썸네일은 webp 다(모델러가 그렇게 만든다). 저장할 때 data URL 이 스스로
34
+ * 말한 형식을 attachment 의 mimetype 으로 쓴다 — 못 박으면 브라우저가 확장자와 다른 바이트를 받는다.
34
35
  */
35
36
  export const figureThumbnailRouter = new Router()
36
37
 
37
- /** `data:image/webp;base64,....` → 형식과 바이트. 아니면 undefined. */
38
- function decodeDataUrl(dataUrl: string): { type: string; buffer: Buffer } | undefined {
39
- const matched = /^data:([^;,]+);base64,(.*)$/s.exec(dataUrl)
40
- if (!matched) {
41
- return undefined
42
- }
43
-
44
- return { type: matched[1], buffer: Buffer.from(matched[2], 'base64') }
45
- }
46
-
47
38
  figureThumbnailRouter.get('/figure-thumbnail/:id', async (context, next) => {
48
39
  const { domain, user, unsafeIP, prohibitedPrivileges } = context.state
49
40
 
@@ -71,46 +62,59 @@ figureThumbnailRouter.get('/figure-thumbnail/:id', async (context, next) => {
71
62
  return
72
63
  }
73
64
 
74
- const { id } = context.params
65
+ await sendFigureThumbnail(context, next, context.params.id, domain)
66
+ })
75
67
 
68
+ /**
69
+ * The picture of a figure the caller may read, by its hash (ADR-0104 decision 6). Exported for tests, which give
70
+ * their own `files`.
71
+ */
72
+ export async function sendFigureThumbnail(
73
+ context: any,
74
+ next: () => Promise<any>,
75
+ id: string,
76
+ domain: { id: string; parentId?: string },
77
+ files: ThumbnailFiles = thumbnailFiles()
78
+ ): Promise<void> {
76
79
  /*
77
80
  읽기는 부모 도메인까지 본다 — `figure` 질의와 같은 범위다. 물려받은 형상을 목록에는 보여 주고
78
81
  그림만 404 를 내면, 화면은 「그림이 아직 없다」고 거짓말하게 된다.
79
82
  */
80
83
  const found = await getRepository(Figure).findOne({
81
84
  where: { domain: { id: In([domain.id, domain.parentId].filter(Boolean)) }, id },
82
- select: { id: true, thumbnail: true, thumbnailUpdatedAt: true }
85
+ relations: { domain: true },
86
+ select: { id: true, thumbnailHash: true, thumbnailUpdatedAt: true, domain: { id: true } }
83
87
  })
84
-
85
- const decoded = found?.thumbnail ? decodeDataUrl(found.thumbnail) : undefined
86
-
87
- if (!decoded) {
88
+ if (!found?.thumbnailHash) {
88
89
  context.status = 404
89
90
  return
90
91
  }
91
92
 
92
93
  /*
93
- **개인 캐시만 허락한다.** 이 그림은 권한을 본 뒤에 내주는 것이므로 공용 캐시(프록시)에 남으면
94
- 안 된다. 부르는 쪽이 `?v=` 로 판을 가리므로 오래 두어도 낡은 그림이 남지 않고, `v` 없이 부른
95
- 경우를 위해 시각을 함께 실어 다시 물어볼 수 있게 한다.
94
+ 그림은 **그 figure 의 도메인**에서 찾는다 — 묻는 도메인이 아니다(판정 조건 4). 해시가 같아도 다른 도메인의 행을
95
+ 집지 않는다. 물려받은 figure 의 그림은 부모 도메인에 있다.
96
96
  */
97
- const stamp = found!.thumbnailUpdatedAt
97
+ const picture = await thumbnailAttachmentOf({ getRepository }, found.domain!.id, found.thumbnailHash)
98
+ if (!picture) {
99
+ context.status = 404
100
+ return
101
+ }
98
102
 
99
- context.type = decoded.type
103
+ /*
104
+ **개인 캐시만 허락한다.** 이 그림은 권한을 본 뒤에 내주는 것이므로 공용 캐시(프록시)에 남으면 안 된다 —
105
+ attachment 의 공개 경로가 쓰는 `public` 을 따라 쓰지 않는다(판정 조건 1). 같은 내용은 같은 해시이므로 ETag 가
106
+ 곧 내용이다. 부르는 쪽이 `?v=` 로 판을 가리므로 오래 두어도 낡은 그림이 남지 않는다.
107
+ */
108
+ context.type = picture.mimetype
100
109
  context.set('Cache-Control', 'private, max-age=31536000')
101
- if (stamp) {
102
- context.set('Last-Modified', new Date(stamp).toUTCString())
103
- context.set('ETag', `"${new Date(stamp).getTime()}"`)
104
-
105
- /* 브라우저가 가진 것이 아직 그것이면 바이트를 보내지 않는다. */
106
- if (context.fresh) {
107
- context.status = 304
108
- return
109
- }
110
+ context.set('ETag', `"${found.thumbnailHash}"`)
111
+ if (found.thumbnailUpdatedAt) context.set('Last-Modified', new Date(found.thumbnailUpdatedAt).toUTCString())
112
+ if (context.fresh) {
113
+ context.status = 304
114
+ return
110
115
  }
111
-
112
- context.body = decoded.buffer
113
- })
116
+ await files.send(context, picture.path!, next)
117
+ }
114
118
 
115
119
  /**
116
120
  * A recipe template's thumbnail (ADR-0095 amended): the file drawn from its model, served behind the same door as the
@@ -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
@@ -75,6 +80,15 @@ export class FigureHistory implements HistoryEntityInterface<Figure> {
75
80
  @Field({ nullable: true, description: 'The thumbnail as it looked at this version.' })
76
81
  thumbnail?: string
77
82
 
83
+ /*
84
+ * 그림의 내용 해시(SHA-256). 그림 자체는 attachment-base 가 갖고(refType `figure-thumbnail`, refBy 이 해시),
85
+ * `/figure-thumbnail/:id` 가 권한을 본 뒤 내준다(ADR-0104 결정 6). `thumbnail` 칸은 옛 base64 가 남은 행을
86
+ * 기동 때 옮기기 위한 칸이다 — 옮긴 뒤에는 비어 있고, 새 그림은 그 칸에 쓰지 않는다.
87
+ */
88
+ @Column({ nullable: true })
89
+ @Field({ nullable: true, description: 'SHA-256 of the thumbnail picture; the picture is served by /figure-thumbnail/:id.' })
90
+ thumbnailHash?: string
91
+
78
92
  /*
79
93
  * 그때의 비용이다. 오늘 다시 계산하면 오늘의 형식으로 나온다 — 그 판을 설명하지 못한다.
80
94
  */
@@ -110,6 +124,21 @@ export class FigureHistory implements HistoryEntityInterface<Figure> {
110
124
  })
111
125
  changes?: string
112
126
 
127
+ @Column({ nullable: true, ...longTextColumn() })
128
+ @Field({
129
+ nullable: true,
130
+ description:
131
+ '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.'
132
+ })
133
+ metrics?: string
134
+
135
+ @Column({ nullable: true, ...longTextColumn() })
136
+ @Field({
137
+ nullable: true,
138
+ description: 'The plan from above at the authored size, as JSON (figure-model figureTopViewOf): { shapes: [{ id, color, outline, holes, top, transparent }], size }.'
139
+ })
140
+ topView?: string
141
+
113
142
  @Column({ nullable: true })
114
143
  @Field({ nullable: true, description: 'When the original figure was created.' })
115
144
  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')
@@ -56,7 +65,7 @@ export class FigureMutation {
56
65
  if (!id) {
57
66
  if (!creationId || !/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(creationId))
58
67
  throw Error('새 모델 저장에는 생성 요청 식별자가 필요합니다.')
59
- const prepared = { ...prepareFigureWrite(source), ...pictureOf(thumbnail) }
68
+ const prepared = { ...prepareFigureWrite(source), ...(await pictureOf(tx, domain, user, thumbnail)) }
60
69
  const previous = await repository.findOne({ where: { id: creationId, domain: { id: domain.id } } })
61
70
  if (previous) {
62
71
  if (previous.source !== prepared.source) throw Error('이 생성 요청은 이미 다른 내용으로 저장되었습니다. 저장된 모델을 다시 열어 주세요.')
@@ -91,7 +100,7 @@ export class FigureMutation {
91
100
  // Exact tenant ownership: an inherited catalog entry is not writable in the child domain.
92
101
  const figure = await repository.findOne({ where: { id, domain: { id: domain.id } } })
93
102
  if (!figure) throw Error('편집할 Figure를 찾을 수 없습니다.')
94
- const prepared = { ...prepareFigureWrite(source, figure, expectedSource), ...pictureOf(thumbnail, figure) }
103
+ const prepared = { ...prepareFigureWrite(source, figure, expectedSource), ...(await pictureOf(tx, domain, user, thumbnail, figure)) }
95
104
  // Compare-and-swap in the UPDATE itself: supported by SQLite as well as PostgreSQL.
96
105
  // A pessimistic row lock is not supported by the product's SQLite deployment.
97
106
  await compareAndSwapFigure(criteria => repository.update(criteria, { ...prepared, updater: user }),
@@ -119,12 +128,13 @@ export class FigureMutation {
119
128
  // 형식을 어기면 여기서 던진다. 파생값은 서버가 계산한다.
120
129
  const { source, derived, violations } = parseSource(figure.source)
121
130
 
131
+ const { thumbnail, ...rest } = figure
122
132
  const saved = await repository.save({
123
- ...figure,
133
+ ...rest,
124
134
  source,
125
135
  ...derived,
126
136
  state: figure.state ?? 'draft',
127
- thumbnailUpdatedAt: figure.thumbnail ? new Date() : undefined,
137
+ ...(await pictureOf(tx, domain, user, thumbnail)),
128
138
  domain,
129
139
  creator: user,
130
140
  updater: user
@@ -174,6 +184,11 @@ export class FigureMutation {
174
184
  행과 정본 둘 다 고친다. 정본 안에도 `type` 이 있어서 한쪽만 고치면 저장된 JSON 과
175
185
  행이 다른 이름을 말하고, 발행할 때 어느 쪽이 도면에 적히는지가 갈린다.
176
186
  */
187
+ if (patch.names !== undefined) assertNames(patch.names)
188
+ /* The picture is kept by its content; the patch's data URL is never written to the row. */
189
+ const { thumbnail: picturePatch, ...patchRest } = patch
190
+ const picture = await pictureOf(tx, domain, user, picturePatch, figure)
191
+
177
192
  let renamed: string | undefined
178
193
  if (patch.type !== undefined && patch.type !== figure.type) {
179
194
  const change = await this.renameType(figure, domain, patch.type, source, tx)
@@ -195,7 +210,7 @@ export class FigureMutation {
195
210
  */
196
211
  domain,
197
212
  ...figure,
198
- ...patch,
213
+ ...patchRest,
199
214
  /* 앞뒤 공백을 떼고 겹침까지 본 이름. `patch.type` 을 그대로 두면 다듬기 전 값이 들어간다. */
200
215
  ...(renamed !== undefined ? { type: renamed } : {}),
201
216
  source,
@@ -209,10 +224,8 @@ export class FigureMutation {
209
224
  state: 'draft',
210
225
  ...(derived ?? {}),
211
226
  // 썸네일이 **썸네일로서** 바뀐 때만 시각을 갱신한다. 이름·설명 변경에는 안 건드린다.
212
- thumbnailUpdatedAt:
213
- patch.thumbnail !== undefined && patch.thumbnail !== figure.thumbnail
214
- ? new Date()
215
- : figure.thumbnailUpdatedAt,
227
+ thumbnail: figure.thumbnail ?? null,
228
+ ...picture,
216
229
  updater: user
217
230
  })
218
231
 
@@ -338,7 +351,9 @@ export class FigureMutation {
338
351
  /* 판본 구독자가 이것들을 판본의 comment · assetFormat · changes 로 옮긴다. Figure 의 칸이 아니다. */
339
352
  __releaseComment: comment ?? null,
340
353
  __releaseFormat: record.format,
341
- __releaseChanges: record.changes
354
+ __releaseChanges: record.changes,
355
+ __releaseMetrics: record.metrics,
356
+ __releaseTopView: record.topView
342
357
  } as any)
343
358
  }
344
359
 
@@ -408,6 +423,37 @@ export class FigureMutation {
408
423
  return true
409
424
  }
410
425
 
426
+ /**
427
+ * 퇴역시키거나 되살린다(ADR-0104 결정 6).
428
+ *
429
+ * 표시일 뿐이다. 발행판은 그대로 남고 받는 쪽은 그대로 받는다 — 발행판은 지우지 않는다. 그래서 **발행을 풀지 않는다**:
430
+ * 행을 엔티티로 저장하지 않고 질의로 고친다. 엔티티로 저장하면 발행된 행이라 판본 구독자가 같은 번호로 판본을 또
431
+ * 뜬다. 대신 쓸 것(`supersededBy`)은 같은 도메인(또는 상위)에 있는 다른 type 이어야 한다.
432
+ */
433
+ @Directive('@transaction')
434
+ @Directive('@privilege(category: "figure", privilege: "mutation", domainOwnerGranted: true)')
435
+ @Mutation(returns => Figure, { description: 'Retires a figure or brings it back. It stays released and served; supersededBy names the type to use instead.' })
436
+ async setFigureDeprecation(
437
+ @Arg('id') id: string,
438
+ @Arg('deprecated') deprecated: boolean,
439
+ @Ctx() context: ResolverContext,
440
+ @Arg('supersededBy', { nullable: true }) supersededBy?: string
441
+ ): Promise<Figure> {
442
+ const { domain, tx } = context.state
443
+ const repository = tx.getRepository(Figure)
444
+ const scope = { id: In([domain.id, domain.parentId].filter(Boolean)) }
445
+ const figure = await repository.findOne({ where: { domain: scope, id } })
446
+ if (!figure) throw new Error(`Figure ${id} 를 찾을 수 없다`)
447
+ const instead = deprecated ? supersededBy?.trim() || null : null
448
+ if (instead) {
449
+ if (instead === figure.type) throw new Error(`Figure '${figure.type}' 는 자기 자신으로 대신할 수 없다`)
450
+ const other = await repository.findOne({ where: { domain: scope, type: instead } })
451
+ if (!other) throw new Error(`대신 쓸 Figure '${instead}' 가 이 도메인에 없다`)
452
+ }
453
+ await repository.createQueryBuilder().update(Figure).set({ deprecated, supersededBy: instead }).where('id = :id', { id }).callListeners(false).execute()
454
+ return await repository.findOneOrFail({ where: { id } })
455
+ }
456
+
411
457
  /**
412
458
  * 저작 보조에게 제안을 시킨다(ADR-0094).
413
459
  *
@@ -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
  })
@@ -47,6 +47,15 @@ export class FigureReplacedDraft {
47
47
  @Column({ nullable: true, ...longTextColumn() })
48
48
  thumbnail?: string
49
49
 
50
+ /*
51
+ * 그림의 내용 해시(SHA-256). 그림 자체는 attachment-base 가 갖고(refType `figure-thumbnail`, refBy 이 해시),
52
+ * `/figure-thumbnail/:id` 가 권한을 본 뒤 내준다(ADR-0104 결정 6). `thumbnail` 칸은 옛 base64 가 남은 행을
53
+ * 기동 때 옮기기 위한 칸이다 — 옮긴 뒤에는 비어 있고, 새 그림은 그 칸에 쓰지 않는다.
54
+ */
55
+ @Column({ nullable: true })
56
+ @Field({ nullable: true, description: 'SHA-256 of the thumbnail picture; the picture is served by /figure-thumbnail/:id.' })
57
+ thumbnailHash?: string
58
+
50
59
  @Column({ nullable: true })
51
60
  @Field({ nullable: true, description: 'The state the figure was in.' })
52
61
  state?: string