@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.
- package/dist-server/index.js +15 -0
- package/dist-server/index.js.map +1 -1
- package/dist-server/routers/figure-thumbnail-router.d.ts +12 -3
- package/dist-server/routers/figure-thumbnail-router.js +36 -30
- package/dist-server/routers/figure-thumbnail-router.js.map +1 -1
- package/dist-server/service/figure/event-subscriber.js +4 -1
- package/dist-server/service/figure/event-subscriber.js.map +1 -1
- package/dist-server/service/figure/figure-format.d.ts +5 -3
- package/dist-server/service/figure/figure-format.js +21 -5
- package/dist-server/service/figure/figure-format.js.map +1 -1
- package/dist-server/service/figure/figure-history.d.ts +4 -0
- package/dist-server/service/figure/figure-history.js +27 -0
- package/dist-server/service/figure/figure-history.js.map +1 -1
- package/dist-server/service/figure/figure-mutation.d.ts +10 -0
- package/dist-server/service/figure/figure-mutation.js +65 -9
- package/dist-server/service/figure/figure-mutation.js.map +1 -1
- package/dist-server/service/figure/figure-replaced-draft.d.ts +1 -0
- package/dist-server/service/figure/figure-replaced-draft.js +5 -0
- package/dist-server/service/figure/figure-replaced-draft.js.map +1 -1
- package/dist-server/service/figure/figure-revert.js +9 -4
- package/dist-server/service/figure/figure-revert.js.map +1 -1
- package/dist-server/service/figure/figure-thumbnail-store.d.ts +35 -0
- package/dist-server/service/figure/figure-thumbnail-store.js +96 -0
- package/dist-server/service/figure/figure-thumbnail-store.js.map +1 -0
- package/dist-server/service/figure/figure-thumbnail-upgrade.d.ts +8 -0
- package/dist-server/service/figure/figure-thumbnail-upgrade.js +56 -0
- package/dist-server/service/figure/figure-thumbnail-upgrade.js.map +1 -0
- package/dist-server/service/figure/figure-type.d.ts +1 -0
- package/dist-server/service/figure/figure-type.js +5 -0
- package/dist-server/service/figure/figure-type.js.map +1 -1
- package/dist-server/service/figure/figure-write.d.ts +14 -10
- package/dist-server/service/figure/figure-write.js +9 -4
- package/dist-server/service/figure/figure-write.js.map +1 -1
- package/dist-server/service/figure/figure.d.ts +4 -0
- package/dist-server/service/figure/figure.js +21 -0
- package/dist-server/service/figure/figure.js.map +1 -1
- package/dist-server/service/figure/index.d.ts +1 -1
- package/dist-server/service/index.d.ts +1 -1
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/package.json +5 -4
- package/server/index.ts +15 -0
- package/server/routers/figure-thumbnail-router.ts +40 -36
- package/server/service/figure/event-subscriber.ts +4 -1
- package/server/service/figure/figure-deprecation-db.test.ts +107 -0
- package/server/service/figure/figure-format.test.ts +19 -1
- package/server/service/figure/figure-format.ts +27 -8
- package/server/service/figure/figure-history.ts +29 -0
- package/server/service/figure/figure-mutation.ts +56 -10
- package/server/service/figure/figure-release-record-db.test.ts +5 -0
- package/server/service/figure/figure-replaced-draft.ts +9 -0
- package/server/service/figure/figure-revert-db.test.ts +6 -5
- package/server/service/figure/figure-revert.ts +9 -4
- package/server/service/figure/figure-thumbnail-db.test.ts +218 -0
- package/server/service/figure/figure-thumbnail-store.ts +107 -0
- package/server/service/figure/figure-thumbnail-upgrade.pg.test.ts +93 -0
- package/server/service/figure/figure-thumbnail-upgrade.ts +70 -0
- package/server/service/figure/figure-type.ts +5 -0
- package/server/service/figure/figure-write.ts +15 -4
- package/server/service/figure/figure.ts +30 -0
- package/translations/en.json +4 -1
- 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.
|
|
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.
|
|
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/
|
|
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": "
|
|
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
|
-
* 썸네일은
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
95
|
-
경우를 위해 시각을 함께 실어 다시 물어볼 수 있게 한다.
|
|
94
|
+
그림은 **그 figure 의 도메인**에서 찾는다 — 묻는 도메인이 아니다(판정 조건 4). 해시가 같아도 다른 도메인의 행을
|
|
95
|
+
집지 않는다. 물려받은 figure 의 그림은 부모 도메인에 있다.
|
|
96
96
|
*/
|
|
97
|
-
const
|
|
97
|
+
const picture = await thumbnailAttachmentOf({ getRepository }, found.domain!.id, found.thumbnailHash)
|
|
98
|
+
if (!picture) {
|
|
99
|
+
context.status = 404
|
|
100
|
+
return
|
|
101
|
+
}
|
|
98
102
|
|
|
99
|
-
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
|
|
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`)
|
|
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
|
-
|
|
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,
|
|
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(
|
|
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
|
-
|
|
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 {
|
|
484
|
+
return { ...measured, changes: null }
|
|
466
485
|
}
|
|
467
|
-
return {
|
|
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
|
-
...
|
|
133
|
+
...rest,
|
|
124
134
|
source,
|
|
125
135
|
...derived,
|
|
126
136
|
state: figure.state ?? 'draft',
|
|
127
|
-
|
|
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
|
-
...
|
|
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
|
-
|
|
213
|
-
|
|
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
|